# How to integrate with Asana Source: https://docs.qawolf.com/Asana Connect QA Wolf to Asana to automatically create and sync bug report issues in your Asana workspace. Select **Workspace settings** from the `Workspace name` dropdown above the flows list. Click **Integrations** from the left-side navigation bar. Click **Enable** next to Asana. Click **Connect** on the modal. Enable **Bug reports** and select the **Workspace** and **Project** in which bug reports are going to be created. * Click **Disconnect** to disable the integration. # How to integrate with Azure DevOps Boards Source: https://docs.qawolf.com/Azure-DevOps-Boards Connect QA Wolf to Azure DevOps Boards to automatically create and sync bug report issues using OAuth. Go to `https://dev.azure.com/ORGANIZATION_NAME/_settings/organizationPolicy` — **ORGANIZATION\_NAME** is your Azure DevOps Organization name. **Because** QA Wolf connects to Azure DevOps using OAuth, you must enable **Third-party application access via OAuth**. If **Third-party application access via OAuth** is disabled, an Azure DevOps organization owner must enable it before QA Wolf can be authorized. If you are not an organization owner, contact your Azure DevOps administrator. Select **Workspace settings** from the **Workspace name** dropdown above the flows list. Click **Integrations** from the left-side navigation bar. Click **Enable** next to Azure DevOps. Click on the connect button. Select the **Azure DevOps** service. Enter the Azure DevOps **Organization name** and click **Continue**. Click **Configuration**, then select the target project from the dropdown. * Click **Disconnect** at any time to disable the integration. # Integrate a mobile build with Fastlane Source: https://docs.qawolf.com/Fastlane Upload Android and iOS build artifacts and trigger QA Wolf test runs from your existing Fastlane lanes using the QA Wolf plugin. ## When to use Fastlane This guide is for teams that already use Fastlane to build Android or iOS apps. If your mobile builds are defined as Fastlane lanes, the QA Wolf Fastlane plugin lets you upload build artifacts and trigger test runs within your existing mobile automation. This is the recommended option for mobile teams that use Fastlane today. If you do not use Fastlane, use the QA Wolf CI SDK instead. QA Wolf does not set up Fastlane or create build lanes—this guide assumes Fastlane is already installed and that it is producing a mobile build artifact. ## Before you begin Before configuring the Fastlane integration, make sure you have the following in place: * A working Fastlane setup for your Android or iOS app. * A Fastlane lane that produces a mobile build artifact (APK, AAB, or IPA). * A QA Wolf API key stored as a secret in your CI environment. * Artifact naming conventions are defined for your environments. See [Artifact naming conventions](#artifact-naming-conventions) below. Before mobile test runs can execute, QA Wolf must enable mobile triggers for your workspace. QA Wolf will handle this and may ask you for: * Which environments you want to test. * Whether PR testing is enabled. * The artifact naming conventions you are using. * The upload and trigger method you chose. Until this step is complete, CI jobs can upload artifacts and send deployment notifications, but mobile test runs will not start automatically. ## How Fastlane works with QA Wolf The Fastlane integration uses a QA Wolf Fastlane plugin to perform two actions: Upload a mobile build artifact to QA Wolf. Notify QA Wolf of a deployment event so a test run can be triggered. You can upload builds without triggering runs, which is useful during initial setup or validation. The artifact name you provide is used to associate the build with the correct environment. QA Wolf automatically applies the file extension. ## Install & configure Fastlane for QA Wolf Add the [QA Wolf Fastlane plugin](https://github.com/qawolf/fastlane-plugin-qawolf) to your project if it is not already installed. This is typically done in your **Fastfile** or plugin configuration, depending on how your project manages Fastlane plugins. Follow the instructions on the `README` to install. Make sure your CI environment exports the **QAWOLF\_API\_KEY** environment variable so Fastlane can authenticate with QA Wolf. ### Find the QAWOLF\_API\_KEY Open the `Workspace name` dropdown in QA Wolf and click **Workspace Settings**. Choose **Integrations**. Generate your **QAWOLF\_API\_KEY** by clicking the icon to the right of **API Key** under **API Access**. ### Add the QAWOLF\_API\_KEY secret Store `QAWOLF_API_KEY` as a secret in your CI system and ensure it is exported as an environment variable in the job that runs Fastlane. The exact steps depend on your CI provider — refer to your CI system's documentation for storing secrets. ## Artifact naming conventions Mobile build artifacts must follow consistent naming conventions so QA Wolf can correctly associate each build with the right environment and make failures easier to diagnose. The artifact name is used to identify: * Which environment the build belongs to * Whether the build is tied to a pull request * Which build was used for a given test run ### Static environments Static environments are long-lived environments such as staging or release environments. **Format** ```text theme={null} - ``` **Example** ```text theme={null} app-staging ``` Use the same basename every time a build is generated for the same environment. ### PR (ephemeral) environments PR environments are short-lived and tied to a specific pull request. These are only relevant if PR testing is enabled. **Format** ```text theme={null} ---pr ``` **Example** ```text theme={null} app-myorg-myrepo-pr123 ``` Including the organization, repository, and pull request number ensures each build can be traced back to the correct change and environment. QA Wolf applies the file extension (.apk, .aab, or .ipa) automatically based on the uploaded artifact. You only need to provide the basename. ## Upload a mobile build artifact Once your Fastlane lane produces a build artifact, you can upload it to QA Wolf. Add the `upload_to_qawolf` action to your lane and provide the artifact basename that matches your naming conventions. If this step completes successfully, the artifact has been uploaded and is available for use in test runs. ```ruby theme={null} lane :qawolf do # Build your app gradle( task: "assemble", build_type: "Release" ) # Upload the artifact to QA Wolf upload_to_qawolf( executable_file_basename: "app-staging" ) end ``` ## Trigger a test run After uploading a build artifact, you can notify QA Wolf that a new deployment is ready for testing. Add the `notify_deploy_qawolf` action to your lane. This step is optional and is required only if you want a test run to start automatically. The environment key must match the value configured by QA Wolf for your workspace. If mobile triggers have not yet been enabled, this step will complete without starting a test run. ```ruby theme={null} notify_deploy_qawolf( executable_environment_key: "RUN_INPUT_PATH" ) ``` ## Verify the integration Run the Fastlane lane in CI. Confirm the artifact upload step completes successfully. Confirm the deploy notification step runs without errors. Once mobile triggers are enabled, check the **Runs** tab to see the triggered test run. If no run appears, the most common cause is a missing or incomplete QA Wolf platform configuration. ## Troubleshooting and common issues * **Artifact uploads succeed, but no runs start:** Mobile triggers may not be enabled yet. Contact QA Wolf to complete platform configuration. * **Artifact not found during test execution:** Verify the artifact basename matches the expected naming conventions. * **Authentication errors:** Confirm QAWOLF\_API\_KEY is set correctly in your CI environment. * **Incorrect environment used:** Verify the environment key passed to notify\_deploy\_qawolf matches the configured value. # Full Service FAQ Source: https://docs.qawolf.com/Full-Service-FAQs Answers to common questions about QA Wolf full service, including test ownership, coverage, and how the service works. ## General ### What is QA Wolf's full service? QA Wolf's full service means we don't just provide the platform — we also create, maintain, and run your end-to-end tests for you. Our team writes Playwright and Appium tests, expands coverage over time, fixes broken tests as your product changes, and ensures tests run reliably on every pull request, schedule, or trigger you choose. You still own the code and can modify it at any time. We simply handle the ongoing work required to keep end-to-end coverage healthy. With full service, QA Wolf: * Writes and expands your end-to-end tests * Maintains tests as your app evolves * Runs tests in parallel across our infrastructure * Investigates failures and fixes test issues * Provides clear artifacts like videos, logs, and traces for every run The goal is simple: you get high end-to-end coverage without needing to build and operate a large internal test automation team. ### Who will we be working with from QA Wolf? Every customer gets a dedicated team of Wolves to make sure that they're successful: * **Customer Success Manager.** Strategic owner on QA Wolf's side for our partnership, helping ensure we are aligned on your goals and helping you get the most out of our service. * **QA Lead.** Primary point of contact for technical implementation, including your test plan, implementation, and testing of new features. * **QA Team.** Responsible for implementing tests, investigating failures, maintaining tests, and reporting bugs. * **Account Executive.** Your AE will guide you through contracting, security reviews, and any other internal procedures your company requires. ## Implementation ### In what order does QA Wolf build tests? Unless there are specific high-priority tests that need to be done first, we generally start with the most complex flows. These take longer to build and often require more collaboration with your team, so tackling them early helps speed up the rest of the process. Test creation isn't linear — we don't build one test after another in sequence. Because we focus on the hardest flows first, your suite will come together unevenly at first and then accelerate as simpler tests are added. If you have critical flows you'd like prioritized earlier, let your QA Lead know. They can adjust the build plan to align with your release schedule. ### How can I see how many flows your team has built? Open the Flows tab. Select **All Flows** from the left sidebar. The total number of groups and flows appears on the right panel at the top. #### How can I see the number of active flows your team has built? Set your filter using the icon. Hover over your team name in **All Flows** above to see the icon, then click it to get the icon. Below the list to the right, the number to the right of the icon shows the total number of flows, and the number to the right of the icon shows the total number of tests that meet the filter criteria. ### When will our tests start running? Tests start running as soon as they're built, and our goal is to deliver value fast — beginning with your most business-critical flows. To keep things moving: * Make sure your team is available for the **Product Tour** meeting. * Approve the **coverage outline** as soon as it's ready. Engage with us early, so we prioritize the right flows and start catching issues within the first few days of onboarding. ### What happens when a test fails? When tests fail, a QA Wolf engineer investigates the issue and determines whether it is a bug in the application or the test. If there's a bug in the application, we will file a bug report through your messaging system (e.g., Slack, Teams) and issue tracker (e.g., Jira, Linear). If there's a problem with the test, it can usually be resolved on the fly. However, some tests will need to be quarantined for more substantial maintenance work. You can monitor the status of failed tests in the Runs tab. ### How can we prevent planned application changes from blocking a release? One of the most common release blockers is a flow that requires maintenance before it can proceed. Most maintenance is simple and happens immediately, but sometimes a flow requires a more comprehensive update. When that happens, the affected flows are disabled and placed in **maintenance mode** until they can be fixed. If you're planning changes that will affect more than a few pages or significantly alter the DOM, let your QA Lead know ahead of time. They can usually update or refactor the flows in advance so runs stay smooth. When in doubt, just shoot us a quick message — even a heads-up helps prevent coverage gaps or blocked runs. ### How does QA Wolf handle flaky tests? As a customer, you'll never have to deal with flaky tests, which are tests that fail one or more times before eventually passing. Since flaky tests create noise that would otherwise slow your release velocity, our dedicated QA team will handle everything and ensure you see only real, human-verified bugs. That's our Zero Flake Guarantee. To keep things moving as quickly as possible, any test that fails is automatically re-run. That's because anything from a simple network hiccup to a slow email server could cause a flake. After multiple consecutive failures, a human steps in to investigate the root cause. ### How often will our test suite run? By default, your suite will run once a day, which is where most companies start. As their deployment processes mature, they increase testing frequency. We also support PR testing for [GitHub](/PR-testing-for-GitHub-Integrations) and [GitLab](/PR-testing-for-GitLab-Integrations) through our Scheduled Runs. You can ask your QA Lead to increase or decrease your test runs to best fit your process. Unlimited test runs are included in your contract, so we encourage running your suite as frequently as it makes sense for your team and process. # Onboarding to QA Wolf Full Service Source: https://docs.qawolf.com/Full-Service-The-First-Three-Months What to expect during QA Wolf full service onboarding, including timelines, meetings, and setup steps. QA Wolf Full Service pairs your team with a dedicated QA engineering team that builds, runs, and maintains your automated test suite. If you're evaluating QA Wolf, [book a demo](https://www.qawolf.com/book-a-demo) to talk through your coverage goals. ## Onboarding Onboarding with QA Wolf is easy, but a few things need to be set up before we can begin developing a Test Plan and building your automated test suite. ### Timeline 1 to 2 weeks after contract start if all blockers are cleared. ### Onboarding to-dos * Connect your Slack, Teams, or Discord account with QA Wolf. * Send test environment credentials to your Account Executive, QA Engineering Lead, or Customer Success Manager. The environment needs to be stable and reachable before onboarding can begin. * Read: [Preparing an environment for QA Wolf test runs](/Test-environments) * Identify a Champion (day-to-day) point of contact for QA Wolf who can clear blockers and make decisions about your test suite. ### Your QA team * Read: [Who will we be working with from QA Wolf?](/Full-Service-FAQs#who-will-we-be-working-with-from-qa-wolf) ### Kick-off meeting **When** Within 5 business days of the contract start date. **Agenda** * Introductions. * Review coverage goals. * Set expectations for the process. * Align on milestones. **Attending** | From your team | From our team | | :------------------------------------------------------------------------------- | :----------------------- | | Point of contact during the sales process | Customer Success Manager | | Champion (day-to-day contact) who will be most hands-on working with us | QA Lead | | Any other team members you'd like to include (engineers, product managers, etc.) | | **Action items** | Your responsibilities | Our responsibilities | | :------------------------------------------------- | :---------------------------------------------- | | Connect a shared Slack, Teams, or Discord channel. | Learn about QA priorities and onboarding goals. | | Share your environment credentials. | | ### Product tour meeting **When** Within 5 business days of the contract start date. **Agenda** * Introductions. * Walk through your app. * Identify testing priorities. * Identify areas to avoid. **Attending** | From your team | From our team | | :----------------------------------------------------------- | :------------------ | | Product or engineering manager for each testable feature set | QA Engineering Lead | | | Test outliner | **Action items** | Your responsibilities | Our responsibilities | | :---------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------- | | Internally align on the product(s) that QA Wolf will be working with and what areas are the highest priority to build coverage for. | Record the product tour. | | | Take detailed notes for test plan creation. | | | Ask clarifying questions about priorities and product functionality. | ## Implementation Once we understand your application and testing priorities, we're ready to start outlining a Test Plan and coding up your automated test suite. ### Timeline * Planning phase: 2 to 4 weeks after the contract start date if all blockers are cleared. * Test creation phase: The next 2 to 3 months after the Test Plan is approved. ### Test planning to-dos * **Send over any existing test plans or internal priority lists.** Anything you have is great: Google Sheets, a TestRail matrix, or just a list of priority features. If you don't have anything, don't worry — we'll work with you to determine priorities and coverage areas. * **Export and send your existing E2E tests — if you have any.** We can convert that code to QA Wolf outlines and test code, or avoid covered product areas if you plan to continue running and maintaining them internally. ### First Test Plan review meeting **When** Within 15 business days of the contract start date. **Agenda** * Introductions. * Review the test plan. * Re-confirm priorities. * Provide feedback on the test plan's coverage areas. * Confirm the scope of work. **Attending** | From your team | From our team | | :----------------------------------------------------------- | :------------------ | | Product or engineering manager for each testable feature set | QA Engineering Lead | | | Test outliner | **Action items** | Your responsibilities | Our responsibilities | | :-------------------------------------------------------------------- | :----------------------------------------------------------------- | | Answer questions from the test outliners on Slack, Teams, or Discord. | Prepare detailed outlines for all tests within budget. | | | Field questions about the test plan and outlining process. | | | Respond to outline feedback and changes in your team's priorities. | ### Final Test Plan review **When** Within 20 business days of the contract start date. **Agenda** * Introductions. * Recap feedback from the first review. * Review changes/updates to the test plan. * Review the AAA framework. * Finalize the scope of work. **Attending** | From your team | From our team | | :----------------------------------------------------------- | :------------------ | | Product or engineering manager for each testable feature set | QA Engineering Lead | | | Test outliner | **Action items** | Your responsibilities | Our responsibilities | | :------------------------------------- | :------------------------------------------------------------------------------------------------------------------------ | | Identify any remaining gaps or issues. | Convert all approved outlines to the [AAA framework](https://www.qawolf.com/blog/intro-to-aaa) in preparation for coding. | | Approve the scope of work. | Update outlines and priorities based on feedback from the first review. | ### Common test planning blockers * Your stakeholders aren't aligned on testing goals and priorities, which prevents us from finalizing a Test Plan. * Your team is unresponsive when QA Wolf reports possible bugs or asks for product clarification. * Your team is unavailable to review the test plan. ### Common test creation blockers * Your test environment is too unstable to run end-to-end tests reliably. Read: [Preparing an environment for QA Wolf test runs](/Test-environments). # How to integrate GitHub/GitHub Actions Source: https://docs.qawolf.com/GitHub-GitHub-Actions Connect QA Wolf to GitHub Actions to automatically trigger test runs when you deploy code from your repository. If your repositories are hosted on GitHub.com or GitHub Enterprise Cloud, this is the recommended setup. The integration connects directly to your QA Wolf environments using GitHub Actions to notify QA Wolf of new deployments. If your team self-hosts GitHub Enterprise Server without internet access, or has disabled GitHub Actions by policy, use the [QA Wolf CI SDK](/other-ci-node) instead. Make sure you have: * Access to your QA Wolf workspace. * Admin access to your GitHub repository. ## Connect QA Wolf to GitHub Enable the GitHub integration from **Workspace Settings → Integrations** in QA Wolf. Install the QA Wolf GitHub App on the repositories you want connected. Your user cannot be a member of multiple GitHub orgs. Generate a `QAWOLF_API_KEY` from **Workspace Settings → Integrations → API Access**. Add it as a `QAWOLF_API_KEY` secret in your repository's **Settings → Secrets and variables → Actions**. ## Add the GitHub Actions workflow QA Wolf's official GitHub Action, [Notify QA Wolf on Deploy](https://github.com/marketplace/actions/notify-qa-wolf-on-deploy), notifies QA Wolf whenever a new deployment is ready for testing. This must be done for every repository you want connected to CI/CD. Add a workflow file (e.g. `.github/workflows/deploy.yml`) with the following, replacing `deployment-type` and `deployment-url` with your actual values. To get the value for `deployment-type`, reach out to QA Wolf. ```yaml theme={null} - name: Notify QA Wolf uses: qawolf/notify-qawolf-on-deploy-action@v1 with: qawolf-api-key: ${{ secrets.QAWOLF_API_KEY }} deployment-type: "staging" deployment-url: "https://staging.example.com" ``` ## Verify the integration Push a commit to trigger a deployment. Confirm a new run appears in QA Wolf under the expected environment. # How to integrate with GitLab Source: https://docs.qawolf.com/GitLab Connect QA Wolf to GitLab CI/CD to automatically trigger test runs when you deploy code from your repository. If your repositories are hosted on GitLab.com or on a GitLab Enterprise Server instance with internet access, this is the recommended setup. The integration connects directly to your QA Wolf environments. If your GitLab instance does not allow outbound HTTPS connections (for example, an air-gapped GitLab Enterprise installation), use the QA Wolf CI SDK instead. Make sure you have: * Admin or Maintainer access to the GitLab project. * Access to your QA Wolf workspace. * At least one QA Wolf environment already configured. * A QA Wolf API key. ## Connect QA Wolf to GitLab Enable the GitLab integration from **Workspace Settings → Integrations** in QA Wolf. You'll be asked to provide a **Group Access Token** with the **Maintainer** role and **API** scope. The Maintainer role is required because Developer tokens cannot create pipeline checks on protected branches. Once connected, QA Wolf can set commit statuses and link test runs to GitLab commits. Generate a `QAWOLF_API_KEY` from **Workspace Settings → Integrations → API Access**. Add it as a `QAWOLF_API_KEY` CI/CD variable in your GitLab project under **Settings → CI/CD → Variables**. You can also define additional variables, such as a deployment URL, if your pipeline uses them. ## Add the deploy notification to GitLab CI/CD QA Wolf provides a public API endpoint that your GitLab pipeline can call upon successful deployment. Add a job to your `.gitlab-ci.yml` that runs after your deploy step completes successfully. ```yml expandable theme={null} stages: - build - deploy - notify deploy: stage: deploy script: - ./deploy.sh environment: name: staging notify_qawolf: stage: notify script: - | curl -X POST "https://app.qawolf.com/api/webhooks/deploy_success" \ -H "Authorization: Bearer $QAWOLF_API_KEY" \ -H "Content-Type: application/json" \ -d "{ \"branch\": \"$CI_COMMIT_REF_NAME\", \"deployment_type\": \"staging\", \"deployment_url\": \"$QA_WOLF_DEPLOY_URL\", \"sha\": \"$CI_COMMIT_SHA\", \"hosting_service\": \"GitLab\" }" when: on_success needs: ["deploy"] ``` This job tells QA Wolf that a new deployment is ready for testing. ## Verify the integration Push a commit to trigger a deployment. Confirm a new run appears in QA Wolf under the expected environment. ## Related * [webhooks/deploy\_success](/deploy-success) * [REST API](/rest-overview) # Glossary Source: https://docs.qawolf.com/Glossary Definitions of key QA Wolf terms including workspaces, environments, groups, flows, runs, coverage outlines, and coverage maps. ## Workspace A **workspace** is the top-level container in the QA Wolf platform. All environments, groups, and related settings live inside a workspace. Use workspaces for separate applications, such as the web, iOS, and Android versions of your product. Or when different business units are responsible for completely separate test suites. ## Environment Under each workspace is an **environment**, which corresponds to a specific version of your app, such as development, staging, or production. Each environment can be configured with separate variables (e.g., base URL, users, etc.), concurrency rates, and other settings relevant to the version of the application you're testing. Flows and tests are unique for each environment and not shared between environments. ## Flow A **flow** is a sequence of tests that validates a user journey in your application. Flows are the primary unit of end-to-end testing in QA Wolf. Flows can be run individually, scheduled, and tagged. ## Test A **test** verifies a specific behavior or step in your application. Multiple tests combine to form a flow that validates a complete user journey. **Example: QA Wolf application hierarchy** | Item | Example | | :---------- | :---------------------- | | Workspace | Wolf Corp | | Environment | Staging | | Folder | accounts | | Flow | happy-path-ios.flow\.js | ## Coverage outline A **coverage outline** is the set of groups and flow stubs that define what needs to be tested in an environment. Outlining is the act of producing one: you point the agent at your application, it explores, and it proposes the groups and flows that represent the main areas and user journeys. An outline records scope — it does not contain test code until the flows are created. ## Coverage map The **coverage map** is the visual representation of a coverage outline. It shows the flows in an environment and how they are organized into groups, so you can see the shape of your coverage and find gaps in it. ## Run A **run** is an execution of one or more flows. Each flow is attempted up to three times per run if it fails. Runs can be started manually, invoked automatically by a schedule, or triggered by a deployment. The status and results of every flow in a run are recorded and reported back. ## Run status A run ends in one of three states: * **Completed** — all flows in the run have passed or been marked as not needing investigation * **Needs investigation** — one or more flows failed and have not been marked as resolved * **Canceled** — the run was canceled mid-execution by a subsequent run matching the same branch and environment A failed flow is considered resolved once it passes on a subsequent attempt, the flow itself is fixed and republished, the underlying application issue is fixed, or a maintenance report is filed against it (which excludes the flow from runs until it's fixed). ## Environment status An environment's status reflects the health of its most recent runs, and is driven by bug priority: * **Ready** — all flows passing * **Needs investigation** — one or more flows failed, or a medium/low priority bug has been reported against a flow * **Failed** — a high or urgent priority bug has been reported against a flow ## Attempt Within a run, a flow may be attempted multiple times. As a tester, you will occasionally have flows that flake — attempting to run a failed flow multiple times ensures that the failure is not a temporary fluke. QA Wolf will attempt to run a flow up to three times: the first attempt runs all flows concurrently, the second retries failures in batches of five, and the final attempt runs remaining failures serially (reducing concurrency further isolates whether an environment issue is at fault). Each attempt has its own video, logs, and results. ## Tag A **tag** groups flows for scheduled runs and controls execution order in run rules. Tags are intended to express **run intent** (test type, expected duration) rather than application structure — QA Wolf runs with full concurrency by default, so organizing flows by feature area rarely adds value. ## Run Rule A **Run Rule** controls the order flows run in by defining a **before** set and an **after** set of flows, selected by name, group, or tag. By default, if a flow in the before set fails, flows in the after set are skipped rather than run, to keep the failure list focused on the root cause. ## Schedule A schedule defines when and how often runs occur automatically. Scheduling is limited to flows. Individual tests can be run manually for debugging, but they cannot be scheduled independently. Use schedules to execute flows at specific times (midnight) or at recurring intervals (every hour), or when a build is deployed. ## Trigger A run can be started four ways: manually, on a **schedule**, from a deployment webhook, or from a CI pull-request button. Of these, only schedule-based triggers are self-serve — deployment and PR-based triggers require setup from QA Wolf. # Integrate a mobile build with GitHub Actions Source: https://docs.qawolf.com/Integrate-a-mobile-build-with-GitHub-Actions Use the QA Wolf GitHub Actions to upload mobile build artifacts and trigger test runs directly from your existing workflows. ## When to use GitHub Actions This guide is for teams that use **GitHub Actions** as their CI system and want a simple, declarative way to upload mobile builds and trigger test runs without writing custom scripts. Use GitHub Actions if your repository already builds mobile artifacts in a GitHub Actions workflow and you prefer using prebuilt actions over maintaining Node.js scripts or Fastlane lanes. This guide assumes your mobile builds already run in GitHub Actions and produce a build artifact. ## Before you begin 1. Make sure your GitHub Actions workflow produces a mobile build artifact (APK, AAB, or IPA). 2. Artifact naming conventions are defined for your environments. See [Artifact naming conventions](#artifact-naming-conventions) below. Before mobile test runs can execute, QA Wolf must enable mobile triggers for your workspace. QA Wolf will handle this and may ask you for: * Which environments you want to test. * Whether PR testing is enabled. * The artifact naming conventions you are using. * The upload and trigger method you chose. Until this step is complete, CI jobs can upload artifacts and send deployment notifications, but mobile test runs will not start automatically. ## How GitHub Actions works with QA Wolf The GitHub Actions integration uses two QA Wolf-provided actions: Upload a mobile build artifact to QA Wolf. Notify QA Wolf of a deployment event to trigger a test run. You can upload builds without triggering runs, which is useful during initial setup or validation. You provide the [artifact basename](#artifact-naming-conventions) when uploading. QA Wolf applies the file extension automatically based on the uploaded file. ## Add QA Wolf actions to your workflow Add the QA Wolf actions to an existing GitHub Actions workflow that builds your mobile app. The workflow must run after the build artifact has been created. ### Find the QAWOLF\_API\_KEY Open the `Workspace name` dropdown in QA Wolf and click **Workspace Settings**. Choose **Integrations**. Generate your **QAWOLF\_API\_KEY** by clicking the icon to the right of **API Key** under **API Access**. ### Add the QAWOLF\_API\_KEY secret Open your GitHub repository and go to **Settings**. Select **Secrets and variables → Actions**. Add a new repository secret named `QAWOLF_API_KEY` and paste your API key. ## Artifact naming conventions Mobile build artifacts must follow consistent naming conventions so QA Wolf can correctly associate each build with the right environment and make failures easier to diagnose. The artifact name is used to identify: * Which environment the build belongs to. * Whether the build is tied to a pull request. * Which build was used for a given test run. ### Static environments Static environments are long-lived environments such as staging or release environments. **Format** ```text theme={null} - ``` **Example** ```text theme={null} app-staging ``` Use the same basename every time a build is generated for the same environment. ### PR (ephemeral) environments PR environments are short-lived and tied to a specific pull request. These are only relevant if PR testing is enabled. **Format** ```text theme={null} ---pr ``` **Example** ```text theme={null} app-myorg-myrepo-pr123 ``` Including the organization, repository, and pull request number ensures each build can be traced back to the correct change and environment. QA Wolf applies the file extension (.apk, .aab, or .ipa) automatically based on the uploaded artifact. You only need to provide the basename. ## Upload a mobile build artifact After your workflow produces a mobile build artifact, upload it to QA Wolf using the upload action. ```yaml theme={null} - name: Upload mobile build to QA Wolf id: upload-run-input uses: qawolf/upload-run-inputs-executable-action@v1 with: qawolf-api-key: ${{ secrets.QAWOLF_API_KEY }} input-file-path: ./path/to/build.apk executable-file-basename: app-staging ``` If this step completes successfully, the artifact is uploaded and available for test runs. ```yaml expandable highlight={1-25} theme={null} name: Deploy and Notify QA Wolf on: pull_request jobs: ... notify: needs: deploy-preview-environment name: Trigger QA Wolf PR testing runs-on: ubuntu-latest steps: ... # Upload the run input file - name: Upload Run Input id: upload-run-inputs-executable uses: qawolf/upload-run-inputs-executable-action@v1 with: qawolf-api-key: "${{ secrets.QAWOLF_API_KEY }}" input-file-path: "path/to/file.apk" - name: Notify QA Wolf of deployment uses: qawolf/notify-qawolf-on-deploy-action@v1 env: ... # Use the output in the RUN_INPUT_PATH environmental variable RUN_INPUT_PATH: "${{ steps.upload-run-inputs-executable.outputs.destination-file-path }}" ... with: ... ``` ## Trigger a test run After uploading the artifact, notify QA Wolf that a new deployment is ready for testing. ```yaml theme={null} - name: Notify QA Wolf of deployment uses: qawolf/notify-qawolf-on-deploy-action@v1 with: qawolf-api-key: ${{ secrets.QAWOLF_API_KEY }} deployment-type: android_app variables: > { "RUN_INPUT_PATH": "/home/wolf/run-inputs-executables/${{ steps.upload-run-input.outputs.destination-file-path }}" } ``` The deployment type and environment key must match the values configured by QA Wolf for your workspace. If mobile triggers have not yet been enabled, this step will complete without starting a test run. ## Verify the integration Run the GitHub Actions workflow. Verify that the artifact upload step completes successfully. Confirm that the deployment notification step runs without errors. Once mobile triggers are enabled, check the **Runs** tab for the triggered test run. ## Troubleshooting and common issues * **If uploads succeed but no runs start:** Mobile triggers may not yet be enabled. Contact QA Wolf to complete platform configuration. * **If the artifact is not found during execution:** Verify that the artifact basename matches your naming conventions and that the destination file path from the upload step is used to trigger the run. * **If you see authentication errors:** Verify that **QAWOLF\_API\_KEY** is configured correctly as a GitHub Actions secret. * **If the workflow fails before the QA Wolf steps run:** Verify that the mobile build step completes successfully and produces the expected artifact. # How to integrate a non-native bug tracker with webhooks Source: https://docs.qawolf.com/Integrate-with-webhooks If we don't support your bug tracker through a native integration, you can still use webhooks to integrate. We know that each implementation will vary. Please reach out and let us know if you need any support. ### Set up the integration Open the `Workspace name` dropdown and click **Workspace settings**. Navigate to **Integrations**. Click **Enable** next to Webhook. Click **Connect** in the modal. Define a webhook title. Define the webhook URL and enable "Notify when bug report is opened." This is the location of the webhook used by your bug tracker. When an issue is created, your endpoint will receive `POST` requests with a JSON body (`Content-Type: application/json`). Here's the expected structure and an example payload. #### Expected JSON Body Fields The primary data is in the request body: * `id` (`string`): Unique bug report ID. * `title` (`string`): Bug report title. * `description` (`string`): Detailed description (may contain newlines/links). * `url` (`string`): Link to view the bug report in QA Wolf. * `event` (`string`): `bug-report-opened`. #### Example Payload This example shows the full request structure your endpoint will receive: ```json JSON theme={null} { "id": "", "title": "Bug Report Title", "description": "🐞\n\nBug report description:\nYou can see it here.\n\nBug report:\nhttps://app.qawolf.com/test-client/bug-reports/...\n\nAffected workflows:\n\n\nIf you are aware of this bug you can set the priority to low which will prevent it from causing a run failure.", "url": "https://app.qawolf.com/bug-reports/...", "event": "bug-report-opened" } ``` #### Authentication If username and password are set, those will be used for basic HTTP authorization when performing the `POST` request against your webhook. Enable the "Notify when bug report is closed" toggle: Make your webhook respond to the `POST` request with a payload containing these fields: * `externalId` (`string`): ID of the issue for the QA Wolf bug report in your issue tracker. It will be used to identify issues that will need to be updated in the future. * `humanId` (`string`): Human-friendly issue ID that will be used on the QA Wolf UI, Slack messages and other integrations. * `url` (`string`): Link to the issue. **When a bug report is closed**, your endpoint will receive `POST` requests with a JSON body (`Content-Type: application/json`). Here's the expected structure and an example payload. #### Expected JSON Body Fields The primary data is in the request body: * `externalId` (`string`): The ID of the issue associated with the closed bug report. * `event` (`string`): `bug-report-closed`. # Integrating with Microsoft Teams Source: https://docs.qawolf.com/Integrating-with-microsoft-teams Set up Microsoft Teams to receive QA Wolf notifications by configuring external access and shared channels. ## Completion checklist In the [Teams Admin Center](https://admin.teams.microsoft.com/): Teams policies > Join external shared channels to On Users > External Access > Ensure QA Wolf is not blocked In the [Microsoft Entra admin center](https://entra.microsoft.com/) (**as a Security Administrator**): Add QA Wolf's tenant ID - `3d41cc11-5772-457c-aad2-16b3924b22d0` Verify access settings do not restrict access to or from QA Wolf, or adjust accordingly Share your Microsoft Entra tenant ID with QA Wolf ### Enable shared channels in Teams Shared channels are **enabled by default** in Teams. QA Wolf provides a shared channel for your Teams integration. To use it, your organization must have shared channels enabled. In the [Microsoft Teams admin center](https://admin.teams.microsoft.com/), expand **Teams** in the left navigation bar and choose **Teams policies**. Click the policy you want to confirm or change. Under *Shared channels settings*, turn on **Join external shared channels** to allow collaboration with other orgs. Click **Apply** to save your changes. ### Enable external access You must enable external access so QA Wolf team members can join your **Teams meetings** and your team can see QA Wolf's presence status (online/offline/away) in shared channels. In the [Teams admin center](https://admin.teams.microsoft.com/), expand **Users** in the left navigation bar and choose **External access**. Choose the appropriate option for allowing **Teams and Skype for Business users in external organizations**. **Do not** select **Block all external domains** — this setting prevents the integration from working. Allowing **qawolf.com** under **Allow only specific external domains** will ensure QA Wolf team members can participate while all other external domains remain blocked. ### Enable cross-tenant access in Microsoft Entra To use a shared channel with QA Wolf, you must allow our Microsoft Entra tenant by adding QA Wolf as an allowed organization using our Microsoft Entra tenant ID. As a **Security Administrator**, sign in to the [Microsoft Entra admin center](https://entra.microsoft.com/). Choose **External Identities** from the left navigation bar, then choose **Cross-tenant access settings**. On the **Organizational settings** tab, click **Add organization**. In the **Add Organization** drawer on the right, type QA Wolf's tenant ID: `3d41cc11-5772-457c-aad2-16b3924b22d0` and click **Add** at the bottom. If your default access settings restrict access, update your inbound and outbound B2B direct connect settings to allow QA Wolf. Still under Organizational Settings, you should see QA Wolf listed under **organizations found**. #### Check access Click on the Configured link underneath Inbound access or Outbound access. Click the B2B direct connect tab. Select the Customize settings radio button. On the **External users and groups** tab: * For **Access status**, select *Allow access* * For **Applies to**, select *All QA Wolf users and groups* After updating these settings, verify that your default access configuration no longer blocks access to or from QA Wolf. Shared channel collaboration will not work if the default settings continue to block external tenants. We use shared channels so customers can access them without switching tenants, and B2B Direct Connect must be used to invite or join them. **B2B Collaboration** will not work (see [*MS doc*](https://learn.microsoft.com/en-us/microsoft-365/solutions/collaborate-teams-direct-connect?view=o365-worldwide) for details). | B2B Collaboration | B2B Direct Connect | | ------------------------------- | ----------------------------------------- | | Applies to guest accounts | Enables direct shared channel invitations | | Requires tenant switching | Seamless access from own tenant | | Only supports standard channels | Fully supports shared channels | #### Complete setup We need your **Microsoft Entra tenant ID** and **domain name** so we can configure cross-tenant access on our side. Customers can locate their Tenant ID and domain on the [Microsoft Entra dashboard](https://entra.microsoft.com/#home). # How to integrate with Jira Source: https://docs.qawolf.com/Jira Connect QA Wolf to Jira to automatically create, sync, and close issue tickets from QA Wolf bug reports. ## Overview * **Automatic issue handling.** Creates Jira issues automatically when new bug reports are generated in QA Wolf, keeping Jira up to date without manual entry. Context added in Jira is not written back to QA Wolf. * **Synchronization.** Closing a bug report in QA Wolf will close the linked Jira ticket. QA Wolf also performs regular bug revalidations to ensure accuracy. Closing a Jira ticket should automatically update QA Wolf. Contact us if that doesn't happen so we can help you troubleshoot. * **Test creation requests.** Add a **QA Wolf** label to a Jira ticket to automatically create a coverage request in QA Wolf. * **Custom configuration.** Specify the project, assignee, and issue status. Currently, each integration setup supports one project, one assignee, and one status type. * **Custom fields.** Add custom Jira fields to bug reports if your Jira project uses them. Custom fields apply globally across all tickets created through QA Wolf, not per workflow. **Full Service Customers:** Need help setting up your JIRA integration? The QA Wolf team can configure this integration on your behalf. To get started, invite us as a user in your JIRA workspace using the following email convention: qa+\[yourcompanyname]@qawolf.com (e.g. [qa+acme@qawolf.com](mailto:qa+acme@qawolf.com)). ## Set up the integration Open the `Workspace name` dropdown and click **Workspace settings**. Navigate to **Integrations**. Click **Enable** next to Jira. The **Sync bug reports to Jira** modal appears. Click **Connect**. Select the Jira team you want to link to your QA Wolf team and click **Accept** to authorize the connection. Choose your Jira **Project**, **Issue Type**, and **Assignee**, then enable: * **Create bug issue report** * **Close bug issue report** This ensures bugs are created and closed automatically across both systems. If there are any custom fields you need us to configure, such as the issue description, please reach out and let us know. We can configure those for you. # How to integrate with Linear Source: https://docs.qawolf.com/Linear Connect QA Wolf to Linear to automatically create and sync bug report issues with your Linear team. Open the `Workspace name` dropdown and click **Workspace settings**. Navigate to **Integrations**. Click **Enable** next to **Linear**. A modal opens. Click **Connect**. Enter the **Team name** exactly as in Linear. Team name is case sensitive. Enable the **Create bug report issue** toggle and optionally set **Label UUID** and **Assignee UUID** to set a default label and assignee for newly created issues. # Managing maintenance reports Source: https://docs.qawolf.com/Manage-maintenance-reports File maintenance reports to quarantine failing flows caused by test issues, and resume them after fixes are applied. Maintenance reports are used when a failure is caused by incorrect, outdated, or fragile test logic rather than an issue in the application under test. Maintenance reports cannot be created directly from the Maintenance tab. They are created from the Investigation view when reviewing a failing flow. Minor test fixes should be made quickly when possible. File a maintenance report when a fix will not be completed before the next deployment or scheduled run, or when the change requires more involved updates to the test, such as refactoring. Maintenance reports are linked to one or more flows. While a maintenance report is open, those flows are skipped in scheduled runs. When the flows pass in a manual run, the maintenance report automatically closes, and the flows are included in schedules again. ## Creating a maintenance report Use a maintenance report for any failing flow that will take more than about 15 minutes to fix (a guideline, not a rule), and quarantine the flow. From the **Runs** tab, select the environment from the **Environments** list. In the center panel, select a run from **Investigating**. In the Investigation view, select the failing flow. In the center panel, click **Diagnose**. Click **Report as needing maintenance** to open the drawer for creating a maintenance report. Click the **Name** field to open a dropdown of existing maintenance reports. As you type, the list autocompletes to help you find a match. Selecting an existing report will add your flow to that report in the **Assign flows** field below. a. If you need a new report, type its name and press **Enter**, or choose the top option in the dropdown. After selecting a name, fill out the remaining form. a. **Description:** Provide a short explanation of why the flow(s) need maintenance. Use this to outline the issue, what's outdated or broken, and any relevant context. b. **Effort:** Estimate the effort required to update or repair the flow(s). c. **Priority:** Set the urgency for maintenance. Flows associated with higher priority reports appear more prominently in the environment status. d. **Reason for Maintenance:** Select the appropriate reason from the predefined list—these standard options help categorize and report on maintenance work consistently. e. **Environment (read-only):** This displays the environment affected by the maintenance. The system automatically populates the field. f. **Assigned Flows (read-only):** This displays the flows included in the maintenance report. g. New reports automatically include only the flow where you initiated the report. Submitting this change removes the flow from all runs. ## Viewing a maintenance report * To view all maintenance reports, open the **Maintenance** tab. * The tab includes two subtabs: 1. Filter by readiness status. 2. Search by report name. ## Closing a maintenance report A maintenance report must be closed manually. When you fix a test with an open maintenance report: Open the **Maintenance** tab, then click the maintenance report associated with the flow you fixed. Click **Run all flows**. This opens the Runs tab in a separate browser tab and starts a manual run of all flows associated with the maintenance report. Any flows that pass will be removed from the maintenance report. If all flows pass, the maintenance report will close automatically. # Integrate a mobile build with the QA Wolf SDK Source: https://docs.qawolf.com/Mobile-build-with-the-QA-Wolf-SDK Upload mobile build artifacts and trigger test runs from any Node.js-based CI system using the QA Wolf CI SDK. Use this guide if your CI system supports Node.js but is not GitHub Actions or Fastlane. Make sure you have: * A CI pipeline that produces a mobile build artifact (APK, AAB, or IPA). * Node.js 18 or later available in your CI environment. * Admin access to your CI system's secret or environment variable storage. * A QA Wolf API key. Before mobile test runs can execute, QA Wolf must enable mobile triggers for your workspace. QA Wolf will handle this and may ask you for: * Which environments you want to test. * Whether PR testing is enabled. * The artifact naming conventions you are using. * The upload and trigger method you chose. Until this step is complete, CI jobs can upload artifacts and send deployment notifications, but mobile test runs will not start automatically. ## Install the CI SDK Install the SDK in your CI job: ```bash theme={null} npm install @qawolf/ci-sdk ``` ## Find the QAWOLF\_API\_KEY Open the **Workspace name** dropdown in QA Wolf and click **Workspace Settings**. Choose **Integrations**. Generate your **QAWOLF\_API\_KEY** by clicking the icon to the right of **API Key** under **API Access**. Make sure the job has access to the `QAWOLF_API_KEY` environment variable. ## Artifact naming conventions Mobile build artifacts must follow consistent naming conventions so QA Wolf can correctly associate each build with the right environment and make failures easier to diagnose. The artifact name is used to identify: * Which environment the build belongs to. * Whether the build is tied to a pull request. * Which build was used for a given test run. ### Static environments Static environments are long-lived environments such as staging or release environments. **Format** ```text theme={null} - ``` **Example** ```text theme={null} app-staging ``` Use the same basename every time a build is generated for the same environment. ### PR (ephemeral) environments PR environments are short-lived and tied to a specific pull request. These are only relevant if PR testing is enabled. **Format** ```text theme={null} ---pr ``` **Example** ```text theme={null} app-myorg-myrepo-pr123 ``` Including the organization, repository, and pull request number ensures each build can be traced back to the correct change and environment. QA Wolf applies the file extension (.apk, .aab, or .ipa) automatically based on the uploaded artifact. You only need to provide the basename. ## Upload a mobile build artifact After your CI pipeline produces a mobile build artifact, upload it to QA Wolf using the SDK. **Minimal example** ```js Javascript expandable theme={null} import { makeQaWolfSdk } from "@qawolf/ci-sdk"; import fs from "fs/promises"; const sdk = makeQaWolfSdk({ apiKey: process.env.QAWOLF_API_KEY, }); async function uploadBuild() { const signedUrl = await sdk.generateSignedUrlForRunInputsExecutablesStorage({ destinationFilePath: "app-staging", }); const fileBuffer = await fs.readFile("./path/to/build.apk"); await fetch(signedUrl.uploadUrl, { method: "PUT", body: fileBuffer, headers: { "Content-Type": "application/octet-stream", }, }); return `/home/wolf/run-inputs-executables/${signedUrl.playgroundFileLocation}`; } const executablePath = await uploadBuild(); ``` If this step completes successfully, the artifact is uploaded and available for test runs. **Full implementation** The following example includes both artifact upload and deploy notification in a single script. ```js Javascript expandable theme={null} import { type DeployConfig, makeQaWolfSdk } from "@qawolf/ci-sdk"; import fs from "fs/promises"; import path from "path"; const { generateSignedUrlForRunInputsExecutablesStorage, attemptNotifyDeploy } = makeQaWolfSdk({ apiKey: process.env.QAWOLF_API_KEY, }); (async () => { const playgroundFileLocation = await uploadRunArtifact("/FileLocation"); if (playgroundFileLocation) { const deployConfig: DeployConfig = { branch: undefined, commitUrl: undefined, deduplicationKey: undefined, deploymentType: undefined, deploymentUrl: undefined, ephemeralEnvironment: undefined, hostingService: undefined, sha: undefined, variables: { RUN_INPUT_PATH: playgroundFileLocation, }, }; const result = await attemptNotifyDeploy(deployConfig); if (result.outcome !== "success") { process.exit(1); } const runId = result.runId; } })(); async function uploadRunArtifact(filePath: string): Promise { const fileName = path.basename(filePath); const signedUrlResponse = await generateSignedUrlForRunInputsExecutablesStorage({ destinationFilePath: fileName, }); if ( signedUrlResponse?.success && signedUrlResponse.playgroundFileLocation && signedUrlResponse.uploadUrl ) { const fileBuffer = await fs.readFile(filePath); const url = signedUrlResponse.uploadUrl; try { const response = await fetch(url, { method: "PUT", body: fileBuffer, headers: { "Content-Type": "application/octet-stream", }, }); if (!response.ok) { return ""; } } catch (error) { return ""; } return signedUrlResponse.playgroundFileLocation; } return ""; } ``` ## Trigger a test run After uploading the artifact, notify QA Wolf that a new deployment is ready for testing. ```javascript theme={null} await sdk.attemptNotifyDeploy({ deploymentType: "android_app", variables: { RUN_INPUT_PATH: executablePath, }, }); ``` The deployment type and environment key must match the values configured by QA Wolf for your workspace. If mobile triggers have not yet been enabled, this step will complete without starting a test run. ## Verify the integration Run the CI job. Verify that the artifact upload completes successfully. Confirm that the deployment notification step runs without errors. Once mobile triggers are enabled, check the **Runs** tab for the test run that was triggered. ## Troubleshooting and common issues * **If uploads succeed but no runs start:** Mobile triggers may not yet be enabled. Contact QA Wolf to complete platform configuration. * **If the artifact is not found during execution:** Verify that the artifact basename matches your naming conventions, and the returned path is used when triggering the run. * **If you see authentication errors:** Verify that **QAWOLF\_API\_KEY** is configured correctly in your CI environment. * **If you encounter Node.js errors:** Ensure Node.js 18 or later is available in the CI job. ## Related * [`@qawolf/ci-sdk`](/libraries/ci-sdk/api-reference) * [CI greenlight](/v0-ci-greenlight) # OpenVPN integration Source: https://docs.qawolf.com/OpenVPN-integration Configure an OpenVPN connection so QA Wolf can access your application behind a private network. * Provide QA Wolf with your .ovpn client configuration file. Refer to your VPN provider's documentation for instructions on generating or downloading this file. * Configure your firewall to allow inbound VPN connections to your OpenVPN server from QA Wolf's static IP range: * **Port:** UDP 1194 (default; configurable) * **Source IPs:** `199.4.212.0/23` # Setting up PR testing Source: https://docs.qawolf.com/PR-testing-for-GitHub-Integrations Run QA Wolf tests against preview environments on pull requests and report results as GitHub status checks. This guide is for QA Wolf full service customers. If you're unsure whether this applies to you, contact your QA Wolf team. ## Configure basic PR testing Go to Workspace Settings -> Integrations to enable and authorize the GitHub integration. Choose one of the following methods to tell QA Wolf when a new preview environment is ready to be tested. **GitHub Deployments** If your team uses a hosting platform that integrates with GitHub — like Vercel — your deployments are already being reported to GitHub via the [GitHub Deployments API](https://docs.github.com/en/rest/deployments/deployments). QA Wolf listens for those deployment records and automatically acts when one is marked as successful. No additional configuration is required. **GitHub Action** Configure the [Notify QA Wolf on Deploy](https://github.com/marketplace/actions/notify-qa-wolf-on-deploy) GitHub Action to tell us when a new preview environment is ready to be tested. **SDK** If GitHub Actions aren't an option, the `@qawolf/ci-sdk` npm package offers a flexible alternative. See the [npm package documentation](https://www.npmjs.com/package/@qawolf/ci-sdk) for setup details. QA Wolf will configure a trigger that matches your preview deployments. Reach out to your QA Wolf team to get this set up. *** ## Advanced: Merge queue setup This section covers an optional flow for teams using GitHub's merge queue. Some customers run QA Wolf in the merge queue as a required final gate; others avoid it when the latency or complexity outweighs the confidence gain. ### Add a default passing PR check Add a GitHub Actions workflow that creates a passing **QA Wolf Test Results** check when a PR is opened or updated. This prevents pull requests from being blocked before the merge queue run happens. ```yaml Create .github/workflows/qawolf-pr-check.yml: expandable theme={null} name: Create QA Wolf PR check on: pull_request: types: [opened, reopened, synchronize] jobs: qawolf_check_creation: runs-on: ubuntu-latest permissions: checks: write pull-requests: read steps: - name: Create passing QA Wolf check uses: actions/github-script@v6 with: script: | await github.rest.checks.create({ owner: context.repo.owner, repo: context.repo.repo, name: 'QA Wolf Test Results', head_sha: context.payload.pull_request.head.sha, status: 'completed', conclusion: 'success', output: { title: 'QA Wolf Test Results', summary: 'QA Wolf tests will run in the merge queue.' } }); ``` ### Trigger QA Wolf tests in the merge queue Add a GitHub Actions workflow that notifies QA Wolf only during merge queue execution. ```yaml Create .github/workflows/qawolf-merge-queue.yml: expandable theme={null} name: Test preview environment on: merge_group: jobs: test-preview-environment: name: Trigger QA Wolf tests runs-on: ubuntu-latest needs: - wait-for-preview-environment steps: - name: Notify QA Wolf of deployment uses: qawolf/notify-qawolf-on-deploy-action@v1 with: qawolf-api-key: "${{ secrets.QAWOLF_API_KEY }}" deployment-url: "${{ env.PREVIEW_URL }}" deployment-type: "provided-by-qawolf" deduplication-key: "${{ github.ref }}" ``` ### What this workflow assumes * Your CI pipeline already creates a preview environment per pull request. * The preview environment URL is available as `PREVIEW_URL` when this job runs. * A prior job (such as `wait-for-preview-environment`) ensures the preview environment is fully deployed and reachable before QA Wolf is notified. ### Require QA Wolf tests before merge In the left sidebar, do one of the following: * Click **Rules** (if your repo uses GitHub's new rulesets), or * Click **Branches** (for classic branch protection rules). ## Verify your PR testing setup On the pull request page, confirm that a check named **QA Wolf Test Results** appears in the checks section and initially shows as passing. When the PR enters the merge queue, GitHub will trigger the merge-queue workflow. In the **QA Wolf app**, go to the **Runs** tab and verify that a new run starts for the preview environment associated with the pull request. Back in **GitHub**, watch the **QA Wolf Test Results** check update from its placeholder state to the final pass or fail result. The merge completes only after this check finishes successfully. # Setting up PR testing Source: https://docs.qawolf.com/PR-testing-for-GitLab-Integrations Run QA Wolf tests against preview environments on merge requests and report results back to GitLab. This guide is for QA Wolf full service customers. If you're unsure whether this applies to you, contact your QA Wolf team. ## Configure basic PR testing Configure the GitLab integration in your team's settings page. Choose one of the following methods to tell QA Wolf when a new preview environment is ready to be tested. **GitLab CI job** Add a `notify_qawolf` job to your `.gitlab-ci.yml` that runs only for merge request pipelines, after the preview environment is deployed and reachable. ```yaml expandable theme={null} stages: - deploy - test notify_qawolf: stage: test image: node:latest rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" needs: - deploy_preview script: - npm install @qawolf/ci-sdk - | node -e " const { makeQaWolfSdk } = require('@qawolf/ci-sdk'); const { attemptNotifyDeploy } = makeQaWolfSdk({ apiKey: process.env.QAWOLF_API_KEY }); attemptNotifyDeploy({ sha: process.env.CI_COMMIT_SHA, branch: process.env.CI_COMMIT_REF_NAME, deploymentType: 'provided-by-qawolf', deploymentUrl: process.env.PREVIEW_URL, hostingService: 'GitLab', }).then(result => { if (result.outcome !== 'success') process.exit(1); }); " ``` **curl** If Node isn't available in your pipeline, you can notify QA Wolf using a raw HTTP call instead. ```yaml expandable theme={null} notify_qawolf: stage: test image: curlimages/curl:latest rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" needs: - deploy_preview script: - | curl -X POST "https://app.qawolf.com/api/webhooks/deploy_success" \ -H "Authorization: Bearer $QAWOLF_API_KEY" \ -H "Content-Type: application/json" \ -d "{ \"branch\": \"$CI_COMMIT_REF_NAME\", \"deployment_type\": \"provided-by-qawolf\", \"deployment_url\": \"$PREVIEW_URL\", \"sha\": \"$CI_COMMIT_SHA\", \"hosting_service\": \"GitLab\" }" ``` ### What this job assumes * Your pipeline already creates a preview environment for each merge request. * The preview environment URL is available as `PREVIEW_URL` when this job runs. * A prior job (such as `deploy_preview`) deploys the preview environment and verifies it is reachable before notifying QA Wolf. QA Wolf will configure a trigger that matches your preview deployments. Reach out to your QA Wolf team to get this set up. *** ## Advanced: Require QA Wolf tests before merge In GitLab, merge requests are typically blocked unless pipelines pass. Complete this section if you want to enforce QA Wolf results as a required check. If your project uses approval rules or protected branches, ensure the merge request pipeline is the one required to pass. ## Verify your PR testing setup Confirm that your merge request triggers a pipeline that deploys a preview environment. In GitLab, check the merge request's environment or the Review App link, and confirm that the preview URL is reachable. In the **QA Wolf app**, go to the **Runs** tab and verify that a new run starts for the preview environment associated with the merge request. In GitLab, confirm the pipeline reflects the final result and that the merge request cannot be merged unless the pipeline succeeds. # Pass data between flows Source: https://docs.qawolf.com/Pass-data-between-flows Share authentication tokens, user IDs, and other values across flows in a coordinated run. Flows that pass data between each other are called **Hopper Flows** in QA Wolf — the pattern for multi-user, multi-device, and multi-platform test scenarios. ## Examples **Create a user, then log in as that user** A common pattern: one flow creates a user and publishes credentials; a second flow logs in using those credentials. ```typescript theme={null} // Flow 1: Create user (producer) await page.goto("https://app.example.com/admin/users/new"); await page.fill('[name="email"]', "testuser@example.com"); await page.fill('[name="password"]', "hunter2"); await page.click('[type="submit"]'); setOutput("USER_EMAIL", "testuser@example.com", "USER_PASSWORD", "hunter2"); ``` ```typescript theme={null} // Flow 2: Log in (consumer) const email = inputs["USER_EMAIL"]; const password = inputs["USER_PASSWORD"]; await page.goto("https://app.example.com/login"); await page.fill('[name="email"]', email); await page.fill('[name="password"]', password); await page.click('[type="submit"]'); ``` **Create a user, log in, then verify activity** Extend the chain: a third flow consumes the session token published by the login flow to verify downstream activity. ```typescript theme={null} // Flow 1: Create user (producer) setOutput("USER_EMAIL", "testuser@example.com", "USER_PASSWORD", "hunter2"); ``` ```typescript theme={null} // Flow 2: Log in (producer + consumer) const email = inputs["USER_EMAIL"]; const password = inputs["USER_PASSWORD"]; // ... log in ... const token = await page.evaluate(() => localStorage.getItem("auth_token")); setOutput("AUTH_TOKEN", token); ``` ```typescript theme={null} // Flow 3: Verify activity (consumer) const token = inputs["AUTH_TOKEN"]; await page.setExtraHTTPHeaders({ Authorization: `Bearer ${token}` }); await page.goto("https://app.example.com/activity"); // ... assert expected activity ... ``` ## When to use * Your app has multi-step workflows that span separate user sessions. * Your test requires one user to create a resource another user acts on. * Your scenario involves multiple devices or platforms in a single run. * Your flow needs data that only exists after another flow has run. * Your test validates coordination between concurrent users or roles. ## Notes * **Key naming** — Keys are uppercase by convention: `AUTH_TOKEN`, `USER_EMAIL`, `USER_PASSWORD`. * **Overwrites** — If the same flow calls `setOutput` multiple times, the last call wins for that key. * **Conflicts** — If two different flows publish the same key and a third flow depends on both, the run fails with `Workflow run failed due to conflicting dependency outputs`. * **Execution order** — A consumer will not run until its producer has published. Use [Run Rules](/Run-Rules) to enforce producer-before-consumer ordering. * **Scheduling** — Producers and consumers must be included in the same scheduled run. Target all flows or use a shared tag. # QA Wolf's static IPv4 Source: https://docs.qawolf.com/QA-Wolf-s-static-IPv4 QA Wolf's static IPv4 range for firewall allowlisting and IP-restricted access to your application. Our range is: `199.4.212.0/23` You can verify QA Wolf's ownership of this range through the [ARIN WHOIS registry](https://whois.arin.net/rest/net/NET-199-4-212-0-1). ## When this is required You need this IP range if you restrict access by source IP, for example: * Firewall allowlisting for public or internal services * Client-based VPNs (such as OpenVPN) that restrict connections by IP ## When this is not required You do not need this IP range for: * Site-to-site IPsec VPNs * Mesh VPNs (such as Tailscale or Twingate) * Routing inside a VPN tunnel # How to integrate with Qase.io Source: https://docs.qawolf.com/Qase-io Sync QA Wolf automated test results to your Qase.io project, including pass/fail status and run metadata. ## What QA Wolf syncs to Qase Integrate QA Wolf with [Qase.io](https://qase.io/) to sync automated test results with your Qase project. For each QA Wolf run, the integration can: * Create a test run in Qase. * Sync automated test results (passed, failed, skipped, blocked, invalid). * Include run metadata (title, run/bug link, environment). * Map results to existing Qase test cases. Results are synced after runs reach Completed status in QA Wolf. ## Limitations * Cloud-hosted [Qase.io](https://qase.io/) only. * Only one Qase project can be configured per QA Wolf workspace. * Qase test cases must already exist. * Custom fields are not supported unless specially configured. ## Configure Qase Customers configure Qase. QA Wolf uses the provided credentials to sync results and enable the integration. API tokens are generated at the account level in Qase. If you don't have permission to create one, ask a project or account admin. ### Set up in Qase * Active Qase.io account. * Existing Qase project to receive test runs. * API access enabled (available on paid Qase plans). * Qase test cases already created (tests are not created automatically). ### Credentials to share with QA Wolf Once Qase is configured, provide QA Wolf with: * Qase API token. * Qase project code (for example, WEBAPP, API, MOBILE). * *(Optional)* tags or environment values to associate with test runs. ## Verify the integration After QA Wolf enables the integration: Run your automated tests in QA Wolf. After the run completes, a new test run appears in your Qase project. Test results and run metadata are visible on the mapped test cases. If runs do not appear as expected, or if test cases are not mapping correctly, contact QA Wolf for verification. # Recommended reading for business users and manual testers Source: https://docs.qawolf.com/Recommended-reading-for-QA-engineers-and-developers Recommended courses on HTML, JavaScript, CSS, and Playwright to help you get more out of QA Wolf. Here are some courses that we recommend: # How to request new coverage Source: https://docs.qawolf.com/Request-new-coverage Submit a coverage request to QA Wolf through the platform, Slack, Teams, Discord, or your issue tracker. ## Request new test coverage * Open a [coverage request](/Request-new-coverage#what-happens-next) in the platform * Send a message in Slack, Teams, or Discord * Tag QA Wolf in Jira or Linear. Tag the ticket with QA Wolf and we'll pick it up from there. ## What to include We can get started with a message as simple as "New onboarding flow in staging, ready for tests," but the more detail that you provide, the less follow-up we'll need and the faster we can get the tests live. We have found that using this format yields the best results for our customers: **Objective:** Describe the goal of the flow you want created. *Example: Verify the user registration process to ensure a user can register and receive a confirmation email.* **Preconditions:** List anything that must be set up before the test can run. *Example: Access to a user with admin permissions.* **Steps:** Outline the actions required to achieve the objective. *Example:* * *Navigate to the registration page.* * *Fill out the registration form with valid data.* * *Submit the form.* * *Check for a confirmation message.* * *Verify a confirmation email is sent with correct details.* * *Confirm that the user is created in the database.* **Test data:** Include any specific data inputs the test will need. *Example: A unique email address and user details for registration.* **Expected results:** Describe what should happen if everything works correctly. *Example:* * *The form submits successfully.* * *A confirmation message appears.* * *A confirmation email is received.* * *The user record is correctly created in the database.* **Postconditions:** Specify any clean-up work needed after the test. *Example: Delete the test user from the database.* **Priority and timeline:** Indicate the priority of the request (urgent, high, medium, low) and whether you have a deadline. **Video recording (optional):** Short walkthrough videos (e.g., Loom) aren't required but are very helpful for the team. ## What happens next As soon as we receive the coverage request we will begin building tests. If you submitted a request via chat (Slack, Teams, Discord) or an issue tracker, a Coverage Request item will also be created in the QA Wolf platform. ### Create a new request From the **Requests tab**, click **Request coverage** in the top right of the page. A drawer opens. Add the details for the flows you're requesting, set a priority, and give the request a name. The name will appear in the **Coverage Requests** list. Keep the name action-oriented (e.g., "Verify OTP autofill on Android and iOS.") Click **Submit request** to save. ### Edit a request Coverage requests include a set of fields that control status, priority, ownership, and how the request connects to flows. * **Name.** Edit this by clicking on the name and typing in the box. Press Enter to save the change. * **Status.** Tracks the request's current state. * **Priority.** Indicates the request's urgency. * **Requested by.** Shows who opened the request and can be updated if needed. * **Estimated completion date.** Provides a target date to help with planning. * **Requirements.** This is the description of the requested coverage, which you can edit by clicking on the icon below the name. * **Related flows.** Links the request to existing flows or creates a new flow. To connect the request to another flow, click the icon in this field to select from the list of all existing flows. ### Find open coverage requests You can find all requests for your workspace in the **Requests** tab. * Use the status tabs on the left bar to switch between requests in different statuses. * Use the **Search** bar to filter by request name. Use the icon to filter by request **Priority** or **Creator is me**. # Run Rules Source: https://docs.qawolf.com/Run-Rules Run Rules let you sequence the order of flows in your suite. By default all flows run in parallel on the QA Wolf run infrastructure, but there are times when you want to sequence some flows to run before others. * Quick sanity checks before longer, or more complex flows * Multi-user flows where state is shared between flows. * When the environment can't handle the load of an entire test suite. # How Run Rules work ## Configuring groups Run Rules execute one group of flows before another group of flows. Groups can be constructed by flow name or by tag. Only **active** flows execute in a run. Drafts are ignored even if they're part of a rule. ## Ordering To run flows in a sequence like **A → B → C**, create two rules: run **A** before **B**, and run **B** before **C**. For each rule, indicate whether flows in the second group should be allowed to run if any flows in the first group fail. # Site-to-Site IPSec Tunnel Source: https://docs.qawolf.com/Site-to-Site-IPSec-Tunnel Set up an IPSec site-to-site VPN tunnel between your network and QA Wolf using pre-shared key authentication. * Configure your VPN gateway to support: * **IPsec site-to-site tunnels** * **Pre-shared key (PSK) authentication** * **Static routing** (dynamic routing is not supported) * **IKEv1 or IKEv2** * Configure your firewall and VPN gateway to allow IPsec traffic: * **Ports:** UDP 500 and UDP 4500 * Use the information provided by QA Wolf to complete your VPN configuration: * **QA Wolf VPN gateway public IP address(es):** Configure these as the remote IPsec peer * **Pre-shared key (PSK):** Set as the tunnel authentication secret * **Assigned subnet:** Add static routes to allow traffic to and from QA Wolf ## Compatibility * **Fully supported:** AWS Site-to-Site VPN, Google Cloud VPN, Azure VPN Gateway * **Supported with configuration:** Cisco ASA, Palo Alto, FortiGate * **Not supported:** Dynamic-routing-only VPNs (for example, BGP-only) If your VPN solution is not listed as fully compatible, contact your QA Wolf engineering lead to confirm interoperability before setup. # Tailscale Client-based VPN Source: https://docs.qawolf.com/Tailscale-Client-based-VPN Connect QA Wolf to your private network through Tailscale by providing an access token or client configuration. * Provide QA Wolf with either an access token or client configuration, depending on your VPN. * Confirm that your VPN mesh supports Linux clients and outbound connections from a dedicated proxy. * Ensure your network allows VPN traffic on TCP 443. # Prepare your environments for testing Source: https://docs.qawolf.com/Test-environments The guidance below focuses on keeping your environment predictable, scalable, and ready for concurrent automated testing. ## Why the stability of your CI environments is essential QA Wolf automatically retries failed tests, but unstable environments and excessive flakiness can slow runs and delay releases. Most test failures caused by infrastructure issues are preventable with the right environment setup. If you're unsure how to size your environment or choose the right concurrency level, contact your Customer Success Manager. Based on your application and usage patterns, they can review your setup and recommend: * Concurrency limits * Infrastructure sizing * Alerting thresholds ## Right-size and monitor your environment Your test environment must handle bursts of concurrent users and background activity during test runs. ### Use scalable infrastructure * For every **500 concurrent tests**, allocate at least **4–8 vCPUs and 8–16 GB of memory**. * If you use a cloud provider (AWS, GCP, Azure), size instance groups or containers accordingly and enable auto-scaling. ### Monitor performance Set up basic telemetry (CloudWatch, Datadog, New Relic, etc.) to track CPU, memory, and network usage. * If utilization regularly exceeds 75%, increase capacity or temporarily reduce concurrency in QA Wolf. ### Isolate test traffic Run QA Wolf tests in a **dedicated staging or test environment**. Avoid sharing environments with: * Demo traffic * Manual QA * Feature branch verification ## Configure concurrency safely Each QA Wolf flow runs in its own container and behaves like a separate user. Your systems must support that level of concurrency. ### Match concurrency to capacity If your environment slows down or times out under load, reduce concurrency in QA Wolf. Many teams start at **25% of total flows per batch** and increase as stability improves. ### Avoid data collisions Prevent tests from interfering with each other: * Use unique login credentials per run. * Prefix generated data with a run ID or timestamp. * Use separate tenants or domains if supported. ### Control deployments during test runs Avoid deploying to test environments while QA Wolf runs are in progress. Many teams enforce this with a CI/CD "deployment hold" or environment flag. #### Use ephemeral environments when possible Preview or ephemeral environments (Vercel, Render, custom Terraform, etc.) provide a clean surface for each run and reduce cross-test interference. ## Prepare the app for automated testing Automated tests must be able to run end-to-end without manual setup. ### Ensure dependencies are reliable * Databases, authentication providers, email services, and queues must be available during runs. * Temporary outages often appear as test failures. ### Align feature flags * Keep feature flags in a consistent, test-ready state. If you use tools like LaunchDarkly or Optimizely, create a dedicated configuration for QA Wolf. ### Provision test users and data * Maintain at least one QA Wolf account with sufficient permissions. * Provide an API or scriptable way to create and clean up test users. * This allows tests to start from a clean state and run concurrently. ### Configure email and messaging * Allow emails from `@qawolfworkflows.com` and `@qawolf.email`. * Support plus addressing (e.g., `user+test123@example.com`) or another delimiter. * Aim for email and SMS delivery within **one minute** to avoid timeouts. # How to integrate with TestRail Source: https://docs.qawolf.com/TestRail Sync QA Wolf automated test results to TestRail, including pass/fail status and run metadata for each test run. ## What QA Wolf syncs to TestRail Integrate QA Wolf with [TestRail](https://www.testrail.com/) to centralize automated test results and reporting. Customers configure TestRail. QA Wolf uses the provided credentials to sync results and enable the integration. For each QA Wolf run, the integration can: * Create a new test run in TestRail. * Sync automated test results (passed, failed, skipped). * Include run metadata (name, run/bug link). * Mark the test run as completed after results are synced. Results are synced after runs reach Completed status in QA Wolf. ## Limitations * Only one TestRail project can be configured per QA Wolf workspace. * Test runs are created at the run level (no test plan–level updates). * The *Skipped* test status must exist in TestRail. ## Configure TestRail for QA Wolf integration Enabling TestRail API access requires an **Administrator** user. If you're not a TestRail admin, ask one to enable the API before generating your credentials. ### Set up in TestRail * Active TestRail account. * API access enabled. * Target TestRail project to receive QA Wolf runs. * Custom test status *Skipped* is configured in TestRail. ### Share with QA Wolf * TestRail username or login email. * TestRail API key or password (depending on your account configuration). * Target TestRail project (project ID or URL). ## Verify the integration After QA Wolf enables the integration: Run your automated tests in QA Wolf. After the run completes, a new test run appears in your TestRail project with synced results and metadata. The TestRail run is automatically marked completed. If runs do not appear as expected, or if you need assistance configuring TestRail, contact QA Wolf for verification. # How to integrate with Testmo Source: https://docs.qawolf.com/Testmo Sync QA Wolf automated test results to your Testmo project, including pass/fail status and run metadata. For each QA Wolf run, the integration can: * Create a new test run in Testmo. * Sync automated test results (passed, failed, skipped, errored). * Include run metadata (title, run link). * Apply optional tags to all synced runs. Results are synced after QA Wolf finishes processing a run. ### Limitations * Up to two Testmo projects can be configured per QA Wolf workspace. * The tags you provide during setup are applied to all synced runs. Dynamic tags (for example, different tags for staging vs production or PR vs scheduled runs) require custom configuration by QA Wolf. ## Configure Testmo for QA Wolf integration Enabling Testmo API access requires an **Administrator** user. If you're not a Testmo admin, ask one to enable the API before generating your credentials. ### Set up in Testmo * Active Testmo account. * API access enabled. * Target Testmo project to receive QA Wolf runs. * *(Optional)* Tags to apply to all synced runs. ### Share with QA Wolf * Testmo login email. * Testmo API key. * Target Testmo project (project ID or URL). * *(Optional)* Tags to apply to runs. ## Verify the integration After QA Wolf enables the integration: Run your automated tests in QA Wolf. After QA Wolf finishes processing the run, a new test run appears in your Testmo project with synced results and metadata. If runs do not appear as expected, or if you need assistance configuring Testmo, contact QA Wolf for verification. # Twingate Client-based VPN Source: https://docs.qawolf.com/Twingate-Client-based-VPN Connect QA Wolf to your private network through Twingate by providing an access token or client configuration. * Provide QA Wolf with either an access token or client configuration, depending on your VPN. * Confirm that your VPN mesh supports Linux clients and outbound connections from a dedicated proxy. * Ensure your network allows VPN traffic on TCP 443. # Upload files Source: https://docs.qawolf.com/Uploading-manually Upload files to QA Wolf and use them in your test flows. Make sure you have: * Your `QAWOLF_API_KEY` — available under **Workspace Name → Workspace Settings → Integrations → API Access** ## Supported file types ### App executables The build under test. * **Mobile apps** (`.apk`, `.aab`, `.ipa`) — use a static filename based on the environment (e.g., `app-staging.ipa`) so tests always reference the latest build. See [Mobile build testing](/Fastlane) for how to automate this as part of your build process. * **Linux desktop apps** (`.deb`) — use a static filename based on the environment, the same convention as mobile app builds. ### Data files Used inside a test flow, not the app itself. * **ZIP archives** (`.zip`) — bundle multiple assets for a single upload. Make sure the contents match the structure your tests expect. * **CSV files** (`.csv`) — test data inputs. Use a consistent filename so flows always pick up the latest version. * **PDF files** (`.pdf`) — for test flows that involve document handling or validation. ## Upload a file QA Wolf accepts file uploads via a two-step signed URL process. ```bash theme={null} curl "https://app.qawolf.com/api/v0/run-inputs-executables-signed-urls?file=$DESTINATION_FILE_PATH" \ -H "Authorization: Bearer $QAWOLF_API_KEY" ``` Returns a `signedUrl` for the next step, plus `fileLocation` and `playgroundFileLocation` — the paths you'll use to reference the file afterward. ```bash theme={null} curl -X PUT \ --header "Content-Type: application/octet-stream" \ --data-binary @some_file.zip \ $SIGNED_URL ``` See [v0/run-inputs-executables-signed-urls](/run-inputs-executables-signed-urls) for the full request/response reference, including error codes. ## Use an uploaded file in a flow ### Mobile apps QA Wolf resolves an uploaded build automatically through `RUN_INPUT_PATH` — omit `app` in your launch call and provide only the package/bundle ID: ```typescript theme={null} import { flow, launch } from "@qawolf/flows/android"; export default flow("Open uploaded build", "Android - Pixel", async () => { const { driver } = await launch({ appPackage: "com.example.android", }); }); ``` See [App resolution](/libraries/flows/api-reference/android#app-resolution) (or the [iOS equivalent](/libraries/flows/api-reference/ios#app-resolution)) for the full resolution order and how to reference a specific path or URL instead. ### Web file upload Use Playwright's `filechooser` event to intercept the file dialog and set the uploaded file programmatically. ```javascript theme={null} const fileName = `${process.env.TEAM_STORAGE_DIR}/fileNameHere`; page.once( "filechooser", (chooser) => void chooser.setFiles(fileName).catch(console.error) ); await page.getByText("Upload file").click(); ``` Use `page.once` rather than `page.on`. `page.once` registers a one-time event listener that automatically unregisters after the first use. Using `page.on` adds a persistent listener that may interfere with subsequent file uploads in the same flow. ### PDF viewing Use the internal PDF viewer to open an uploaded PDF file in a flow. ```javascript theme={null} const invoicePath = `${process.env.TEAM_STORAGE_DIR}/invoices/invoice-1.pdf`; const pdfPage = await context.newPage(); await pdfPage.goto("http://pdf-viewer.psc.qaw.internal"); pdfPage.once( "filechooser", (chooser) => void chooser.setFiles(invoicePath).catch(console.error) ); await pdfPage.waitForTimeout(4000); await pdfPage.click("#openFile"); ``` # Test a feature that sends or receives emails Source: https://docs.qawolf.com/Use-emails-in-tests Use mail.inbox() from @qawolf/emails to send, receive, and verify emails during your QA Wolf flows. Any email address used in a flow must be on your team's [Email Allowlist](#the-email-allowlist). ## Examples **Verify a signup confirmation or magic link** Use a fresh inbox and fill it into your app's email field. Wait for the message after the triggering action. ```javascript theme={null} const inbox = await mail.inbox({ new: true }); await page.getByLabel("Email").fill(inbox.emailAddress); const after = new Date(); await page.getByRole("button", { name: "Send magic link" }).click(); const message = await inbox.waitForMessage({ after }); await expect(message.urls[0]).toContain("/activate"); ``` **Extract a login code from email** Use `after` immediately before the action that sends the code to avoid matching an older message from the same address. ```javascript theme={null} const inbox = await mail.inbox({ new: true }); await page.getByLabel("Email").fill(inbox.emailAddress); const after = new Date(); await page.getByRole("button", { name: "Send code" }).click(); const message = await inbox.waitForMessage({ after }); const match = message.text.match(/\b\d{6}\b/); if (!match) throw new Error("Login code email did not contain a 6-digit code"); await page.getByLabel("Code").fill(match[0]); await page.getByRole("button", { name: "Verify" }).click(); ``` **Wait for a batch of emails** Use `waitForMessages` when one action should trigger several emails, such as a team invite or multi-step onboarding sequence. Use `delay` if your app enqueues email work in the background. ```javascript theme={null} const inbox = await mail.inbox({ new: true }); const after = new Date(); await page.getByRole("button", { name: "Invite team" }).click(); const messages = await inbox.waitForMessages({ after, minCount: 3, timeout: 120_000, delay: 5_000, }); const subjects = messages.map((m) => m.subject); expect(subjects).toContain("Welcome"); expect(subjects).toContain("Your workspace is ready"); ``` **Simulate an inbound email** Use `sendMessage` when your app receives or reacts to incoming email — for example, a support reply or an automated trigger. ```javascript theme={null} const inbox = await mail.inbox({ new: true }); await inbox.sendMessage({ to: ["support@example.com"], subject: "Need help", text: "The test user is asking for support.", }); ``` **Send with attachments** Pass an `attachments` array to `sendMessage`. Use `contentId` when the HTML body references an inline file. ```javascript theme={null} await inbox.sendMessage({ to: ["invoices@example.com"], subject: "Invoice", text: "Attached.", attachments: [ { fileName: "invoice.txt", content: Buffer.from("invoice total: $10.00"), type: "text/plain", }, ], }); ``` **Reply to an app email** ```javascript theme={null} const original = await inbox.waitForMessage({}); await inbox.sendMessage({ to: [original.from], subject: `Re: ${original.subject}`, text: "Thanks, this worked.", }); ``` ## When to use * Your app sends a confirmation, magic link, or verification code by email. * Your app sends a batch of emails when a user action occurs. * Your app receives or reacts to inbound email. * You need a fresh, isolated inbox for every test run. * Your app processes email replies or attachments. ## The Email Allowlist QA Wolf includes an **Email Allowlist** to ensure test emails are delivered only to approved addresses or domains. You must add any email address used in a flow to the allowlist before running the flow. QA Wolf provides internal email domains — **qawolf.email** and **qawolfworkflows.com** — for email testing. These are recommended over public email services because QA Wolf controls the servers, which stabilizes tests that rely on email interactions. The platform sets an automatic default address. You cannot delete the default. **To add or change the default email address:** Click the icon on the upper right. A drawer opens. Click **Addresses**. Enter the username and domain in the appropriate fields, then click **Add**. a. You can create as many email addresses as you like in the allowlist. b. To change the default, hover over the address you want to set as default, click the icon, and select **Make default**. ## Address options **Use a fresh address for each run** Pass `new: true` to generate a unique derived address. This is the right choice for most tests — it isolates each run so old emails from previous runs are never matched. ```javascript theme={null} const inbox = await mail.inbox({ new: true }); // Example: my-team+a25sa5q@qawolf.email ``` If your app does not accept `+` addressing, use a custom delimiter: ```javascript theme={null} const inbox = await mail.inbox({ new: true, delimiter: "-" }); ``` **Use the workspace default** Omit all options to use your team's default address. Use this only when a test intentionally shares a stable inbox. ```javascript theme={null} const inbox = await mail.inbox(); ``` **Use a specific allowlisted address** ```javascript theme={null} const inbox = await mail.inbox({ address: "my-team+admin@qawolf.email", }); ``` ## Full sample test ```javascript theme={null} import { flow } from "@qawolf/flows/web"; import { mail } from "@qawolf/emails"; export default flow( "Sign in with email code", { target: "Web - Chrome", launch: true }, async ({ page }) => { const inbox = await mail.inbox({ new: true }); await page.goto("https://example.com/login"); await page.getByLabel("Email").fill(inbox.emailAddress); const after = new Date(); await page.getByRole("button", { name: "Send code" }).click(); const message = await inbox.waitForMessage({ after }); const match = message.text.match(/\b\d{6}\b/); if (!match) throw new Error("Login code email did not contain a 6-digit code"); await page.getByLabel("Code").fill(match[0]); await page.getByRole("button", { name: "Verify" }).click(); await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); }, ); ``` # How to integrate with Xray Source: https://docs.qawolf.com/Xray Sync QA Wolf automated test results to Xray Test Management for Jira, including test executions and metadata. ## Features Integrate QA Wolf with [Xray Test Management for Jira](https://marketplace.atlassian.com/apps/1211769/xray-test-management-for-jira) to sync automated test results directly to Jira. For each QA Wolf run, the integration can: * Create a test execution in Xray. * Sync automated test results (passed, failed, skipped). * Link executions to existing Xray test cases. * Include execution metadata (start/end time, environment). * Optionally link executions to a test plan. Results are synced after runs reach Completed status in QA Wolf. ## Limitations * Cloud-hosted only (Jira Cloud + Xray Cloud). * Only one Jira/Xray project can be configured per QA Wolf workspace. * Xray test cases must already exist. * Test executions are created at the execution level (no test plan management). * Custom fields are not supported unless specially configured. ## Configure Xray / Jira Customers configure Xray and Jira. QA Wolf uses the provided credentials to sync results and enable the integration. Generating Xray API credentials requires administrator access in Jira/Xray. If you're not a Jira/Xray admin, ask one to create the credentials. ### Set up in Xray * Jira Cloud instance with Xray installed. * Xray-enabled Jira project to receive test executions. * Xray Cloud API access enabled. * Xray test cases already created (tests are not created automatically). ### Credentials to share with QA Wolf Once Xray is configured, provide QA Wolf with: * Xray Client ID. * Xray Client Secret. * Jira project key (for example, QA or MOBILE). * *(Optional)* Default test plan to link executions. ## Verify the integration After QA Wolf enables the integration: Run your automated tests in QA Wolf. After the run completes, a new Test Execution appears in your Jira project. Test results and execution metadata are visible in Xray. If executions do not appear as expected, or if test cases are not mapping correctly, contact QA Wolf for verification. # How to integrate with Zephyr Source: https://docs.qawolf.com/Zephyr Sync QA Wolf automated test results to Zephyr Test Management for Jira, including test cycles and metadata. ## What QA Wolf syncs to Zephyr Integrate QA Wolf with [Zephyr Test Management for Jira](https://marketplace.atlassian.com/apps/1213259/zephyr-test-management-and-automation-for-jira) to sync automated test results directly to Jira. For each QA Wolf run, the integration can: * Create test executions in Zephyr. * Associate executions with a test cycle. * Sync automated test results (passed, failed, skipped). * Include execution metadata (cycle name, environment). Results are synced after runs reach Completed status in QA Wolf. ## Limitations * Cloud-hosted only (Jira Cloud + Zephyr Squad or Scale). * Only one Zephyr-enabled Jira project can be configured per QA Wolf workspace. * Zephyr test cases must already exist. * Custom fields are not supported unless specially configured. ## Configure in Jira / Zephyr Customers configure Jira and Zephyr. QA Wolf uses the provided credentials to sync results and enable the integration. Generating Jira API tokens requires administrator access to the Atlassian account. If you're not an admin, ask one to create the token. ### Jira/Zephyr setup * Jira Cloud instance. * Zephyr installed and licensed (Squad or Scale). * Zephyr-enabled Jira project to receive test executions. * API access enabled (via Atlassian REST API). * Zephyr test cases already created (tests are not created automatically). ### Credentials to share with QA Wolf Once Jira and Zephyr are configured, provide QA Wolf with: * Jira user email. * Jira API token. * Jira base URL. * Zephyr project key (for example, QA, MOBILE). * *(Optional)* Default test cycle name or naming convention. ## Verify the integration After QA Wolf enables the integration: Run your automated tests in QA Wolf. After the run completes, test executions appear in the configured Zephyr project. Executions are grouped under the appropriate test cycle and reflect synced results. If executions do not appear as expected, or if test cases are not mapping correctly, contact QA Wolf for verification. # Measure accessibility for native mobile Source: https://docs.qawolf.com/a11y-native Use accessibility IDs to verify that key screens are reachable and usable by screen readers on iOS and Android. Use accessibility IDs to verify that key screens are reachable and usable by screen readers on iOS and Android. The `~` selector prefix is WebdriverIO shorthand for locating an element by its accessibility ID — this proves the element is correctly exposed to the accessibility layer and keeps your tests stable across visual refactors. This recipe covers accessibility spot-checking for native mobile. For operationalized a11y monitoring — scheduled runs, trend tracking, aggregated reports, and stakeholder dashboards — talk to your QA Wolf team about full-service accessibility testing. ## Examples **Verify a screen's key elements are reachable via accessibility semantics** ```typescript theme={null} const email = await driver.$("~Email"); const password = await driver.$("~Password"); const loginBtn = await driver.$("~Log in"); await expect(email).toBeDisplayed(); await expect(password).toBeDisplayed(); await expect(loginBtn).toBeEnabled(); ``` If you can't locate an element using `~`, that element is likely missing an accessibility label entirely — an accessibility bug worth raising with your dev team before writing the test. **Assert that icon buttons have meaningful accessible names** ```typescript theme={null} const menuIcon = await driver.$("~Main menu"); await expect(menuIcon).toBeDisplayed(); const label = await menuIcon.getAttribute("content-desc"); if (!label || label.trim().length < 3) { throw new Error("A11y: menu icon accessible label is missing or too short"); } ``` ## When to use * Your app has key interactive elements that must be exposed to VoiceOver (iOS) or TalkBack (Android). * Your team wants to catch regressions where a developer removed or renamed an accessibility label. * Your team wants a lightweight proxy for accessibility hygiene without a full WCAG audit. * Your app has icon-only buttons that must have meaningful accessible names. ## Platform reference | Platform | Attribute | WebdriverIO selector | | -------- | ------------------------------------------- | ------------------------- | | Android | `content-desc` | `driver.$('~your-label')` | | iOS | `accessibilityIdentifier` / accessible name | `driver.$('~your-id')` | ## Quick reference | Goal | How to do it | | -------------------------------------------------------- | ------------------------------------------------ | | Prove an element is on the accessibility layer | Locate it with `driver.$('~accessibility-id')` | | Assert a screen is navigable via accessibility semantics | Use `~` selectors throughout the full flow | | Assert an icon button has a meaningful label | `getAttribute('content-desc')` and check length | | Assert an error or toast is readable | `await expect($('~error-id')).toHaveText('...')` | QA Wolf does not run a WCAG rules engine against native app views. Native apps don't have a DOM, so axe-core style scanning isn't available for native iOS or Android. For deeper audits — color contrast, touch target size, reading order — supplement these tests with manual testing using VoiceOver on iOS, TalkBack on Android, or the Xcode Accessibility Inspector and Android Studio Layout Inspector. ## Full sample test ```typescript theme={null} import { flow } from "@qawolf/flows/android"; export default flow( "Accessibility — login screen", { target: "Android - Pixel", launch: true }, async ({ driver, test }) => { await test("verify login screen elements are accessible", async () => { // Arrange await driver.$("~Email").waitForDisplayed({ timeout: 10_000 }); // Act const email = driver.$("~Email"); const password = driver.$("~Password"); const loginBtn = driver.$("~Log in"); await email.setValue(process.env.USER_EMAIL); await password.setValue(process.env.USER_PASSWORD); await loginBtn.click(); // Assert await expect(driver.$("~Home")).toBeDisplayed(); const menuIcon = driver.$("~Main menu"); await expect(menuIcon).toBeDisplayed(); const label = await menuIcon.getAttribute("content-desc"); if (!label || label.trim().length < 3) { throw new Error("A11y: menu icon accessible label is missing or too short"); } }); }, ); ``` # Measure web accessibility with axe-core Source: https://docs.qawolf.com/a11y-web Inject axe-core into a Playwright page to catch WCAG violations and gate releases on accessibility quality. Use axe-core or Lighthouse to assert that pages meet accessibility standards. Both tools are available in QA Wolf web flows — axe-core for precise violation gating, Lighthouse for scores and shareable reports. This recipe covers accessibility spot-checking using axe-core and Lighthouse. For operationalized a11y monitoring — scheduled runs, trend tracking, aggregated reports, and stakeholder dashboards — talk to your QA Wolf team about full-service accessibility testing. ## Examples **Gate a release on axe-core violations** ```typescript theme={null} await page.addScriptTag({ url: "https://unpkg.com/axe-core@4.8.2/axe.min.js", }); const violations = await page.evaluate(async () => { const { violations } = await window.axe.run(); return violations; }); const critical = violations.filter((x) => x.impact === "critical"); const serious = violations.filter((x) => x.impact === "serious"); expect(critical.length).toBe(0); expect(serious.length).toBe(0); ``` **Generate a Lighthouse accessibility report** ```typescript theme={null} const { lhr } = await playAudit({ page, thresholds: { accessibility: 90 }, reports: { formats: { html: true, json: true }, directory: `${process.env.TEAM_STORAGE_DIR}/lighthouse`, name: `a11y-${Date.now()}`, }, config: { extends: "lighthouse:default", settings: { onlyCategories: ["accessibility"] }, }, }); const a11yScore = Math.round(lhr.categories.accessibility.score * 100); console.log(`Accessibility score: ${a11yScore}`); ``` Lighthouse scores accessibility against its own model, which does not map 1:1 to WCAG conformance levels. Use it as a directional score and audit artifact — not as a hard compliance gate. ## When to use * Your app has pages that must meet WCAG standards and you need to catch regressions before they reach production. * Your team needs a release gate that fails on critical or serious WCAG violations. * Your team needs a shareable accessibility report for stakeholders or compliance purposes. * Your team wants to establish a baseline score for a page and track it over time. ## Choosing the right approach | | Axe-core | Lighthouse | | ------------------- | ------------------------------------------- | ------------------------------------------- | | **Best for** | CI checks, violation counts, release gating | Scores, formal reports, stakeholder sharing | | **Output** | Violation list by severity | Accessibility score + full report | | **Use as a gate** | Yes — throw on critical / serious | Not recommended | | **Report artifact** | No (console / logs) | Yes — HTML, JSON, PDF | These two approaches complement each other. Axe-core is precise and gateable; Lighthouse is broad and reportable. For the most complete picture, use both. ## Full sample test ```typescript theme={null} import { flow, expect } from "@qawolf/flows/web"; export default flow( "Accessibility", { target: "Web - Chrome", launch: true }, async ({ page, test }) => { await test("assert no accessibility violations", async () => { // Arrange await page.goto("https://your-app.com"); // Act await page.addScriptTag({ url: "https://unpkg.com/axe-core@4.8.2/axe.min.js", }); const violations = await page.evaluate(async () => { const { violations } = await window.axe.run(); return violations; }); // Assert const critical = violations.filter((x) => x.impact === "critical"); const serious = violations.filter((x) => x.impact === "serious"); const moderate = violations.filter((x) => x.impact === "moderate"); const minor = violations.filter((x) => x.impact === "minor"); expect(critical.length).toBe(0); expect(serious.length).toBe(0); expect(moderate.length).toBe(0); expect(minor.length).toBe(0); }); }, ); ``` The full sample gates on all violation levels. Adjust the threshold to match your policy — some teams only gate on `critical` and `serious`, particularly when first introducing accessibility testing. # Anatomy of a flow Source: https://docs.qawolf.com/anatomy-of-a-qa-wolf-test-mobile-edition Learn the structure of every QA Wolf flow — the AAA framework, imports, the flow wrapper, launch styles, and callback parameters — and how that structure extends across web, iOS, and Android. If the agent generated a flow for you and you want to understand what it produced, here's what each part does — and how those same parts extend once you're testing the same behavior on more than one platform. If your team has written Playwright or Appium tests before, QA Wolf flows will look familiar. The selectors, interactions, and assertions work the same way. What's different is the wrapper around them. Every flow is wrapped in `flow()` from the `@qawolf/flows` package — it tells the runner what to run, where to run it, and which runtime objects to inject into the callback. ## AAA framework QA Wolf flows follow the Arrange, Act, Assert (AAA) format. * **Arrange** — sets up state before the interaction. * **Act** — performs the interaction being tested. * **Assert** — verifies the expected outcome. ```typescript theme={null} export default flow( "Complete checkout", { target: "Web - Chrome", launch: true }, async ({ page, test }) => { await test("arrange", async () => { //-------------------------------- // Arrange: //-------------------------------- await page.goto(process.env.BASE_URL); }); await test("act", async () => { //-------------------------------- // Act: //-------------------------------- await page.fill("[data-testid='email']", "test@example.com"); await page.click("[data-testid='submit']"); }); await test("assert", async () => { //-------------------------------- // Assert: //-------------------------------- await expect(page).toHaveURL(`${process.env.BASE_URL}/confirmation`); }); }, ); ``` iOS and Android use `driver.$(...)` calls instead of `page` calls. ## Import statement ```typescript Web theme={null} import { flow, expect } from "@qawolf/flows/web"; ``` ```typescript iOS theme={null} import { flow, expect } from "@qawolf/flows/ios"; ``` ```typescript Android theme={null} import { flow, expect } from "@qawolf/flows/android"; ``` ```typescript Node theme={null} import { flow } from "@qawolf/flows/cli"; ``` For web flows, always import `expect` from `@qawolf/flows/web` — not from `@playwright/test`, which causes assertions to bypass QA Wolf's reporting. iOS, Android, and Node flows don't have this pitfall: `expect` is wired to the QA Wolf runner automatically. ## Flow wrapper ```typescript theme={null} export default flow( "Name shown in QA Wolf", { target: "Web - Chrome", launch: true }, async ({ page }) => { // test steps go here }, ); ``` * **Name** — what this flow is called in your results, bug reports, and QA Wolf dashboard. Make it descriptive enough that a failing flow name tells you where to look. * **Configuration** — specifies where the flow runs and how it starts. Pass a plain target string to name the browser, device, or environment, or pass an object with `target` and `launch` to configure startup simultaneously — see [Target literals](/libraries/flows/api-reference/top-level#target-literals) for the exact matching rule and the list of values. * **Callback** — the `async` function containing your test logic. What it receives depends on the launch style and platform (see below). ## Launch styles | Style | When to use | | ------------------- | ----------------------------------------------------------------------------- | | `launch: true` | Most flows — default startup, no configuration needed | | Options object | Startup options are known up front — e.g., persistent browser context | | Explicit `launch()` | Startup depends on runtime logic — e.g., branching on an environment variable | ### Declarative launch — recommended Pass `launch: true` to use the default startup, or pass an options object to customize it. Either way, QA Wolf starts the browser or app before your callback runs, and the callback receives the platform object ready to use. If you've used Playwright directly, this is the equivalent of calling `await browser.launch()` — QA Wolf handles that setup for you, so your callback starts with a ready-to-use `page`. ```typescript Web theme={null} export default flow( "Sign in", { target: "Web - Chrome", launch: true }, async ({ page, test }) => { await test("navigate to sign in", async () => { await page.goto(process.env.BASE_URL); }); }, ); ``` ```typescript iOS theme={null} export default flow( "Sign in", { target: "iOS - iPhone 15 (iOS 26)", launch: true }, async ({ driver, test }) => { await test("launch app", async () => { // driver is ready to use }); }, ); ``` ```typescript Android theme={null} export default flow( "Sign in", { target: "Android - Pixel 9", launch: true }, async ({ driver, test }) => { await test("launch app", async () => { // driver is ready to use }); }, ); ``` Pass an options object instead of `true` when you need to customize startup, such as reusing a browser profile. See the [Web API Reference](/libraries/flows/api-reference/web) for the full list of options. ```typescript theme={null} export default flow( "Sign in with saved profile", { target: "Web - Chrome", launch: { browserContext: "persistent", userDataDir: "/tmp/qawolf-profile", }, }, async ({ page, test }) => { await test("navigate to sign in", async () => { await page.goto(process.env.BASE_URL); }); }, ); ``` The callback receives a different object depending on the platform: | Platform | Callback receives | | -------------- | ----------------------------------------------------------- | | Web browser | `page`, `context`, optional `browser` | | Web (Electron) | `page` | | iOS | `driver` — the Appium/WebdriverIO session for the device | | Android | `driver` — the Appium/WebdriverIO session for the device | | Node | nothing additional — only `inputs`, `setOutput`, and `test` | ### Explicit launch — advanced flows only For advanced cases where the startup depends on runtime logic, you can call `launch()` explicitly inside the callback. See the [Web API Reference](/libraries/flows/api-reference/web) for details. For Electron desktop app testing, see [Testing Electron apps](/electron). ## Callback parameters Every flow callback receives three parameters regardless of platform: **`inputs`** — values passed into the flow from outside. Use this to read data published by another flow in the same run. Keys are uppercase by convention, e.g. `inputs["EMAIL"]`. **`setOutput(...)`** — publishes one or more key-value pairs that a later flow in the same run can read via its `inputs`. Keys are uppercase by convention. You can publish multiple values in a single call: ```typescript theme={null} setOutput("USER_ID", userId, "EMAIL", email); ``` A consumer flow will not run until its producer has called `setOutput`. See [Passing data between flows](/Pass-data-between-flows) for how producers and consumers work together. **`test(...)`** — wraps a named sub-step, grouping actions and assertions under a label that appears in your results. When a step fails, the label tells you exactly where in the flow the failure happened. ```typescript theme={null} export default flow( "Create account", { target: "Web - Chrome", launch: true }, async ({ page, inputs, setOutput, test }) => { await test("fill registration form", async () => { await page.goto(process.env.BASE_URL); await page.getByLabel("Email").fill(inputs["EMAIL"]); await page.getByRole("button", { name: "Sign up" }).click(); }); await test("confirm account created", async () => { const userId = await page.getByTestId("user-id").textContent(); setOutput("USER_ID", userId); }); }, ); ``` Launch-enabled flows also receive the platform object (`page`, `context`, `browser`, or `driver`) as shown in the launch styles section above. For the full callback context shape, see the API Reference for [Web](/libraries/flows/api-reference/web#flow-callback-context), [Android](/libraries/flows/api-reference/android#flow-callback-context), and [iOS](/libraries/flows/api-reference/ios#flow-callback-context). `launch()`, `device`, and `platform.target` are stubs in the package code, and the runner replaces them at execution time. They only work while a flow is running inside the QA Wolf runner — keep them inside the flow callback, not at the module level. \ \ Module-level code should be limited to imports, constants, and pure helper functions. \ \ `platform.target` (full API in the [Top-Level Reference](/libraries/flows/api-reference/top-level)) lets a single flow branch on the active platform at runtime. Use it sparingly — prefer [separate platform-specific flows](#import-statement) if you find yourself branching often. ### Pure helper functions for platform differences For platform-specific implementation differences that don't fit neatly into a Page Object — such as log capture, where iOS uses `"safariConsole"` and parses JSON while Android uses `"browser"` with logging preferences — write a helper function that handles the difference internally. The flow calls the helper and stays linear. ```typescript theme={null} // /src/helpers/console-logs-ios.ts export async function getConsoleLogs(driver: WebdriverIO.Browser) { const raw = await driver.getLogs("safariConsole"); return raw.map((entry) => JSON.parse(entry.message)); } //helpers/console-logs-android.ts export async function getConsoleLogs(driver: WebdriverIO.Browser) { const raw = await driver.getLogs("browser"); return raw.map((entry) => entry.message); } ``` Keep helper functions like this one pure. As noted above, runtime APIs only work inside the flow callback, not at module level. ## Environment variables Reference environment variables with `process.env.VAR_NAME`. You typically use them in the Arrange section before any interactions begin. ```typescript theme={null} await page.goto(process.env.BASE_URL); ``` To set environment variables for your flows, use the environment settings in the QA Wolf platform. # Microphone injection (Android) Source: https://docs.qawolf.com/android-audio-injection Inject audio into the Android emulator's microphone input to test recording and voice features. The Android emulator routes audio played on the runner host through to the emulator's microphone input. Use `device.passAudioAsMicrophoneInput(...)` to play an audio file during a flow, and your app will receive it as microphone input. **Start your app recording before you call this.** `passAudioAsMicrophoneInput` plays the file and resolves only once playback has finished, so awaiting it first plays the whole file into a microphone nobody is listening to, and the recording that follows captures silence. `delaySeconds` does not work around this either, since it is awaited inline as well. Store your audio file in team storage and reference it via `process.env.TEAM_STORAGE_DIR`. See [Upload files](/Uploading-manually) for instructions. ## Examples **Inject audio into the microphone** Start the recording in your app, then play the audio into it: ```typescript theme={null} await driver.$(`//*[@text='Record']`).click(); await device.passAudioAsMicrophoneInput({ data: `${process.env.TEAM_STORAGE_DIR}/audio.mp3`, durationSeconds: 10, }); await driver.$(`//*[@text='Done']`).click(); ``` **Start playback before your app begins listening** If your app needs audio already flowing when it starts listening, hold the promise and await it once the app is ready: ```typescript theme={null} const playback = device.passAudioAsMicrophoneInput({ data: `${process.env.TEAM_STORAGE_DIR}/audio.mp3`, durationSeconds: 10, }); await driver.$(`//*[@text='Record']`).click(); await playback; ``` **Pull a recording off the emulator** ```typescript theme={null} await device.adb([ "pull", "/sdcard/Recordings/My recording 1.m4a", `${process.env.TEAM_STORAGE_DIR}/recorded.m4a`, ]); ``` ## When to use * Your app records audio or processes microphone input and you need to test that flow with known audio data. * Your app has voice commands or speech recognition features. * Your app validates or analyzes microphone input. * Your test needs to run the same audio scenario repeatedly with consistent inputs. ## Options `passAudioAsMicrophoneInput` accepts `delaySeconds` (wait before playback starts) and `durationSeconds` (cap playback length; omit to play the whole file). See the [Android Device Controls](/libraries/flows/api-reference/android-device-reference) for the full signature. `delaySeconds` delays playback, but the call still resolves only once playback has finished. Use it to line playback up with something your app does after it starts listening, not to start the recording after the call. ## Verifying the injected audio Assert on something your app derived from the audio, such as the text a speech-recognition field transcribed. A file appearing on disk only proves your app recorded something, and a recording made in the wrong order contains silence while still producing a file. `device.captureAudioOutput` cannot confirm that injection reached your app. The capture is taken on the runner host, where the injected audio is present whether or not the emulator ever passed it to the guest, so it returns your audio even when your app heard nothing. The same is true of any other host-side check. Verify from inside the app instead. ## Full sample test ```typescript theme={null} import { device, expect, flow } from "@qawolf/flows/android"; const audioPath = `${process.env.TEAM_STORAGE_DIR}/audio.mp3`; export default flow( "Test voice input", { target: "Android - Pixel", launch: true }, async ({ driver, test }) => { await test("app transcribes the injected speech", async () => { // Arrange await driver.$(`//*[@text='Add note']`).click(); // Act: start listening first, then play the audio into the microphone await driver.$(`//*[@content-desc='Voice input']`).click(); await device.passAudioAsMicrophoneInput({ data: audioPath, durationSeconds: 10, }); await driver.$(`//*[@text='Done']`).click(); // Assert on what the app heard, not on whether a file was written await expect(driver.$(`//*[@resource-id='note-body']`)).toHaveText( "remember to buy oat milk", ); }); }, ); ``` # Android barcode and QR code scanning Source: https://docs.qawolf.com/android-barcode Test barcode and QR code scanning features in Android apps using the emulator's virtual camera scene. Use `device.setVirtualSceneImage()` and `device.playAutomation()` to place a barcode or QR code image in the emulator's virtual camera scene and animate the camera toward it — simulating a real scan without a physical device. See the [Android Device Controls](/libraries/flows/api-reference/android-device-reference) for the full API. The sequence is: store the barcode image in team storage → place it on the virtual scene (`table` or `wall`) → play the `Walk_to_image_room` macro to animate the camera toward it → wait for your app to detect and process the scan. Store your barcode image in team storage and reference it via `process.env.TEAM_STORAGE_DIR`. See [Upload files](/Uploading-manually) for instructions. The image must encode the exact value your test expects. ## Examples **Scan a barcode on the virtual table** ```typescript theme={null} await device.setVirtualSceneImage({ image: `${process.env.TEAM_STORAGE_DIR}/barcode.jpg`, location: "table", }); await device.playAutomation({ macro: "Walk_to_image_room", }); ``` **Scan a barcode on the virtual wall** ```typescript theme={null} await device.setVirtualSceneImage({ image: `${process.env.TEAM_STORAGE_DIR}/barcode.jpg`, location: "wall", }); await device.playAutomation({ macro: "Walk_to_image_room", }); ``` ## When to use * Your app has a barcode or QR code scanner and you need to test it without physical hardware. * Your app uses QR codes to initiate a session, link an account, or navigate to a URL. * Your app reads barcodes as part of a checkout, inventory, or verification flow. * Your test needs to assert on a specific scanned value. ## Full sample test ```typescript theme={null} import { device, flow } from "@qawolf/flows/android"; const barcodeImagePath = `${process.env.TEAM_STORAGE_DIR}/barcode.jpg`; export default flow( "Scan barcode", { target: "Android - Pixel", launch: true }, async ({ driver, test }) => { await test("scan barcode and verify value", async () => { // Arrange await driver.$(`//*[@text='Scan']`).click(); // Act await device.setVirtualSceneImage({ image: barcodeImagePath, location: "table", }); await device.playAutomation({ macro: "Walk_to_image_room", }); await driver .$(`//*[@text='Barcode Captured']`) .waitForDisplayed({ timeout: 10_000 }); // Assert const value = await driver .$(`//android.widget.TextView[@resource-id='com.example.app:id/scanned_value']`) .getAttribute("text"); if (value !== "1234567890128") { throw new Error(`Unexpected scan value: ${value}`); } }); }, ); ``` # Mock device location (Android) Source: https://docs.qawolf.com/android-location-mocking Set a custom GPS location on the Android emulator to test location-aware features in your app. Use `device.setGeoLocation()` to override the emulator's GPS coordinates during a flow. This lets you test location-aware features — such as store finders, delivery zones, or region-specific content — without physically moving a device. See the [Android Device Controls](/libraries/flows/api-reference/android-device-reference) for the full `setGeoLocation` API. ## Examples **Set a GPS location** ```typescript theme={null} await device.setGeoLocation({ latitude: 40.78222, longitude: -73.96528, }); ``` ## When to use * Your app shows location-aware content such as store finders, delivery zones, or regional pricing. * Your app restricts features by geography and you need to test from a specific location. * Your app uses GPS coordinates to personalize content and you need to assert on that behavior. * Your test needs to simulate a device in a different city or country. ## Troubleshooting Some apps don't immediately respond to a location change. Try one or more of the following. **Grant location permissions automatically** Pass `autoGrantPermissions: true` in your launch options. ```typescript theme={null} const { driver } = await launch({ appPackage: "com.example.app", autoGrantPermissions: true, }); ``` **Toggle location services** ```typescript theme={null} await driver.toggleLocationServices(); await driver.toggleLocationServices(); ``` **Reload the session** ```typescript theme={null} await device.setGeoLocation({ latitude: 40.78222, longitude: -73.96528, }); await driver.reloadSession(); ``` **Prime location tracking via Google Maps** Some apps rely on the system location provider being active. Open Google Maps and trigger location tracking before your app reads the location, then reload the session. ```typescript theme={null} await driver.activateApp("com.google.android.apps.maps"); const skipBtn = driver.$(`//android.widget.Button[@text='SKIP']`); if (await skipBtn.isExisting()) { await skipBtn.click(); } const locationBtn = driver.$( `//android.widget.FrameLayout[@resource-id='com.google.android.apps.maps:id/mylocation_button']`, ); const contentDesc = await locationBtn.getAttribute("content-desc"); if (contentDesc.includes("Re-center")) { await locationBtn.click(); } const permissionBtn = driver.$( `//android.widget.Button[@resource-id='com.android.permissioncontroller:id/permission_allow_foreground_only_button']`, ); if (await permissionBtn.isExisting()) { await permissionBtn.click(); } await driver.reloadSession(); ``` ## Full sample test ```typescript theme={null} import { device, flow, launch } from "@qawolf/flows/android"; export default flow( "Test location feature", { target: "Android - Pixel", launch: true }, async ({ driver, test }) => { await test("set location and verify location-based content", async () => { // Arrange await driver.$(`//*[@text='Find Stores']`).click(); // Act await device.setGeoLocation({ latitude: 40.78222, longitude: -73.96528, }); // Assert await driver .$(`//*[@text='Stores near Central Park']`) .waitForDisplayed({ timeout: 10_000 }); }); }, ); ``` # Measure performance (Android) Source: https://docs.qawolf.com/android-performance Measure frame rate and CPU usage on the Android emulator during automated flows. Use `adb` commands via `device.adb(...)` to collect FPS and CPU metrics during a flow and assert on rendering performance and CPU load. See the [Android `dumpsys` documentation](https://developer.android.com/tools/dumpsys) for more on SurfaceFlinger stats. Performance measurements run on the Android emulator. FPS values reflect emulator rendering, not physical device GPU performance. CPU values are per-core percentages and can exceed 100% on multi-core emulators (typically 4 cores). ## Examples **Assert average FPS** ```typescript theme={null} await device.adb("shell dumpsys SurfaceFlinger --timestats -clear -enable"); // perform the rendering-heavy interaction you want to measure const resTxt = await device.adb("shell dumpsys SurfaceFlinger --timestats -dump"); const match = resTxt.match(/averageFPS\s*=\s*(?\d+\.\d*)/); const avgFps = Number(match.groups["avgFps"]); expect(avgFps).toBeGreaterThan(40); ``` If the device is idle when you collect stats, the reported FPS may be near zero because there is no active rendering. Always start measuring immediately before a rendering-heavy action. **Sample CPU usage** ```typescript theme={null} const packageName = await driver.getCurrentPackage(); const processId = await device.adb(`shell pidof ${packageName}`); const stdout = await device.adb(`shell top -n 1 -p ${processId}`); const match = stdout.match(/\S+\s+\S+\s+\S+\s+\S+\s+(\S+)/); const cpuUsage = parseFloat(match[1]); expect(cpuUsage).toBeLessThan(50); ``` ## When to use * Your app has animations or rendering-heavy screens and you need to assert on frame rate. * Your app performs background processing and you need to verify CPU usage stays within acceptable limits. * Your flow includes a workflow that should complete without excessive CPU load. * Your team has performance budgets you want to enforce in CI. ## Full sample test ```typescript theme={null} import { device, flow } from "@qawolf/flows/android"; export default flow( "Measure performance", { target: "Android - Pixel", launch: true }, async ({ driver, test }) => { await test("assert FPS", async () => { await device.adb("shell dumpsys SurfaceFlinger --timestats -clear -enable"); // perform the rendering-heavy interaction you want to measure const resTxt = await device.adb("shell dumpsys SurfaceFlinger --timestats -dump"); const match = resTxt.match(/averageFPS\s*=\s*(?\d+\.\d*)/); const avgFps = Number(match.groups["avgFps"]); expect(avgFps).toBeGreaterThan(40); }); await test("assert CPU usage", async () => { const packageName = await driver.getCurrentPackage(); const processId = await device.adb(`shell pidof ${packageName}`); const stdout = await device.adb(`shell top -n 1 -p ${processId}`); const match = stdout.match(/\S+\s+\S+\s+\S+\s+\S+\s+(\S+)/); const cpuUsage = parseFloat(match[1]); expect(cpuUsage).toBeLessThan(50); }); }, ); ``` # Mock hardware sensors (Android) Source: https://docs.qawolf.com/android-sensor-mocking Simulate accelerometer, gyroscope, light, and pressure sensor input on the Android emulator. Use `device.adb(...)` to send `adb emu sensor set` commands during a flow. This lets you simulate hardware sensor input and test how your app responds. ```typescript theme={null} import { device } from "@qawolf/flows/android"; ``` See the [Android emulator sensor documentation](https://developer.android.com/studio/run/emulator-console#sensor-set) for available sensor names and value formats. Sensor commands affect the emulator's sensor state directly. They work on emulators only — not physical devices. ## Examples **Accelerometer** ```typescript theme={null} await device.adb("emu sensor set acceleration 19:9.81:0"); ``` **Gyroscope** ```typescript theme={null} await device.adb("emu sensor set gyroscope 0.00:0.00:1.00"); ``` **Light** ```typescript theme={null} await device.adb("emu sensor set light 100"); ``` **Pressure** ```typescript theme={null} await device.adb("emu sensor set pressure 10"); ``` ## When to use * Your app responds to device tilt or motion and you need to test that behavior. * Your app adjusts UI based on ambient light levels. * Your app reads barometric pressure for altitude or weather features. * Your app uses gyroscope data for orientation or gesture detection. # Speaker recording (iOS) Source: https://docs.qawolf.com/audio-capture Record audio from the device speaker during a test to verify that your app plays the correct audio. Use `device.startSpeakerRecording()` to capture audio output from the iOS device speaker while your app is playing audio. The recording is saved as a WAV file that can be downloaded and analyzed. See the [iOS Device Controls](/libraries/flows/api-reference/ios-device-reference) for the full API. ## Examples **Record and download speaker audio** ```typescript theme={null} const session = await device.startSpeakerRecording(driver); // trigger audio playback in your app const file = await device.stopSpeakerRecording(driver, session.id); const buffer = await device.downloadSpeakerRecording(driver, file.filename); const { writeFile } = await import("node:fs/promises"); await writeFile("/tmp/output.wav", buffer); ``` ## When to use * Your app plays audio and you need to verify the correct sound or track played. * Your app uses text-to-speech and you need to validate the spoken output. * Your app plays audio prompts and you need to confirm they play correctly after an update. * Your test needs to capture audio output for comparison against a known reference. ## Analyzing the recording To compare the recording against a reference file, see [Audio analysis (iOS)](/i-os-audio-analysis). `stopSpeakerRecording()` automatically calculates a Chromaprint fingerprint and includes it as `file.fingerprint`. You can use this directly for audio analysis without calling `calculateAudioFingerprint()` separately. ## Full sample test ```typescript theme={null} import { device, flow } from "@qawolf/flows/ios"; export default flow( "Record speaker audio", { target: "iOS - iPhone 15 (iOS 26)", launch: true }, async ({ driver, test }) => { await test("record audio and verify file was captured", async () => { // Arrange await driver.$(`//XCUIElementTypeButton[@name='Play']`).waitForDisplayed({ timeout: 10_000 }); // Act const session = await device.startSpeakerRecording(driver); await driver.$(`//XCUIElementTypeButton[@name='Play']`).click(); await driver.pause(10_000); const file = await device.stopSpeakerRecording(driver, session.id); const buffer = await device.downloadSpeakerRecording(driver, file.filename); // Assert expect(buffer.length).toBeGreaterThan(0); const { writeFile } = await import("node:fs/promises"); await writeFile("/tmp/output.wav", buffer); }); }, ); ``` # iOS Barcode and QR code scanning Source: https://docs.qawolf.com/barcode-qrcode-scanning Inject barcode and QR code data into any iOS app to test scanning workflows without a physical camera. This feature works with any app, regardless of whether it uses Apple's built-in scanning or a third-party library like ZXing or Google ML Kit. ## Examples **Inject a QR code:** ```js theme={null} // Inject QR Code import { device } from "@qawolf/flows/ios"; const { bundleId } = await driver.execute("mobile: activeAppInfo"); const cleanup = await device.injectBarcode(driver, bundleId, { value: "https://qawolf.com/qr-code", }); // ... assert that the app handled the scanned QR code ... await cleanup(); ``` **Inject a barcode:** ```js theme={null} // Inject EAN-13 Barcode import { device } from "@qawolf/flows/ios"; const { bundleId } = await driver.execute("mobile: activeAppInfo"); const cleanup = await device.injectBarcode(driver, bundleId, { type: "org.gs1.EAN-13", value: "1234567890123", }); // ... assert that the app handled the scanned barcode ... await cleanup(); ``` ## When to use * Your app scans QR codes to trigger navigation, deep links, or actions. * Your app scans barcodes for product lookups, ticketing, or payments. * You need to test scanning flows without a physical code or camera setup. * You need to run the same scanning scenario repeatedly with consistent results. ## Supported formats **2D Codes:** * QR Code (`org.iso.QRCode`) * Aztec Code (`org.iso.Aztec`) * PDF417 (`org.iso.PDF417`) * Data Matrix (`org.iso.DataMatrix`) * Micro QR Code (`org.iso.MicroQRCode`) **1D Barcodes:** * EAN-13/EAN-8 (`org.gs1.EAN`) * UPC-A/UPC-E (`org.gs1.UPC`) * Code 128 (`org.gs1.Code128`) * Code 39 (`org.gs1.Code39`) * Code 93 (`org.gs1.Code93`) * ITF-14 (`org.gs1.ITF14`) * Interleaved 2 of 5 (`org.gs1.Interleaved2of5`) * Codabar (`org.gs1.Codabar`) ## Advanced: Multiple objects Some apps expect the camera to detect multiple codes simultaneously. To inject multiple detections in a single call: ```js theme={null} // Inject Multiple Barcodes (delivered to the delegate in one batch) import { device } from "@qawolf/flows/ios"; const cleanup = await device.injectBarcode(driver, bundleId, [ { type: "org.iso.QRCode", value: "https://example.com/qr-deep-link" }, { type: "org.gs1.EAN", value: "1234567890123" }, { type: "org.gs1.Code128", value: "PRODUCT-SKU-12345" }, ]); // ... assert that the app handled all scanned codes ... await cleanup(); ``` ## Advanced: Custom bounds and corners Some apps use the position of the detected code in the view. To include position data: ```js theme={null} // Inject QR Code with Custom Bounds and Corners import { device } from "@qawolf/flows/ios"; const cleanup = await device.injectBarcode(driver, bundleId, { type: "org.iso.QRCode", value: "https://example.com", bounds: { x: 0.3, // normalized coordinates (0.0 - 1.0) y: 0.2, width: 0.4, height: 0.6, }, corners: [ { x: 0.3, y: 0.2 }, // top-left { x: 0.7, y: 0.2 }, // top-right { x: 0.7, y: 0.8 }, // bottom-right { x: 0.3, y: 0.8 }, // bottom-left ], }); // ... assert that the app handled the scanned QR code at the expected position ... await cleanup(); ``` ## Advanced: Raw binary data To include raw binary data alongside the decoded value: ```js theme={null} // Inject QR Code with Raw Binary Data (iOS 13+) import { device } from "@qawolf/flows/ios"; const cleanup = await device.injectBarcode(driver, bundleId, { type: "org.iso.QRCode", value: "https://example.com", rawValue: Buffer.from("custom binary data"), // also accepts a base64-encoded string }); // ... assert the app received the raw payload ... await cleanup(); ``` ## Default values If not specified, the following defaults apply: * **type:** `org.iso.QRCode` * **bounds:** `{ x: 0.38, y: 0.27, width: 0.32, height: 0.57 }` * **corners:** Calculated automatically from bounds * **rawValue:** UTF-8 encoding of the value string ## Full sample test ```js theme={null} import { flow, device, expect } from "@qawolf/flows/ios"; export default flow( "iOS Media - AVMetadata Injection", { target: "iOS - iPhone 15 (iOS 26)", launch: { app: { env: "IPA_BUILD_LOCATION" }, respectSystemAlerts: true, autoAcceptAlerts: true, }, }, async ({ driver, test }) => { await test("App install and open AVMetadata", async () => { // Tap "Media" await driver .$( `-ios predicate string:name == 'Media' AND type == 'XCUIElementTypeButton'`, ) .waitForDisplayed(); await driver .$( `-ios predicate string:name == 'Media' AND type == 'XCUIElementTypeButton'`, ) .click(); // Click media dropdown await driver .$( `-ios predicate string:name == 'AVMetadata (Barcode/QRCode)' AND type == 'XCUIElementTypeStaticText'`, ) .waitForDisplayed(); await driver .$( `-ios predicate string:name == 'AVMetadata (Barcode/QRCode)' AND type == 'XCUIElementTypeStaticText'`, ) .click(); }); await test("QR Code Test", async () => { const qrCodeLinkToInject = "https://qawolf.com/qr-code"; await device.injectBarcode(driver, process.env.BUNDLE_ID, { value: qrCodeLinkToInject, }); await driver.pause(3000); //-------------------------------- // Assert: //-------------------------------- expect( await driver .$( `-ios predicate string:name == 'qrLastCodeLabel' AND type == 'XCUIElementTypeStaticText'`, ) .getText(), ).toBe(`QR Code: ${qrCodeLinkToInject}`); }); await test("Barcode Scanning Test", async () => { const barcodeToInject = "1234567890123"; await device.injectBarcode(driver, process.env.BUNDLE_ID, { type: "org.gs1.EAN-13", value: barcodeToInject, }); await driver.pause(3000); //-------------------------------- // Assert: //-------------------------------- expect( await driver .$( `-ios predicate string:name == 'qrLastCodeLabel' AND type == 'XCUIElementTypeStaticText'`, ) .getText(), ).toBe(`EAN-13: ${barcodeToInject}`); }); }, ); ``` # Test Chrome extensions Source: https://docs.qawolf.com/browser-extensions Load and test Chrome extensions in QA Wolf flows using Playwright's persistent context and Chrome flags. Use `launch()` with Chrome flags to load an extension into the browser before your flow runs. This lets you test extension popup UIs, content scripts, and extension behavior alongside your app. Store your extension zip in team storage and reference it via `process.env.TEAM_STORAGE_DIR`. See [Upload files](/Uploading-manually) for instructions. Replace `` with your extension's ID, which you can find at `chrome://extensions` with Developer mode enabled. ## Example ```typescript theme={null} import { flow, launch } from "@qawolf/flows/web"; const extensionPath = `${process.env.TEAM_STORAGE_DIR}/my-extension.zip`; const { context } = await launch({ browser: "chromium", persistentContext: true, args: [ `--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`, ], permissions: ["clipboard-read", "clipboard-write"], }); const extensionPage = await context.newPage(); await extensionPage.goto(`chrome-extension:///popup.html`); ``` ## When to use * Your app uses a Chrome extension and you need to test the full user flow with the extension installed. * You need to test the extension's popup UI or options page directly. * You need to verify that a content script modifies or interacts with a page correctly. ## Full sample test ```typescript theme={null} import { flow, launch } from "@qawolf/flows/web"; const extensionPath = `${process.env.TEAM_STORAGE_DIR}/my-extension.zip`; export default flow( "Test Chrome extension popup", "Web - Chrome", async ({ test }) => { await test("launch extension and verify popup renders", async () => { // Arrange const { context } = await launch({ browser: "chrome", persistentContext: true, acceptDownloads: true, args: [ `--allow-file-access-from-files`, `--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`, `--no-sandbox`, ], }); await expect .poll( () => context.pages(), { intervals: [1_000, 2_000, 10_000], timeout: 10_000 }, ) .toHaveLength(2); const extensionPage = context.pages().at(-1); // Act await extensionPage.locator("#open-popup").click(); // Assert await expect(extensionPage.locator("#popup-content")).toBeVisible(); }); }, ); ``` # Camera injection Source: https://docs.qawolf.com/camera-and-audio-injection Inject images into your app's camera input to test camera features without real hardware. Camera injection is only available for apps that QA Wolf resigns during installation. It is not available for system apps or Safari. ## Example **Inject an image into the camera feed:** ```javascript theme={null} import { device } from "@qawolf/flows/ios"; const bundleId = process.env.BUNDLE_ID; // Bundle ID of app being tested const storagePath = process.env.STORAGE_PATH; // QA Wolf remote storage const imagePath = `${storagePath}/large.jpg`; const cleanup = await device.injectCamera(driver, bundleId, { data: imagePath, type: "image", // optional — inferred from .jpg/.png/etc. }); // ... run your assertions while the camera feed is mocked ... await cleanup(); ``` Once the config is pushed, the camera preview updates to the injected image and loops continuously. ## When to use * Your app captures photos or displays a camera preview and you want to test that flow without a physical camera setup. * You need to run the same scenario repeatedly with consistent inputs. ## Supported file types **Images:** Any format supported by UIImage — JPG, PNG, HEIC, GIF, BMP, TIFF, WebP ## Full sample test ```js theme={null} import { flow, device, expect } from "@qawolf/flows/ios"; export default flow( "iOS Media - Camera Photo Injection", { target: "iOS - iPhone 15 (iOS 26)", launch: { app: { env: "IOS_APP_STAGING" }, respectSystemAlerts: true, autoAcceptAlerts: true, }, }, async ({ driver, test }) => { await test("iOS Media - Camera Photo Injection", async () => { //-------------------------------- // Arrange: //-------------------------------- // Install and Launch Trot app const imageName = "large.jpg"; const baseDir = process.env.BASE_IMAGE_DIR const imagePath = `${baseDir}/${imageName}`; // Tap "Media" await driver .$( `-ios predicate string:name == 'Media' AND type == 'XCUIElementTypeButton'`, ) .waitForDisplayed(); await driver .$( `-ios predicate string:name == 'Media' AND type == 'XCUIElementTypeButton'`, ) .click(); // Tap Video Recording await driver .$( `-ios predicate string:name == 'Video Recording' AND type == 'XCUIElementTypeButton'`, ) .waitForDisplayed(); await driver .$( `-ios predicate string:name == 'Video Recording' AND type == 'XCUIElementTypeButton'`, ) .click(); // Tap AVCaptureMovieFileOutput await driver .$( `-ios predicate string:name == 'AVCaptureMovieFileOutput' AND type == 'XCUIElementTypeButton'`, ) .waitForDisplayed(); await driver .$( `-ios predicate string:name == 'AVCaptureMovieFileOutput' AND type == 'XCUIElementTypeButton'`, ) .click(); // Observe "Start Recording" button await driver .$( `-ios predicate string:name == 'Start Recording' AND type == 'XCUIElementTypeStaticText'`, ) .waitForDisplayed({ timeout: 10000 }); //-------------------------------- // Act: //-------------------------------- // Inject image as the camera feed await device.injectCamera(driver, process.env.APP_ID, { data: `${imagePath}`, type: "image", }); await driver.pause(3000); //-------------------------------- // Assert: //-------------------------------- // Assert Screenshot of Image File Displaying in Live Preview const previewElement = driver.$( `-ios predicate string:name == 'livePreview' AND type == 'XCUIElementTypeOther'`, ); await previewElement.waitForDisplayed({ timeout: 5000 }); await expect(driver) .toHaveScreenshot( previewElement, "video_recording_image_preview", { maxMisMatchPercentage: 5 }, ); }); }, ); ``` # How to integrate with CircleCI Source: https://docs.qawolf.com/circle-ci Connect QA Wolf to CircleCI to automatically trigger test runs when you deploy code from your repository. Deployment triggers are configured for your main environment per your QA Wolf contract. Contact your QA Wolf representative before setting this up — a QAE must configure a deployment trigger for your environment before deploy notifications can initiate test runs. Make sure you have: * Access to your QA Wolf workspace. * Admin access to your CircleCI project. * At least one QA Wolf environment already configured with a deployment trigger. * A QA Wolf API key. ## Find the QAWOLF\_API\_KEY Open the `Workspace name` dropdown in QA Wolf and click **Workspace Settings**. Choose **Integrations** and then click the icon to the right of **API Key** under **API Access**. ### Add the QAWOLF\_API\_KEY secret Open your CircleCI project and go to **Project Settings → Environment Variables**. Click **Add Environment Variable**. Name it `QAWOLF_API_KEY`, paste your API key, and save. ## Add the notify script to your repository Create a file at `.circleci/notifyQaWolf.mjs` in the repository that corresponds to the deployments QA Wolf will be testing. ```javascript theme={null} import assert from "assert"; import { makeQaWolfSdk } from "https://esm.sh/@qawolf/ci-sdk@0.23.0"; const apiKey = process.env.QAWOLF_API_KEY; assert(apiKey, "QAWOLF_API_KEY is required"); const sha = process.env.CIRCLE_SHA1; assert(sha, "CIRCLE_SHA1 is required"); const branch = process.env.CIRCLE_BRANCH; assert(branch, "CIRCLE_BRANCH is required"); const deployConfig = { branch, // Required only if the target trigger requires matching a deployment type deploymentType: "staging", // e.g., "production", "staging", "qa" // Optional: Include deployment URL to override URL environment variable for the run deploymentUrl: undefined, hostingService: "GitHub", // Set to where your repo is hosted, e.g. "GitHub" or "GitLab" // Optional: Include pull request number for PR testing // pullRequestNumber: 123, // Recommended: Include repository information repository: { name: "your-repo-name", owner: "your-org-name", }, sha, }; const { attemptNotifyDeploy } = makeQaWolfSdk({ apiKey }); const result = await attemptNotifyDeploy(deployConfig); if (result.outcome !== "success") { // Fail the job. throw Error(`Failed to notify QAWolf: ${JSON.stringify(result)}`); } // result.runId can be output from the job to be used in a CI-greenlight job. ``` Replace `deploymentType` with the value your QA Wolf representative provides. Replace `name` and `owner` under `repository` with your actual repository details. Set `hostingService` to match where your code is hosted — `"GitHub"` or `"GitLab"` — not where your pipeline runs. ## Add the notify job to your CircleCI config Add the `notify-qa-wolf` job to your `.circleci/config.yml` and place it in your workflow after your deploy step. ```yaml theme={null} version: 2.1 orbs: node: circleci/node@7.0.0 jobs: # build: TODO # deploy: TODO notify-qa-wolf: executor: node/default steps: - checkout - run: node --version - run: node --experimental-network-imports .circleci/notifyQaWolf.mjs workflows: build-deploy-test-workflow: jobs: # - build # - deploy - notify-qa-wolf ``` The `notify-qa-wolf` job must: * Use the `node/default` executor. * Check out your code. * Run `node --experimental-network-imports .circleci/notifyQaWolf.mjs`. Place it after your deploy job in the workflow so it runs only once your environment is healthy. ## Verify the integration Push a new commit and wait for your CircleCI pipeline to complete. Open QA Wolf and confirm a new run appears under the expected environment. ## Related * [QA Wolf CI SDK](/other-ci-node) # Connect to a VPN (web) Source: https://docs.qawolf.com/connect-with-vpn-web Start an OpenVPN tunnel from a web flow to reach internal services or staging environments. This helper requires runner support. In QA Wolf-managed runs, the runner configures this automatically. If you're building a custom runner, see [Testkit Client](/libraries/testkit/api-reference/client). ## Examples **Start an OpenVPN tunnel** ```typescript theme={null} import { startOpenVpn } from "@qawolf/testkit"; const pid = await startOpenVpn({ configPath: "/tmp/client.ovpn", }); ``` **Limit routing to specific hosts** ```typescript theme={null} import { startOpenVpn } from "@qawolf/testkit"; const pid = await startOpenVpn({ configPath: "/tmp/client.ovpn", routeHosts: ["internal.example.com", "api.staging.io"], }); ``` ## When to use * Your web app connects to internal or staging services that aren't publicly accessible. * Your flow needs to reach an API behind a corporate firewall. * You need to test against a staging environment that requires VPN access. ## Full example ```typescript theme={null} import { flow } from "@qawolf/flows/web"; import { startOpenVpn } from "@qawolf/testkit"; export default flow( "Test internal API", { target: "Web - Chrome", launch: true }, async ({ page, test }) => { await test("connect to VPN", async () => { await startOpenVpn({ configPath: "/tmp/client.ovpn", routeHosts: ["internal.example.com"], }); }); await test("navigate to internal service", async () => { await page.goto(process.env["INTERNAL_URL"]!); await expect(page).toHaveURL(/internal/); }); }, ); ``` # webhooks/deploy_success Source: https://docs.qawolf.com/deploy-success Notify QA Wolf of a successful deployment to trigger a test run, including request format, headers, body fields, and example responses. `POST https://app.qawolf.com/api/webhooks/deploy_success` If your build server supports `node`, use [`@qawolf/ci-sdk`](/libraries/ci-sdk/api-reference) instead of calling this endpoint directly. This gives you type safety and clearer errors and output. ## Request ```bash theme={null} curl -X POST https://app.qawolf.com/api/webhooks/deploy_success \ -H "Authorization: Bearer $QAWOLF_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "branch": "main", "sha": "de12adda500f2bc5a29dbd89f4fb1b0e1a31de81", "deployment_type": "staging", "deployment_url": "https://staging.example.com", "hosting_service": "GitHub", "workspaceId": "ckz2y0o7j01914cgtra6lk4wn" }' ``` ## Request headers | Header | Value | | --------------- | ----------------------- | | `Authorization` | `Bearer ` | | `Content-Type` | `application/json` | ## Request body All fields are optional. ### Commonly used | Field | Type | Description | | ----------------- | ------ | --------------------------------------------------------------------------------------------------- | | `branch` | string | Git branch name. Used to display in the UI and match pull requests on any linked repo. | | `sha` | string | Git commit SHA. Used to create GitHub commit checks and display a link to the commit in the run UI. | | `deployment_type` | string | Required if the target trigger is configured to match a deployment type. | | `deployment_url` | string | Overrides the environment URL. Available in tests as `process.env.URL`. | ### Advanced | Field | Type | Description | | ----------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `hosting_service` | string | `"GitHub"` or `"GitLab"`. Defaults to `"GitHub"`. | | `commit_url` | string | Pass with `sha` if no hosting service repo is configured. Makes the commit ID a clickable link in the QA Wolf UI. | | `variables` | object | Key/value pairs that override environment variables for every run triggered by this notification. | | `deduplication_key` | string | Custom key controlling run cancellation behavior. By default, new runs cancel ongoing runs with the same branch/environment combination. | | `ephemeral_environment` | boolean | Pass with `deployment_url` if the deployment is not associated with a code-hosting integration. | | `workspaceId` | string | Workspace to create runs in. Ignored by a workspace API key, which already implies its workspace. With an organization or user API key, omitting it uses your organization's first-created workspace. See [which workspace a request acts on](/rest-overview#which-workspace-a-request-acts-on). | ## Response ```json theme={null} { "results": [ { "created_suite_id": "cl1f6i0in15676w115vt43vw2", "outcome": "created", "trigger_id": "ckzoog9wy01720xyrvl8ah7gu" } ] } ``` `results` is an array of matched triggers. Each entry carries an `outcome` of `created`, `skipped`, or `failed`, alongside `created_suite_id` if QA Wolf created a run, `duplicate_suite_id` if a run for this deployment already exists, or `failure_code` and `failure_message` if QA Wolf could not create the run. Branch on `failure_code`, such as `billing-prevented` or `environment-not-ready`, and display `failure_message`. Treat `failure_code` as an open set, since QA Wolf adds new codes over time. A failed entry also carries `failure_reason`, which is deprecated. It holds an internal diagnostic whose values change without notice, so do not branch on it. `warning` is present when the request needed a workspace and did not name one, in which case it reports the workspace QA Wolf used. It never changes the outcome. `@qawolf/ci-sdk` prints it for you. ```json theme={null} { "results": [ { "created_suite_id": "cl1f6i0in15676w115vt43vw2", "outcome": "created", "trigger_id": "ckzoog9wy01720xyrvl8ah7gu" } ], "warning": "No workspaceId was sent, so this request used your first-created workspace: Acme (ckz2y0o7j01914cgtra6lk4wn). Send workspaceId to choose explicitly." } ``` On the first delivery of a deployment, a matched trigger returns `created_suite_id` even when the deployment duplicates one QA Wolf is already handling. Deduplication is settled after this response is sent, so the synchronous response does not report it. To find out whether a duplicate superseded your run, poll [CI greenlight](/v0-ci-greenlight) and compare `relevantRunId` with `rootRunId` (see [Superseding logic](/v0-ci-greenlight#superseding-logic)). You see `duplicate_suite_id` only when retrying `deploy_success` for the same `sha` after deduplication has already settled. On the first delivery, QA Wolf never reports a matched trigger as a duplicate. ## Response codes | Code | Description | | ----- | ------------------------------------------------------------------------- | | `200` | Request accepted. Inspect the response body to confirm a run was created. | | `401` | Missing or invalid API key. | | `403` | Forbidden. Usually indicates a disabled workspace. | | `404` | Run not found. | | `405` | Method not allowed. Use `POST`. | | `500` | Internal server error. Contact support if the issue persists. | A 200 response does not guarantee a run was created. Inspect the response body to confirm. ## Related * [`@qawolf/ci-sdk`](/libraries/ci-sdk/api-reference) * [CI greenlight](/v0-ci-greenlight) # Testing Electron apps Source: https://docs.qawolf.com/electron Test a desktop app built with Electron from a QA Wolf flow. Electron flows use `@qawolf/flows/web`. The callback receives the first window as `page`, giving you the full Playwright API for interacting with the app. ## Examples **Launch and interact with an Electron app** ```typescript theme={null} import { flow } from "@qawolf/flows/web"; export default flow( "Sign in to desktop app", { target: "Electron", launch: { executablePath: "/Applications/MyApp.app/Contents/MacOS/MyApp" }, }, async ({ page, test }) => { await test("sign in", async () => { await page.getByRole("button", { name: "Sign in" }).click(); }); }, ); ``` **Choose the executable path at runtime** Use explicit launch when the path depends on runtime logic — for example, switching between a stable and canary build. ```typescript theme={null} import { flow, launch } from "@qawolf/flows/web"; export default flow("Sign in to desktop app", "Web - Chrome", async () => { const useCanary = process.env.USE_CANARY_APP === "true"; const { firstWindowPage } = await launch({ kind: "electron", executablePath: useCanary ? "/Applications/MyApp Canary.app/Contents/MacOS/MyApp" : "/Applications/MyApp.app/Contents/MacOS/MyApp", }); await firstWindowPage.getByRole("button", { name: "Sign in" }).click(); }); ``` ## When to use * Your app is a desktop application built with Electron. * You need to test app startup, window behavior, or native OS integrations. * Your flow needs to interact with the first Electron window before any navigation. * You want to switch between builds — stable vs canary — at runtime. ## Notes **Declarative vs explicit launch** For most Electron flows, use the declarative style with `target: "Electron"` and `launch.executablePath`. The callback receives the first window as `page` automatically. Use explicit `launch({ kind: "electron", executablePath })` only when the executable path depends on runtime logic. In this case, the first window is returned as `firstWindowPage` rather than injected as `page`. For the full Electron launch shape, see the [Web API Reference](/libraries/flows/api-reference/web). # webhooks/environment_terminated Source: https://docs.qawolf.com/environment_terminated Stops all runs targeting the environment and requests flow promotion to the static base environment. The environment will show as "closed" in QA Wolf. `POST https://app.qawolf.com/api/webhooks/environment_terminated` If your build server supports `node`, use [`@qawolf/ci-sdk`](/libraries/ci-sdk/api-reference) instead of calling this endpoint directly. This gives you type safety and clearer errors and output. ## Request ```bash theme={null} curl -X POST https://app.qawolf.com/api/webhooks/environment_terminated \ -H "Authorization: Bearer $QAWOLF_API_KEY" \ -H "Content-Type: application/json" \ -d '{"deploymentUrl": "", "workspaceId": ""}' ``` ## Request headers | Header | Value | | --------------- | ----------------------- | | `Authorization` | `Bearer ` | | `Content-Type` | `application/json` | ## Request body Pass one of the following to identify the environment. | Field | Type | Description | | ------------------ | ------ | ---------------------------------------------------------------------------------------------------------- | | `environmentId` | string | ID of the ephemeral environment to terminate. | | `environmentAlias` | string | Alias of the ephemeral environment to terminate. | | `deploymentUrl` | string | Preview URL used when the environment was created via `deploy_success` with `ephemeral_environment: true`. | You may also pass `workspaceId`. | Field | Type | Description | | ------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `workspaceId` | string | Workspace that owns the environment. Only used alongside `environmentAlias` or `deploymentUrl`, since `environmentId` identifies its own workspace. Ignored by a workspace API key. With an organization or user API key, omitting it looks the alias up in your organization's first-created workspace. See [which workspace a request acts on](/rest-overview#which-workspace-a-request-acts-on). | ## Response ```json theme={null} { "code": "terminated", "environmentId": "cl1f6i0in15676w115vt43vw2" } ``` A `warning` field behaves the same way here as on [webhooks/deploy\_success](/deploy-success#response) — present when the request needed a workspace and didn't name one, never changing the outcome. ## Response codes | Code | Description | | ----- | ------------------------------------------------------------- | | `200` | Environment terminated successfully. | | `401` | Missing or invalid API key. | | `403` | Forbidden. Usually indicates a disabled workspace. | | `404` | Environment not found. | | `409` | Conflict. Environment is already terminated. | | `500` | Internal server error. Contact support if the issue persists. | ## Related * [`@qawolf/ci-sdk`](/libraries/ci-sdk/api-reference) * [Notify deploy](/deploy-success) # Beacon injection (iOS) Source: https://docs.qawolf.com/i-beacon-injection Simulate iBeacon proximity events in your iOS app without physical beacon hardware. Use `device.injectBeacon()` to simulate the device detecting a nearby iBeacon, triggering `CLLocationManager` callbacks in your app as if real hardware were present. Pushing a `.region` file triggers a region-entry callback; also pushing a `.beacon` file triggers ranging callbacks. See the [iOS Device Controls](/libraries/flows/api-reference/ios-device-reference) for the full `injectBeacon` API. ## Examples **Region entry and ranging** ```typescript theme={null} const cleanup = await device.injectBeacon(driver, "com.example.app", { uuid: "8613BEAD-5465-4515-8F9C-AEEA717484C9", beacons: [{ major: 1, minor: 7 }], }); // assert your app responded to the beacon event await cleanup(); ``` **Region entry only (no ranging)** Omit the `beacons` array to trigger only a region-entry event. ```typescript theme={null} const cleanup = await device.injectBeacon(driver, "com.example.app", { uuid: "e62c96fd-014a-454f-9b41-32245d802bb3", }); // assert your app responded to the region entry await cleanup(); ``` ## When to use * Your app uses iBeacon proximity for retail check-ins, loyalty triggers, or location-based promotions. * Your app uses indoor navigation or asset tracking via Bluetooth beacons. * Your app responds to beacon region entry or exit events. * Your test needs to verify ranging callbacks with specific major/minor values. ## Location permissions iBeacons require location permissions. Two important caveats: **`autoAcceptAlerts` chooses "Ask Next Time" for location dialogs.** If the permission dialog appears, Appium will not choose "Always Allow" — and there is no way to change it after the fact. **Background location access cannot be auto-accepted.** If your app requires "Change to Always Allow" for background beacon detection, you must handle the dialog manually using `respectSystemAlerts: true`: ```typescript theme={null} const { driver: d } = await launch({ bundleId: "com.example.app", respectSystemAlerts: true, }); const allowBtn = d.$(`//XCUIElementTypeButton[@name='Allow']`); if (await allowBtn.isExisting()) { await allowBtn.click(); } ``` `respectSystemAlerts: true` may slow down Appium performance. Use it only when you need to interact with system dialogs manually. ## Full sample test ```typescript theme={null} import { device, flow } from "@qawolf/flows/ios"; export default flow( "Test beacon proximity", { target: "iOS - iPhone 15 (iOS 26)", launch: true }, async ({ driver, test }) => { await test("inject beacon and verify app response", async () => { // Arrange const allowBtn = driver.$(`//XCUIElementTypeButton[@name='Allow']`); if (await allowBtn.isExisting()) { await allowBtn.click(); } await driver.$(`//XCUIElementTypeStaticText[@name='Nearby']`).waitForDisplayed({ timeout: 10_000 }); // Act const cleanup = await device.injectBeacon(driver, process.env.BUNDLE_ID, { uuid: "8613BEAD-5465-4515-8F9C-AEEA717484C9", beacons: [{ major: 1, minor: 7 }], }); // Assert await driver .$(`//XCUIElementTypeStaticText[@name='Beacon Detected']`) .waitForDisplayed({ timeout: 10_000 }); await cleanup(); }); }, ); ``` # Audio analysis (iOS) Source: https://docs.qawolf.com/i-os-audio-analysis Compare recorded audio against a reference file using Chromaprint fingerprinting to verify your app plays the correct audio. Use `device.calculateAudioFingerprint()` to generate a Chromaprint fingerprint from a recorded audio file and compare it against a known reference. Chromaprint supports fuzzy matching — small differences in volume or encoding don't affect the result, making it resilient to minor variations in playback. See the [iOS Device Controls](/libraries/flows/api-reference/ios-device-reference) for the full API. Store your reference audio file in team storage and reference it via `process.env.TEAM_STORAGE_DIR`. See [Upload files](/Uploading-manually) for instructions. `stopSpeakerRecording()` automatically calculates a fingerprint — you only need to call `calculateAudioFingerprint()` separately for the reference file. ## Examples **Compare recorded audio against a reference** ```typescript theme={null} import { readFile } from "node:fs/promises"; const session = await device.startSpeakerRecording(driver); // trigger audio playback in your app const file = await device.stopSpeakerRecording(driver, session.id); const buffer = await device.downloadSpeakerRecording(driver, file.filename); const recordingFingerprint = file.fingerprint ?? (await device.calculateAudioFingerprint(driver, buffer)).fingerprint; const referenceBuffer = await readFile(`${process.env.TEAM_STORAGE_DIR}/reference.wav`); const { fingerprint: referenceFingerprint } = await device.calculateAudioFingerprint(driver, referenceBuffer); const result = findBestMatch(recordingFingerprint, referenceFingerprint); expect(result.similarity).toBeGreaterThan(0.85); ``` ## When to use * Your app plays audio and you need to verify the correct track or sound effect played. * Your app uses text-to-speech and you need to validate the output matches expected speech. * Your app plays audio prompts and you need to confirm they haven't changed after an update. * Your app has audio-dependent features and functional assertions aren't sufficient. ## Similarity thresholds | Threshold | Use case | | --------- | -------------------------------------------------------------------------- | | `> 0.95` | Near-identical audio — same file, minimal encoding differences | | `> 0.85` | Same content with minor volume or quality variations (recommended default) | | `> 0.70` | Looser match — same song or speech with notable differences | ## Full sample test ```typescript theme={null} import { device, flow } from "@qawolf/flows/ios"; import { readFile } from "node:fs/promises"; const REFERENCE_AUDIO_PATH = `${process.env.TEAM_STORAGE_DIR}/reference.wav`; export default flow( "Verify audio playback", { target: "iOS - iPhone 15 (iOS 26)", launch: true }, async ({ driver, test }) => { await test("record audio and compare against reference", async () => { // Arrange await driver.$(`//XCUIElementTypeButton[@name='Play']`).waitForDisplayed({ timeout: 10_000 }); // Act const session = await device.startSpeakerRecording(driver); await driver.$(`//XCUIElementTypeButton[@name='Play']`).click(); await driver.pause(10_000); const file = await device.stopSpeakerRecording(driver, session.id); const buffer = await device.downloadSpeakerRecording(driver, file.filename); // Assert const recordingFingerprint = file.fingerprint ?? (await device.calculateAudioFingerprint(driver, buffer)).fingerprint; const referenceBuffer = await readFile(REFERENCE_AUDIO_PATH); const { fingerprint: referenceFingerprint } = await device.calculateAudioFingerprint(driver, referenceBuffer); const result = findBestMatch(recordingFingerprint, referenceFingerprint); expect(result.similarity).toBeGreaterThan(0.85); }); }, ); ``` # Welcome to QA Wolf Source: https://docs.qawolf.com/index Create, run, and maintain end-to-end tests automatically with QA Wolf. QA Wolf helps teams plan, create, run, and maintain end-to-end tests automatically as part of the AI-driven software development lifecycle. Using AI, QA Wolf maps your application, generates tests from natural-language prompts, and runs them in parallel on managed infrastructure. The tests are written as standard Playwright and Appium code, so your team keeps full ownership and can review, modify, and version them like any other code. QA Wolf supports the full lifecycle of end-to-end testing—from identifying the user journeys that matter, to generating automation, to continuously validating releases as your product evolves. *** ## How QA Wolf works Testing with QA Wolf happens in three steps. ### Outline AI explores your application and identifies the user paths that can be tested. This creates a coverage outline of your product so teams can understand what to cover first, and where new features create gaps in coverage. ### Create Describe a flow in plain language, and QA Wolf generates Playwright and Appium tests to validate the behavior of your application. The resulting tests are standard code that your team owns and can modify. ### Run Tests run in parallel across QA Wolf infrastructure. Runs can be triggered on demand, scheduled, or executed automatically from CI pipelines. *** ## Why use QA Wolf QA Wolf helps teams build reliable end-to-end coverage without managing testing infrastructure or maintaining fragile automation. Our deterministic tests make results trustworthy, so teams can move faster without second-guessing failures. And because we can handle the most complex test cases, teams can cover critical user journeys that other tools can't cover. ### AI-generated automation Describe user journeys in natural language, and QA Wolf generates tests in Playwright and Appium. ### Customer-owned test code Tests remain fully accessible to your team. ### Parallel execution by default Run large suites quickly without managing browsers, devices, or test runners. ### Continuous validation Tests run automatically across environments, so teams know when changes break critical user journeys. ### Clear coverage of critical paths AI mapping helps teams understand what parts of the product are tested and where additional coverage may be needed. *** ## What you can test with QA Wolf QA Wolf is designed to validate the workflows that matter most in modern applications—from simple journeys to complex multi-system interactions. ### Web applications QA Wolf supports complex browser-based interactions and modern web application architectures. * Canvas and WebGL interfaces. * Drag-and-drop interactions. * File uploads and downloads. * PDF generation and validation. * Visual regression and UI diffing. * Multi-user workflows across multiple sessions. * Multi-site flows that move across domains. * Browser extensions and plugins. * Electron and hybrid desktop applications. ### iOS applications Test native iOS apps on **real iPhones and iPads**. * Gestures, layouts, and device orientation. * Push notifications and deep links. * Apple Pay transactions. * Apple ID authentication. * Camera, photos, and video interactions. * System permissions and OS dialogs. * Sensors such as GPS, Bluetooth, and Wi-Fi. * Cross-device journeys between web and mobile. ### Android applications Test native Android apps in **isolated device environments designed for automation scale**. * Google Pay transactions. * Push notifications and deep links. * Android permission prompts. * Camera and sensor interactions. * Device orientation and layout changes. * Native authentication flows. * Companion app workflows for connected systems. *** ## Who QA Wolf is for * **Manual QA.** Map user journeys, generate end-to-end tests with AI, and validate behavior across releases without needing to write code. * **Test Automators / SDET.** Build, extend, and maintain Playwright and Appium test suites with full access to the underlying code and infrastructure. * **Developers.** Generate tests from prompts, modify the underlying code when needed, and run tests in CI to validate application behavior on every change. * **Product and Design teams.** Map critical user flows and validate the experience before releases to ensure key journeys work as expected. *** ## Security and data protection QA Wolf is designed to support teams building secure applications. * **SOC 2 Type II compliant.** QA Wolf meets rigorous security and operational standards verified through independent audits. * **HIPAA-ready infrastructure.** QA Wolf supports teams building and testing applications that must meet HIPAA requirements. * **Secure infrastructure.** Tests run in isolated environments managed by QA Wolf. * **Secure credential storage.** Sensitive credentials are stored securely and can be safely used in automated tests. Learn more in the [Security](https://trust.qawolf.com) documentation. *** ## Explore QA Wolf Connect your application and create your first tests. Learn how workspaces, environments, flows, and runs are organized. Let QA Wolf build and maintain your test suite for you. # Microphone injection (iOS) Source: https://docs.qawolf.com/ios-audio-injection Inject audio into your app's microphone input to test audio features without real hardware. Use `device.injectAudio()` to replace your app's microphone input with a pre-recorded audio file. This lets you test voice recording, speech recognition, and audio processing features under controlled, repeatable conditions. See the [iOS Device Controls](/libraries/flows/api-reference/ios-device-reference) for the full API. Audio injection is only available for apps that QA Wolf resigns during installation. It is not available for system apps or Safari. ## Examples **Inject audio into the microphone** ```typescript theme={null} const cleanup = await device.injectAudio(driver, process.env.BUNDLE_ID, { data: `${process.env.TEAM_STORAGE_DIR}/sample_audio_input.wav`, }); // trigger the microphone feature in your app await cleanup(); ``` **Pull the recording off the device** ```typescript theme={null} const recordingAsBase64 = await driver.execute("mobile: pullFile", { remotePath: `@${process.env.BUNDLE_ID}:/path/to/recording.m4a`, }); const buffer = Buffer.from(recordingAsBase64, "base64"); const { writeFile } = await import("node:fs/promises"); await writeFile(`${process.env.TEAM_STORAGE_DIR}/recording.m4a`, buffer); ``` ## When to use * Your app records audio or processes microphone input and you need to test that flow with known audio data. * Your app has voice commands or speech recognition features that need consistent, repeatable input. * Your app validates or transforms microphone input and you need to assert on the output. * Your test needs to simulate a specific audio input that is difficult to produce in a test environment. ## Supported file types WAV ## Full sample test ```typescript theme={null} import { device, flow } from "@qawolf/flows/ios"; const audioPath = `${process.env.TEAM_STORAGE_DIR}/sample_audio_input.wav`; export default flow( "Test microphone input", { target: "iOS - iPhone 15 (iOS 26)", launch: { app: { env: "IPA_BUILD_LOCATION" }, respectSystemAlerts: true, autoAcceptAlerts: true, }, }, async ({ driver, test }) => { await test("inject audio and verify recording was saved", async () => { // Arrange await driver.$(`//XCUIElementTypeButton[@name='Record Audio']`).click(); // Act const cleanup = await device.injectAudio(driver, process.env.BUNDLE_ID, { data: audioPath, }); await driver.$(`//XCUIElementTypeButton[@name='Record']`).click(); await driver.pause(10_000); await driver.$(`//XCUIElementTypeButton[@name='Done']`).click(); await cleanup(); // Assert const recordingAsBase64 = await driver.execute("mobile: pullFile", { remotePath: `@${process.env.BUNDLE_ID}:/path/to/recording.m4a`, }); const buffer = Buffer.from(recordingAsBase64, "base64"); const { writeFile } = await import("node:fs/promises"); await writeFile(`${process.env.TEAM_STORAGE_DIR}/recording.m4a`, buffer); expect(buffer.length).toBeGreaterThan(0); }); }, ); ``` # SDK Reference Source: https://docs.qawolf.com/libraries/ci-sdk/api-reference Reference for the @qawolf/ci-sdk TypeScript package, including installation, initialization, and functions for triggering and polling test runs from CI. `@qawolf/ci-sdk` provides a TypeScript SDK (CJS and ESM compatible) for interacting with the QA Wolf API from your CI pipeline. ## Installation ```bash theme={null} npm install @qawolf/ci-sdk ``` Requires Node.js 18 or later. ```javascript theme={null} import { fetch } from "undici"; const sdk = makeQaWolfSdk({ apiKey: process.env.QAWOLF_API_KEY }, { fetch }); ``` ## makeQaWolfSdk The entry point for all SDK functions. Pass your `QAWOLF_API_KEY` to initialize. ```javascript theme={null} import { makeQaWolfSdk } from "@qawolf/ci-sdk"; const { attemptNotifyDeploy, pollCiGreenlightStatus, makePollCiGreenlightStatusIterator, notifyTerminatedEphemeralEnvironment, generateSignedUrlForRunInputsExecutablesStorage, } = makeQaWolfSdk({ apiKey: process.env.QAWOLF_API_KEY, }); ``` SDK functions do not throw. They return a result object with an `outcome` field. Always inspect the outcome to determine whether your CI step should pass or fail. ## attemptNotifyDeploy Notifies QA Wolf of a successful deployment, which starts a run if one is configured. See [webhooks/deploy\_success](/deploy-success) for the underlying endpoint. ```javascript theme={null} import { type DeployConfig, makeQaWolfSdk } from "@qawolf/ci-sdk"; const { attemptNotifyDeploy } = makeQaWolfSdk({ apiKey: process.env.QAWOLF_API_KEY, }); const deployConfig: DeployConfig = { branch: undefined, sha: undefined, deploymentType: "staging", deploymentUrl: undefined, hostingService: undefined, commitUrl: undefined, deduplicationKey: undefined, variables: undefined, workspaceId: process.env.QAWOLF_WORKSPACE_ID, }; const result = await attemptNotifyDeploy(deployConfig); if (result.outcome !== "success") { process.exit(1); } const runId = result.runId; // Store runId as a CI job output to pass to pollCiGreenlightStatus. ``` ### DeployConfig fields | Field | Type | Description | | ---------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------- | | `branch` | string | VCS branch name. | | `sha` | string | VCS commit SHA. | | `deploymentType` | string | Required if the target trigger matches on deployment type. | | `deploymentUrl` | string | Overrides the environment URL. Available in tests as `process.env.URL`. | | `hostingService` | string | `"GitHub"` or `"GitLab"`. Defaults to `"GitHub"`. | | `commitUrl` | string | Pass with `sha` if no hosting service repo is configured. Makes the commit ID a clickable link in the QA Wolf UI. | | `pullRequestNumber` | number | VCS PR number, for PR testing. | | `mergeRequestNumber` | number | VCS MR number, for MR testing. | | `repository.name` | string | Repository name. | | `repository.owner` | string | Repository owner or organization (VCS). | | `repository.namespace` | string | Repository namespace or group (VCS). | | `ephemeralEnvironment` | boolean | Pass with `deploymentUrl` for ephemeral environments without a code-hosting integration. | | `deduplicationKey` | string | Custom key controlling run cancellation behavior. | | `variables` | object | Key/value pairs that override environment variables for triggered runs. | | `workspaceId` | string | Workspace to create runs in — same semantics as the [`deploy_success` webhook's `workspaceId`](/deploy-success#request-body). | ### Result fields | Field | Description | | ------------- | ---------------------------------------------------------------------------------- | | `outcome` | `"success"`, `"failed"`, or `"aborted"`. | | `runId` | ID of the triggered run. Pass to `pollCiGreenlightStatus`. | | `failReason` | Present when `outcome` is `"failed"`. Reason the notification failed. | | `abortReason` | Present when `outcome` is `"aborted"`. Reason the notification was aborted. | | `httpStatus` | HTTP status code if the notification failed or was aborted due to a network error. | A run is only created if there is a matching trigger in your QA Wolf configuration. `outcome: "success"` means the notification was accepted, not that a run was created. Check `runId` to know whether a run was created. ## pollCiGreenlightStatus Polls the [CI greenlight endpoint](/v0-ci-greenlight) until the run completes. Returns whether it is safe to release. ```javascript theme={null} import { makeQaWolfSdk } from "@qawolf/ci-sdk"; const { pollCiGreenlightStatus } = makeQaWolfSdk({ apiKey: process.env.QAWOLF_API_KEY, }); const { outcome } = await pollCiGreenlightStatus({ runId, onRunStageChanged: (current, previous) => { console.log(current, previous); }, abortOnSuperseded: false, }); if (outcome !== "success") { process.exit(1); } ``` ### Options | Field | Type | Description | | ------------------- | -------- | -------------------------------------------------------------------------- | | `runId` | string | The run ID returned by `attemptNotifyDeploy`. | | `onRunStageChanged` | function | Optional callback fired when the run stage changes. | | `abortOnSuperseded` | boolean | Defaults to `false`. When `true`, polling aborts if the run is superseded. | | `pollTimeout` | number | Timeout in milliseconds. Defaults to two hours. | ### Result fields | Field | Description | | ------------- | ----------------------------------------------------------------------------------- | | `outcome` | `"success"`, `"failed"`, or `"aborted"`. Only `"failed"` indicates bugs were found. | | `abortReason` | Present when `outcome` is `"aborted"`. Reason polling stopped. | | `httpStatus` | HTTP status code if polling was aborted due to a network error. | ### Advanced: makePollCiGreenlightStatusIterator An async generator for fine-grained control over the polling lifecycle. Use this when you need custom early-exit logic — for example, proceeding after a time limit or when bug counts are within an acceptable threshold. ```javascript theme={null} import { makeQaWolfSdk } from "@qawolf/ci-sdk"; const { makePollCiGreenlightStatusIterator } = makeQaWolfSdk({ apiKey: process.env.QAWOLF_API_KEY, }); let underReviewStartTime: number | null = null; const iterator = makePollCiGreenlightStatusIterator({ runId: "your-run-id" }); for await (const iteration of iterator) { if (iteration.isAborted) { console.error(`Poll aborted: ${iteration.abortReason}`); process.exit(1); } const { status, stageChanged } = iteration; switch (status.runStage) { case "initializing": break; case "underReview": if (stageChanged) underReviewStartTime = Date.now(); const timeInReview = underReviewStartTime ? Date.now() - underReviewStartTime : 0; if (timeInReview > 10 * 60 * 1000) { if (status.blockingBugsCount > 5) process.exit(1); } break; case "completed": if (!status.greenlight) process.exit(1); return; case "canceled": process.exit(1); default: status.runStage satisfies never; throw new Error(`Unexpected run stage: ${status.runStage}`); } } ``` Each iteration yields either a status update or an abort notification: | Field | Present when | Description | | ---------------- | ------------------ | ------------------------------------------------------------------- | | `isAborted` | always | `false` for status updates, `true` for abort notifications. | | `status` | `isAborted: false` | Current `CiGreenlightStatus` from the API. | | `previousStatus` | `isAborted: false` | Status from the previous iteration. `undefined` on first iteration. | | `stageChanged` | `isAborted: false` | `true` if the run stage changed from the previous iteration. | | `elapsedMs` | always | Milliseconds elapsed since polling started. | | `abortReason` | `isAborted: true` | Why polling was aborted. | | `httpStatus` | `isAborted: true` | HTTP status code if applicable. | Handle all run stages in your `switch` statement. The `default: status.runStage satisfies never` pattern provides compile-time safety — TypeScript will error if a new stage is added and your code doesn't handle it. ## notifyTerminatedEphemeralEnvironment Notifies QA Wolf that an ephemeral environment has been terminated. Stops all runs targeting the environment and triggers flow promotion. See [webhooks/environment\_terminated](/environment_terminated) for the underlying endpoint. ```javascript theme={null} import { type NotifyTerminatedEphemeralEnvironmentInput, makeQaWolfSdk, } from "@qawolf/ci-sdk"; const { notifyTerminatedEphemeralEnvironment } = makeQaWolfSdk({ apiKey: process.env.QAWOLF_API_KEY, }); const terminateConfig: NotifyTerminatedEphemeralEnvironmentInput = { deploymentUrl: "https://preview-123.example.com", }; const result = await notifyTerminatedEphemeralEnvironment(terminateConfig); if (result.outcome !== "success") { process.exit(1); } const environmentId = result.environmentId; ``` ### Input fields Pass one of the following to identify the environment: | Field | Type | Description | | ------------------ | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `environmentId` | string | ID of the ephemeral environment. | | `environmentAlias` | string | Alias of the ephemeral environment. | | `deploymentUrl` | string | Preview URL used when the environment was created — must match the `deploymentUrl` passed to `attemptNotifyDeploy` along with `ephemeralEnvironment: true` when originally notifying QA Wolf. | You may also pass `workspaceId`: | Field | Type | Description | | ------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `workspaceId` | string | Workspace that owns the environment — same semantics as the [`environment_terminated` webhook's `workspaceId`](/environment_terminated#request-body). | ### Result fields | Field | Description | | --------------- | ---------------------------------------- | | `outcome` | `"success"`, `"failed"`, or `"aborted"`. | | `environmentId` | ID of the terminated environment. | ## generateSignedUrlForRunInputsExecutablesStorage Generates a signed URL for uploading a run input executable (APK, AAB, DEB, IPA, ZIP, CSV, PDF) to QA Wolf. See [v0/run-inputs-executables-signed-urls](/run-inputs-executables-signed-urls) for the underlying endpoint. ```javascript theme={null} import { makeQaWolfSdk } from "@qawolf/ci-sdk"; import fs from "fs/promises"; const { generateSignedUrlForRunInputsExecutablesStorage } = makeQaWolfSdk({ apiKey: process.env.QAWOLF_API_KEY, }); const signedUrlResponse = await generateSignedUrlForRunInputsExecutablesStorage({ destinationFilePath: "app-staging", }); if ( !signedUrlResponse?.success || !signedUrlResponse.uploadUrl || !signedUrlResponse.playgroundFileLocation ) { throw new Error("No upload URL received from QA Wolf"); } const fileBuffer = await fs.readFile("./path/to/build.apk"); await fetch(signedUrlResponse.uploadUrl, { method: "PUT", body: fileBuffer, headers: { "Content-Type": "application/octet-stream" }, }); ``` ### Input fields | Field | Type | Description | | --------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------- | | `destinationFilePath` | string | Filename and extension, optionally including directories. Reach out to your QA Wolf representative for the correct value. | ### Response fields | Field | Description | | ------------------------ | ------------------------------------------------------------------------------------ | | `success` | `true` if the signed URL was generated successfully. | | `uploadUrl` | Pre-signed URL for uploading the file via `PUT`. | | `playgroundFileLocation` | File path without workspace ID. Use as a `variables` value in `attemptNotifyDeploy`. | ## Versioning This package follows SemVer. Notes: * Use the `^` range operator — patch and minor updates will not introduce breaking changes. * Major version bumps indicate a breaking API change. QA Wolf will give advance notice. * Only top-level exports are covered by SemVer. * New fields in API response types are not considered breaking changes. # Troubleshooting Source: https://docs.qawolf.com/libraries/ci-sdk/troubleshooting Common issues when integrating with @qawolf/ci-sdk. Common issues when integrating `@qawolf/ci-sdk` into your CI pipeline. ## `outcome` is `"success"` but no run was created Cause: The deployment notification was accepted but no trigger matched the request. Check: * `deploymentType` matches the value configured in your QA Wolf trigger * `hostingService` matches where your code is hosted, not where your pipeline runs * At least one trigger is configured for the target environment in QA Wolf * Contact your QA Wolf representative to confirm trigger configuration ## `outcome` is `"failed"` or `"aborted"` on `attemptNotifyDeploy` Cause: The notification request was rejected or timed out. Check: * `QAWOLF_API_KEY` is set correctly in your CI environment * The key belongs to the correct QA Wolf workspace * Your CI runner has outbound HTTPS access to `app.qawolf.com` ## `pollCiGreenlightStatus` times out Cause: The run did not complete within the polling timeout (default: two hours). Check: * The run is visible in the QA Wolf UI and has not stalled * The `pollTimeout` option is set appropriately for your expected run duration * The `runId` passed to `pollCiGreenlightStatus` matches the run you intend to poll ## `pollCiGreenlightStatus` aborts with `"run-canceled"` Cause: The run was canceled, typically because a newer run superseded it. Check: * Whether a newer deployment notification was sent before this run completed * Whether `deduplicationKey` is configured correctly in `attemptNotifyDeploy` * A canceled run cannot be recovered — submit a new deployment notification if needed ## Artifact upload succeeds but the run cannot find the file Cause: The file path passed to `attemptNotifyDeploy` via `variables` does not match the uploaded location. Check: * Use `playgroundFileLocation` from the `generateSignedUrlForRunInputsExecutablesStorage` response as the variable value * For mobile apps, prefix the path with `/home/wolf/run-inputs-executables/` * The variable name (`RUN_INPUT_PATH`, `ANDROID_APP`, etc.) matches what your QA Wolf trigger expects ## `fetch is not defined` error Cause: The SDK requires `fetch`, which is only available natively in Node.js 18 or later. Check: * Node.js 18 or later is available in your CI environment * If you can't upgrade, pass the `undici` polyfill shown in [Installation](/libraries/ci-sdk/api-reference#installation) ## Mobile triggers are enabled but runs are not starting Cause: Mobile triggers require additional platform configuration by QA Wolf before they activate. Check: * Contact your QA Wolf representative to confirm mobile triggers have been enabled for your workspace * Artifact naming conventions match what was agreed with QA Wolf * The artifact upload step is completing successfully before `attemptNotifyDeploy` is called # Commands Reference Source: https://docs.qawolf.com/libraries/cli/api-reference/commands Reference notes for every qawolf CLI command and flag. The qawolf CLI exposes six top-level commands. ## `auth` Manages authentication with the QA Wolf platform. The CLI reads credentials from the `QAWOLF_API_KEY` environment variable first, then the system keychain, then a config file fallback. ### `auth login` Prompts for an API key, validates it against the platform, and stores it locally. Requires an interactive terminal — in non-interactive contexts, set `QAWOLF_API_KEY` directly. ```bash theme={null} qawolf auth login ``` ### `auth logout` Removes stored credentials. This command cannot remove credentials set via the `QAWOLF_API_KEY` environment variable. ```bash theme={null} qawolf auth logout ``` ### `auth whoami` Displays the authenticated workspace name, ID, optional slug, and the credential source. ```bash theme={null} qawolf auth whoami ``` ## `flows` Manages and runs QA Wolf flows. ### `flows run [pattern]` Runs flows matching `[pattern]`, or every flow when omitted. The default pattern matches `**/*.flow.{ts,js}` in the current working directory and in `.qawolf//` subdirectories. ```bash theme={null} qawolf flows run qawolf flows run "flows/checkout/**" qawolf flows run checkout --env --headed ``` Options: * `--retries ` — number of retries for each failing flow. Default: `0`. * `--bail` — stop after the first failure. Default: `false`. * `--workers ` — parallel worker count for web flows. Default: `1`. Android flows must run with `--workers 1`; the CLI errors out if Android flows are selected with a higher value. * `--timeout ` — per-flow timeout in milliseconds. Default: `30000`. * `--junit [path]` — write a JUnit XML report. With no value, writes to `/junit-report.xml`. Pass an explicit path to override. * `--video ` — `on`, `off`, or `retain-on-failure`. Default: `off`. * `--trace ` — Playwright trace mode. `on`, `off`, or `retain-on-failure`. Default: `off`. * `--har ` — HAR capture mode. `on`, `off`, or `retain-on-failure`. Default: `off`. * `--har-content ` — `omit` or `full`. `full` includes response bodies and uses more memory. Default: `omit`. * `--output-dir ` — directory for artifacts. Default: `qawolf-output`. * `--headed` — show the browser window instead of running headless. Default: `false`. * `--env ` — environment ID. When set, the CLI pulls missing flows before the run. See [where to find it](/local-execution/pull-flows#pull-an-environment). Exit codes: `0` when all flows pass, `1` when one or more flows fail, `2` for invalid arguments or an unrecognized flow target. See [Exit codes](/libraries/cli/api-reference/index#exit-codes). ### `flows list [pattern]` Lists flows matching `[pattern]`. By default, lists local flows; with `--remote`, lists flows from the QA Wolf platform. ```bash theme={null} qawolf flows list qawolf flows list "flows/checkout/**" qawolf flows list --remote ``` Options: * `--remote` — list flows from the QA Wolf platform instead of the local project. Default: `false`. ### `flows pull` Downloads an environment's flows into the local `.qawolf//` cache. ```bash theme={null} qawolf flows pull --env qawolf flows pull --env --out ./snapshot qawolf flows pull --env --yes ``` Options: * `--env ` — required. The environment ID to pull from. See [where to find it](/local-execution/pull-flows#pull-an-environment). * `--out ` — destination directory. Defaults to `.qawolf//`. * `--yes` — overwrite locally-modified files without prompting. Default: `false`. ## `install` Installs every runtime dependency the project's flows need. With a `[pattern]` argument, it installs only the dependencies for the matching flows. ```bash theme={null} qawolf install qawolf install "flows/checkout/**" ``` ### `install browsers [pattern]` Installs the Playwright browsers used by the project's web flows. ```bash theme={null} qawolf install browsers qawolf install browsers "flows/web/**" ``` ### `install android [pattern]` Installs Android system images, AVDs, and the Appium driver used by the project's Android flows. Requires `ANDROID_HOME` or `ANDROID_SDK_ROOT`. ```bash theme={null} qawolf install android qawolf install android "flows/mobile/**" ``` ## `init` Scaffolds a QA Wolf project in the current directory. Creates `qawolf.config.ts`, an example flow at `src/flows/example.flow.ts`, and `.qawolf/.gitignore`. When no `package.json` exists, creates one with `"type": "module"`, the `@qawolf/flows` dependency, and a `test:e2e` script. When one already exists, only adds the `test:e2e` script, after a prompt. ```bash theme={null} qawolf init qawolf init --yes ``` Options: * `--yes` — overwrite existing files without prompting. Default: `false`. ## `doctor` Diagnoses problems running flows locally. Checks your environment (CLI and Node.js versions, API key, connectivity) and the dependencies your flows need, such as Playwright browsers and the Android SDK. ```bash theme={null} qawolf doctor qawolf doctor --all ``` Options: * `--all` — run every platform check, including platforms the project does not use. Default: `false`. ## `run` Triggers and manages runs on the QA Wolf platform. Unlike the local execution commands, `run` does not execute flows on your machine and requires authentication. ### `run create` Creates a run on the QA Wolf platform. ```bash theme={null} qawolf run create --environment-id qawolf run create --environment-id --flow-ids ... --environment-variables KEY=VALUE ``` Options: * `--environment-id ` — the ID of the environment to run. See [where to find it](/local-execution/pull-flows#pull-an-environment). * `--environment-variables ` — environment variables to set for the run. * `--flow-ids ...` — limit the run to specific flow IDs. * `--ignore-rules` — ignore the environment's run rules. # Environment Variables Source: https://docs.qawolf.com/libraries/cli/api-reference/environment-variables Environment variables the qawolf CLI reads for API access, Android and iOS SDK paths, and other runtime configuration. The qawolf CLI reads the following environment variables. ## `QAWOLF_API_KEY` API key for the QA Wolf platform. Used by `flows pull`, `flows list --remote`, and `flows run --env`. Required for any command that talks to the platform. Takes precedence over credentials stored via `qawolf auth login`. ## `QAWOLF_API_URL` Base URL for the QA Wolf platform API. Defaults to `https://app.qawolf.com`. The CLI strips trailing slashes. ## `ANDROID_HOME` Path to the Android SDK root. Required by `qawolf install android` and by Android flow execution. The CLI accepts `ANDROID_SDK_ROOT` as a fallback. ## `ANDROID_SDK_ROOT` Fallback path to the Android SDK root, used when `ANDROID_HOME` is not set. ## CI detection The CLI treats the following variables as a signal that it is running in CI and switches the default output format to JSON: * `CI` * `GITHUB_ACTIONS` * `GITLAB_CI` * `CIRCLECI` * `JENKINS_URL` * `BUILDKITE` ## Agent detection The CLI treats the following variables as a signal that it is running inside an agent and switches the default output format to agent mode: * `CLAUDE_CODE` * `CURSOR_SESSION_ID` # Overview Source: https://docs.qawolf.com/libraries/cli/api-reference/index Overview of qawolf CLI commands, flags, configuration, environment variables, and exit codes for running flows locally. Use this section for stable command descriptions, flags, configuration, environment variables, and exit codes. ## Commands * [`qawolf auth`](/libraries/cli/api-reference/commands#auth) — manage authentication. * [`qawolf flows`](/libraries/cli/api-reference/commands#flows) — run, list, and pull flows. * [`qawolf install`](/libraries/cli/api-reference/commands#install) — install runtime dependencies for the project. * [`qawolf init`](/libraries/cli/api-reference/commands#init) — scaffold a project in the current directory. * [`qawolf doctor`](/libraries/cli/api-reference/commands#doctor) — diagnose problems running flows locally. * [`qawolf run`](/libraries/cli/api-reference/commands#run) — trigger and manage QA Wolf runs on the platform. Example invocation: ```bash theme={null} qawolf auth login qawolf flows pull --env qawolf flows run ``` Find the environment ID in the app URL — see [Pull flows](/local-execution/pull-flows#pull-an-environment). ## Global options The following flags apply to every command: * `--verbose` — emits debug logs to stderr. * `--json` — formats output as JSON. * `--agent` — formats output for agent consumption. * `-V, --version` — prints the CLI version. When the CLI detects a recognized CI environment (`CI`, `GITHUB_ACTIONS`, `GITLAB_CI`, `CIRCLECI`, `JENKINS_URL`, `BUILDKITE`), output defaults to JSON. When it detects a recognized agent environment (`CLAUDE_CODE`, `CURSOR_SESSION_ID`), output defaults to agent mode. With `--json` or `--agent`, the CLI writes the structured result to stdout and diagnostic text to stderr, so scripts can parse stdout directly. ## Exit codes Every qawolf CLI command exits with one of the codes below. | Code | Name | Meaning | | ---- | ------------- | ----------------------------------------------------------------------------------------------- | | `0` | `success` | The command completed successfully. | | `1` | `testFailure` | One or more flows failed. | | `2` | `invalidArgs` | A Commander parse error, an unknown subcommand, or a bad flag value. | | `3` | `auth` | `QAWOLF_API_KEY` is missing or invalid. | | `4` | `network` | The QA Wolf API was unreachable, a download from storage failed, or a registry was unreachable. | | `5` | `config` | `qawolf.config.ts` is invalid, or a file collision occurred during `qawolf init`. | # Troubleshooting Source: https://docs.qawolf.com/libraries/cli/troubleshooting Solutions for common qawolf CLI issues including authentication errors, missing API keys, SDK path problems, and failed flow execution. ## `QAWOLF_API_KEY` is not set Cause: The CLI could not find an API key in the environment, the system keychain, or the local config file. Check: * the `QAWOLF_API_KEY` environment variable is set in the current shell * credentials have been stored locally with `qawolf auth login` * the value has not been overwritten by another shell profile ## QA Wolf API rejected the request (HTTP 401) Cause: The API key is invalid or has been revoked. Check: * the key matches one issued for the current workspace * the key was not truncated or copied with surrounding whitespace * `qawolf auth whoami` returns the expected workspace ## QA Wolf API rejected the request (HTTP 403) Cause: The API key is valid but does not have access to the requested environment. Check: * the key was issued for a workspace that contains this environment * the environment ID matches one returned by the platform — see [where to find it](/local-execution/pull-flows#pull-an-environment) ## Could not reach the QA Wolf API Cause: The CLI could not connect to the platform at `QAWOLF_API_URL`. Check: * the network can reach `https://app.qawolf.com` (or the URL configured in `QAWOLF_API_URL`) * `QAWOLF_API_URL` does not contain a typo or unexpected trailing path * corporate proxy and VPN settings allow outbound HTTPS ## Flow bundle download link has expired Cause: The signed URL the CLI received from the platform expired before it downloaded the bundle. Check: * re-run `qawolf flows pull --env ` to fetch a fresh link * network conditions are not slowing the download past the link's expiration window ## Android SDK not found Cause: `qawolf install android` could not locate the Android SDK at the path given by `ANDROID_HOME` or `ANDROID_SDK_ROOT`. Check: * `ANDROID_HOME` is exported in the current shell and points at the SDK root * the SDK is installed via Android Studio or via the standalone `cmdline-tools` package * `sdkmanager` exists at `$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager` ## iOS targets are not supported Cause: The selected flow targets an iOS device. The CLI does not yet support iOS execution. Check: * the flow's `target` is set to a web or Android value * iOS flows are filtered out of the run with a pattern argument ## Could not load `@qawolf/testkit` Cause: The runner could not resolve `@qawolf/testkit` from the project directory. Check: * `@qawolf/testkit` is listed in `package.json` and installed * the command is being run from the project root, or from a directory inside a pulled environment ## Android flows are not supported with `--workers > 1` Cause: `--workers ` was greater than `1` while the run included at least one Android flow. Check: * rerun the Android flows with `--workers 1` * split the run into a web-only invocation with `--workers ` and an Android-only invocation with `--workers 1` ## Basic target flow cannot be executed Cause: The flow targets `"Basic"`, the Node-only platform type. The CLI does not execute Basic flows. Check: * the flow has been ported to a supported target, or * the flow is excluded from the run with a pattern argument # Client Source: https://docs.qawolf.com/libraries/emails/api-reference/client Reference notes for runtime-facing client creation and configuration. Runtime-facing APIs include: * `createEmailsClient(...)` * `configureEmailsClient(...)` * `getCurrentEmailsClient(...)` * `resetEmailsClient(...)` * `buildGetInboxFn(...)` Example: ```ts theme={null} import { configureEmailsClient, createEmailsClient } from "@qawolf/emails"; const client = await createEmailsClient({ apiKey: process.env["QAWOLF_API_KEY"]!, pollForEmailsDefaultTimeoutMs: 30_000, teamId: "team_123", url: "https://app.qawolf.com/api", waitForMessagesDefaultDelayMs: 1_000, }); configureEmailsClient(client); ``` ## `EmailsClientOptions` `createEmailsClient(...)` accepts one of two transport configurations. Use the QA Wolf API configuration for new code: ```ts theme={null} const client = await createEmailsClient({ apiKey: process.env["QAWOLF_API_KEY"]!, pollForEmailsDefaultTimeoutMs: 30_000, teamId: "team_123", url: "https://app.qawolf.com/api", waitForMessagesDefaultDelayMs: 1_000, }); ``` The client also supports an internal runner configuration: ```ts theme={null} const client = await createEmailsClient({ emailerUrl: process.env["EMAILER_URL"]!, pollForEmailsDefaultTimeoutMs: 30_000, teamId: "team_123", waitForMessagesDefaultDelayMs: 1_000, }); ``` Do not pass both configurations at the same time. If you use `apiKey` and `url`, do not pass `emailerUrl`. ```ts theme={null} type EmailsClientOptions = | { apiKey: string; emailerUrl?: never; logger?: (severity: string, message: string) => void; pollForEmailsDefaultTimeoutMs: number; teamId?: string; url: string; waitForMessagesDefaultDelayMs: number; } | { apiKey?: never; emailerUrl: string; logger?: (severity: string, message: string) => void; pollForEmailsDefaultTimeoutMs: number; teamId?: string; url?: never; waitForMessagesDefaultDelayMs: number; }; ``` ## Role of `createEmailsClient(...)` This creates a service-backed client that local harnesses, tooling, or other runtime environments can use directly. Example: ```ts theme={null} import { createEmailsClient } from "@qawolf/emails"; const client = await createEmailsClient({ apiKey: process.env["QAWOLF_API_KEY"]!, logger: (severity, message) => { console.log(severity, message); }, pollForEmailsDefaultTimeoutMs: 30_000, teamId: "team_123", url: "https://app.qawolf.com/api", waitForMessagesDefaultDelayMs: 1_000, }); const inbox = await client.getInbox({ new: true }); ``` ## Compatibility note `buildGetInboxFn(...)` remains useful for compatibility with runtimes that still inject `getInbox` directly, but new runtime code should prefer `createEmailsClient(...)`. Example: ```ts theme={null} import { buildGetInboxFn } from "@qawolf/emails"; const getInbox = await buildGetInboxFn({ apiKey: process.env["QAWOLF_API_KEY"]!, pollForEmailsDefaultTimeoutMs: 30_000, teamId: "team_123", url: "https://app.qawolf.com/api", waitForMessagesDefaultDelayMs: 1_000, }); const inbox = await getInbox({ new: true }); ``` # Mail Source: https://docs.qawolf.com/libraries/emails/api-reference/mail Reference for the @qawolf/emails mail API, covering inbox creation, sending messages, and waiting for parsed emails inside flows. The top-level `mail` facade exposes: * `mail.inbox(options?)` `mail.inbox(...)` is a thin facade over the currently configured emails client. If no client has been configured, it throws a setup error that points callers to `configureEmailsClient(...)`. Example: ```ts theme={null} import { mail } from "@qawolf/emails"; const inbox = await mail.inbox({ new: true }); console.log(inbox.emailAddress); ``` ## Inbox handle The returned inbox handle exposes: * `emailAddress` * `sendMessage(...)` * `waitForMessage(...)` * `waitForMessages(...)` The exported wait type is shared by both wait methods. Each method uses only some of its fields. Pass `{}` when you want default wait behavior. Example: ```ts theme={null} const inbox = await mail.inbox({ new: true }); await inbox.sendMessage({ subject: "Sign in link", text: "Use the link in this email", to: [inbox.emailAddress], }); const latestMessage = await inbox.waitForMessage({}); ``` ## Current `GetInboxOptions` ```ts theme={null} type GetInboxOptions = { address?: string | undefined; delimiter?: string | undefined; new?: boolean | undefined; }; ``` Validation and behavior: * `new: true` derives a unique address based on the current default * `delimiter` customizes the separator used for derived addresses * `address` must come from the allowed workspace address set * omitting `address` triggers a lookup of the workspace default address If `teamId` is missing from the runtime client configuration, inbox creation fails. Examples: ```ts theme={null} const defaultInbox = await mail.inbox(); const namedInbox = await mail.inbox({ address: "flows@example.com" }); const derivedInbox = await mail.inbox({ new: true, delimiter: "-" }); ``` ## `sendMessage(...)` Input shape: ```ts theme={null} type SendMessage = { attachments?: { content: Buffer; contentId?: string; disposition?: "attachment" | "inline"; fileName: string; type?: string; }[]; bcc?: string[]; cc?: string[]; html?: string; replyTo?: string[]; replyToMessageId?: string; subject: string; text?: string; to: string[]; }; ``` Behavior: * `to` is always an array of strings * at least one of `html` or `text` is required * QA Wolf base64-encodes attachments before sending the request to the email service Return shape: ```ts theme={null} type SendMessageResult = { id: string; }; ``` Example: ```ts theme={null} const result = await inbox.sendMessage({ attachments: [ { content: Buffer.from("hello"), fileName: "note.txt", type: "text/plain", }, ], html: "

Hello from QA Wolf

", subject: "Welcome", to: [inbox.emailAddress], }); console.log(result.id); ``` ## Parsed email shape Both wait methods below return this shape: ```ts theme={null} type ParsedEmail = { attachments?: { content: Buffer; fileName: string; type?: string; }[]; bcc?: string[]; cc?: string[]; from: string; html: string; id: string; replyTo?: string[]; subject: string; teamId: string; text: string; to: string[]; urls: string[]; }; ``` QA Wolf derives `urls` by parsing links from the HTML body. Example: ```ts theme={null} declare const message: ParsedEmail; console.log(message.from); console.log(message.subject); console.log(message.urls); ``` ## `waitForMessage(...)` Method options: ```ts theme={null} type WaitForMessageOptions = { after?: Date; timeout?: number; }; ``` `waitForMessage(...)` uses: * `after` when it is provided * otherwise the time when `mail.inbox(...)` was called * `timeout` when it is provided It returns one `ParsedEmail` and throws if no message arrives in time. Example: ```ts theme={null} const message = await inbox.waitForMessage({ after: new Date(Date.now() - 60_000), timeout: 15_000, }); console.log(message.subject); ``` ## `waitForMessages(...)` Method options: ```ts theme={null} type WaitForMessagesOptions = { after?: Date; delay?: number; minCount?: number; timeout?: number; }; ``` Behavior: * it uses `after` when provided, otherwise the time when `mail.inbox(...)` was called * it waits for `delay` first * it polls until at least `minCount` messages are found, or until timeout * it returns the matching parsed emails Example: ```ts theme={null} const messages = await inbox.waitForMessages({ delay: 2_000, minCount: 2, timeout: 30_000, }); console.log(messages.map((message) => message.subject)); ``` # Troubleshooting Source: https://docs.qawolf.com/libraries/emails/troubleshooting Troubleshoot common @qawolf/emails problems, including missing client configuration, stale messages, and inbox wait timeouts. ## `mail.inbox()` throws because no client is configured Cause: The runtime did not call `configureEmailsClient(...)` before the flow ran. Check: * the runner creates the client * the runner configures it before flow execution ## `waitForMessage(...)` returns an older message Check: * whether the flow should pass `after: new Date()` * whether the same inbox is being reused across multiple steps or retries ## No message arrives before timeout Check: * the product under test actually sent the email * the address being used is allowed for the workspace * the timeout is long enough for the expected delivery path ## Old emails are matched by waitForMessage Using `new: true` to generate a unique address for each run ensures `waitForMessage` only sees messages from the current run. When reusing a stable address is unavoidable, pass `after: new Date()` immediately before the action that triggers the email: ```javascript theme={null} const after = new Date(); await page.getByRole("button", { name: "Send code" }).click(); const message = await inbox.waitForMessage({ after }); ``` For a local harness where neither option is available, calling `resetEmailsClient()` between test suites clears the client state. # Android Source: https://docs.qawolf.com/libraries/flows/api-reference/android Reference for @qawolf/flows/android, the entry point for defining Android flows with device controls, launch options, and assertions. `@qawolf/flows/android` defines Android flows and advanced device controls. QA Wolf runs Android flows on ephemeral emulators. A fresh one is provisioned for each run and discarded when it finishes, so nothing carries over from a previous run. Because emulators are created on demand, you don't reserve or configure devices yourself: choose a target and QA Wolf allocates an emulator for it. ## Primary exports * `flow(...)` * `launch(...)` * `device` * `expect` * `testContextDependencies` It also exports Android-specific target, launch, device, callback context, and flow definition types. Example: ```ts theme={null} import { flow } from "@qawolf/flows/android"; export default flow( "Open Android app", { target: "Android - Pixel", launch: true }, async ({ driver, test }) => { await test("app launches", async () => { await driver.pause(1000); }); }, ); ``` ## Flow callback context All Android flow callbacks receive: * `inputs` * `setOutput(...)` * `test(...)` Launch-enabled Android flows also receive `driver`. `test(...)` can be omitted for simple flows where grouping steps into named sub-steps doesn't add value. For most flows, wrapping steps in `test(...)` is recommended — the label appears in your results and makes failures easier to locate. ## `testContextDependencies` `testContextDependencies` is exported for runner and tooling integration. Flow authors should usually use the public callback parameters above instead of depending on the raw runner dependency list. ## Target model The target input model is: ```ts theme={null} type AndroidFlowTargetInput = | AndroidFlowTarget | { target: AndroidFlowTarget; launch?: false | undefined; } | { target: AndroidFlowTarget; launch: true | LaunchOptions; }; ``` Pass either: * a target directly for the common path * `{ target, launch }` when startup behavior should be part of the flow Example: ```ts theme={null} import { flow } from "@qawolf/flows/android"; export const targetOnlyFlow = flow( "Target-only path", "Android - Pixel", async () => { // call launch() explicitly when startup should happen in the callback }, ); export const launchedFlow = flow( "Launch-enabled path", { target: "Android - Pixel", launch: true }, async ({ driver, test }) => { await test("launch app", async () => { await driver.pause(1000); }); }, ); ``` ## `flow(...)` Use `flow(...)` for Android authoring. * without launch, the callback receives `inputs`, `setOutput(...)`, and `test(...)` * with `launch: true`, the flow calls `launch()` with default Android startup * with `launch: `, the flow calls `launch(options)` * when launch is enabled, the callback also receives `driver` Example: ```ts theme={null} import { flow } from "@qawolf/flows/android"; export default flow( "Launch in flow definition", { target: "Android - Pixel", launch: true }, async ({ driver, test }) => { await test("launch app", async () => { await driver.pause(1000); }); }, ); ``` ## `launch(...)` Starts Android automation for the active flow. Returns: ```ts theme={null} type LaunchResult = { driver: Awaited>; }; ``` This API is only available while a flow is running. Example: ```ts theme={null} import { flow, launch } from "@qawolf/flows/android"; export default flow("Launch explicitly", "Android - Pixel", async () => { const { driver } = await launch(); await driver.pause(1000); }); ``` ### Launch shape ```ts theme={null} type LaunchOptions = { app?: { path?: string; env?: string; url?: string; }; appPackage?: string; appActivity?: string; appWaitActivity?: string; autoGrantPermissions?: boolean; noReset?: boolean; waitForIdleTimeout?: number; browserName?: string; capabilities?: Record; }; ``` Example: ```ts theme={null} import { flow, launch } from "@qawolf/flows/android"; export default flow("Launch apk", "Android - Pixel", async () => { const { driver } = await launch({ app: { path: "apps/mobile/android/app.apk" }, appPackage: "com.example.android", autoGrantPermissions: true, }); await driver.pause(1000); }); ``` ### Launch defaults The implementation applies these defaults: * when `app` is omitted, launch falls back to the runner-provided executable input path and then to installed-app startup through `appPackage` * `autoGrantPermissions` defaults to `true` * `noReset` defaults to `false` Example: ```ts theme={null} import { flow, launch } from "@qawolf/flows/android"; export default flow("Use Android defaults", "Android - Pixel", async () => { const { driver } = await launch({ appPackage: "com.example.android", }); await driver.pause(1000); }); ``` ### Supported app formats QA Wolf installs your app from either a local path or a URL, in either the `.apk` or `.aab` format. | Source | Formats | What QA Wolf does | | ---------- | -------------- | ------------------------------------- | | local path | `.apk`, `.aab` | installs the build | | URL | `.apk`, `.aab` | downloads the build, then installs it | ```ts theme={null} // a build your CI uploaded, or a path in your repository await launch({ app: { path: "apps/android/app.aab" } }); // a link QA Wolf can fetch, including a signed or expiring one await launch({ app: { url: "https://builds.example.com/app.apk?token=abc" } }); ``` QA Wolf writes the version of the installed build to the run log, so you can confirm which build a run tested against. QA Wolf only fetches a URL when its path ends in `.apk` or `.aab`. A query string does not change that, so a signed or expiring download link works as long as the path keeps the extension. QA Wolf passes anything else, such as a share page or a download endpoint that names the file elsewhere, to the driver untouched. You can also install an app yourself over adb, which skips everything above: ```ts theme={null} // -r to replace an existing install, and a timeout large enough for the transfer await device.adb(["install-multiple", "-r", ...splitPaths], { timeout: 600_000 }); await launch({ appPackage: "com.example.app", appActivity: ".MainActivity" }); ``` Prefer handing QA Wolf the `.apk` or `.aab`. Installing over adb means you own everything that comes with it: choosing the right split APKs for the device, reading the manifest for the launcher activity, and sizing the transfer. Passing `app` gets all of that handled for you. ### App resolution When your CI pipeline uploads an Android build, QA Wolf sets `RUN_INPUT_PATH` to the uploaded file before the flow runs. Omit `app` in your launch call and QA Wolf will use that path automatically — you only need to provide the `appPackage`. When `app` is provided, the resolution order is: 1. `app.path` 2. `app.env` 3. `app.url` When `app` is omitted, launch falls back to `RUN_INPUT_PATH`. QA Wolf resolves relative paths against `RUN_INPUTS_EXECUTABLES_DIR` when that environment variable is present. Explicit app source examples: ```ts theme={null} await launch({ app: { path: "apps/mobile/android/app.apk" } }); await launch({ app: { env: "ANDROID_APP_PATH" } }); await launch({ app: { url: "https://example.com/app.apk" } }); ``` `RUN_INPUT_PATH` fallback example: ```ts theme={null} // The runner sets RUN_INPUT_PATH before the flow starts. await launch({ appPackage: "com.example.android", }); ``` If `app` is present but does not resolve to a value, launch does not fall back to `RUN_INPUT_PATH`. ### Advanced Appium capabilities The named launch options above cover the most common settings. For anything else, use the `capabilities` escape hatch: its entries are spread into the underlying [webdriver.io `remote`](https://webdriver.io/docs/api) capabilities and passed straight to the Android driver. `startAndroid` is a thin wrapper around webdriver.io's `remote`, so capabilities that QA Wolf does not set itself are forwarded as-is. See the Appium references for the full list of available keys: * [Appium core capabilities](https://appium.io/docs/en/2.11/guides/caps/) * [UiAutomator2 (Android) driver capabilities](https://github.com/appium/appium-uiautomator2-driver/blob/v3.7.8/README.md#capabilities) ```ts theme={null} import { flow, launch } from "@qawolf/flows/android"; export default flow( "Launch with custom capabilities", "Android - Pixel", async () => { const { driver } = await launch({ // Named options cover the common knobs: autoGrantPermissions: true, waitForIdleTimeout: 10, // Anything else is passed through verbatim: capabilities: { "appium:disableWindowAnimation": true, "appium:uiautomator2ServerLaunchTimeout": 60000, }, }); await driver.pause(1000); }, ); ``` Named options take precedence over `capabilities` for the same key. In particular, `autoGrantPermissions` and `noReset` are always applied (using their defaults when omitted), so configure those through the named options rather than `capabilities`. ### Migrating from `wdio.startAndroid` Older flows obtained a `wdio` handle from the test context and called `startAndroid` directly. With the flow API, pass the same configuration through the flow's `launch` options instead — map the settings that have a named option, and route the rest through `capabilities`. The `driver` you receive is the same webdriver.io `Browser` handle, so there is no separate "raw" driver to obtain. ```ts theme={null} // Before: raw wdio.startAndroid const driver = await wdio.startAndroid({ "appium:app": process.env.ANDROID_PATH, "appium:appPackage": "com.example.android", "appium:autoGrantPermissions": true, "appium:disableWindowAnimation": true, "appium:waitForIdleTimeout": 10, }); ``` ```ts theme={null} // After: flow launch options import { flow } from "@qawolf/flows/android"; export default flow( "Open Android app", { target: "Android - Pixel", launch: { app: { env: "ANDROID_PATH" }, appPackage: "com.example.android", autoGrantPermissions: true, waitForIdleTimeout: 10, capabilities: { "appium:disableWindowAnimation": true, }, }, }, async ({ driver }) => { await driver.pause(1000); }, ); ``` `app: { env: "ANDROID_PATH" }` reads the path from the environment variable named `ANDROID_PATH`, replacing the raw `"appium:app": process.env.ANDROID_PATH` form. ## `device` `device` is a runtime proxy over the Android emulator API. Use it for device-level operations — such as setting location, simulating sensors, or managing device state — that sit outside app UI interactions. Use `driver` for interacting with the app itself. See [Android Device Controls](/libraries/flows/api-reference/android-device-reference) for the full method list. ## `expect` The exported `expect` is the assertion helper for Android flows, backed by [`expect-webdriverio`](https://webdriver.io/docs/api/expect-webdriverio). All WebdriverIO matchers are available — including auto-retrying element matchers like `toBeDisplayed`, `toExist`, `toHaveText`, and `toHaveAttribute`. See the [expect-webdriverio matchers reference](https://webdriver.io/docs/api/expect-webdriverio) for the full list. Always import `expect` from `@qawolf/flows/android` rather than from `expect-webdriverio` directly, so that QA Wolf's defaults (timeout, `toHaveScreenshot`) stay in effect. Example: ```ts theme={null} import { expect, flow } from "@qawolf/flows/android"; export default flow( "Verify welcome screen", { target: "Android - Pixel", launch: true }, async ({ driver, test }) => { await test("welcome heading is visible", async () => { const heading = driver.$("~welcome-heading"); await expect(heading).toBeDisplayed(); await expect(heading).toHaveText("Welcome"); }); }, ); ``` For visual regression assertions with `expect(driver).toHaveScreenshot(...)`, see [Native mobile screenshots](/visual-diffing-native-app). # Android Device Controls Source: https://docs.qawolf.com/libraries/flows/api-reference/android-device-reference Reference notes for the `device` object from `@qawolf/flows/android`. `device` is a runtime proxy over the Android emulator API. Use it for emulator-level operations — such as setting location, placing images in the virtual camera scene, configuring network proxies, passing audio to the microphone, or running raw `adb` commands — that sit outside app UI interactions. Use `driver` for interacting with the app itself. `device` is only available while a flow is running. ```typescript theme={null} import { device, flow } from "@qawolf/flows/android"; ``` ## `setGeoLocation` Sets the emulator's GPS location. ```typescript theme={null} function setGeoLocation(options: GeoLocationOptions): Promise; type GeoLocationOptions = { latitude: number; longitude: number; altitude?: number; satellites?: number; velocity?: number; }; ``` Example: ```typescript theme={null} import { device, flow } from "@qawolf/flows/android"; export default flow("Set device location", "Android - Pixel", async () => { await device.setGeoLocation({ latitude: 37.7749, longitude: -122.4194, }); }); ``` Use this to test location-aware features without physically moving a device. See [Mock device location](/android-location-mocking) for a full walkthrough. ## `setVirtualSceneImage` Places an image into the Android emulator's virtual camera scene. Use this to test features that require the camera to see a specific image — such as barcode or QR code scanning. ```typescript theme={null} function setVirtualSceneImage(options: VirtualSceneImageOptions): Promise; type VirtualSceneImageOptions = { /** Path to the image file to place in the virtual scene. Defaults to the default virtual scene image when omitted. */ image?: string; /** Where to place the image in the virtual scene. */ location: "table" | "wall"; }; ``` Example: ```typescript theme={null} import { device, flow } from "@qawolf/flows/android"; export default flow( "Scan barcode", { target: "Android - Pixel", launch: true }, async ({ driver, test }) => { await test("place barcode image", async () => { await device.setVirtualSceneImage({ image: "/path/to/barcode.jpg", location: "table", }); }); }, ); ``` Virtual scene is scoped to barcode/QR code scanning and augmented reality (AR) use cases. It is not a general photo or video injection mechanism. See [Android barcode and QR scanning](/android-barcode) for a full walkthrough. ## `playAutomation` Triggers an automation macro on the Android emulator. Use this in combination with `setVirtualSceneImage` to animate the virtual camera toward a placed image. ```typescript theme={null} function playAutomation(options: PlayAutomationOptions): Promise; type PlayAutomationOptions = | { /** Name of a built-in emulator macro. */ macro: "Reset_position" | "Track_horizontal_plane" | "Track_vertical_plane" | "Walk_to_image_room"; /** Optional path to override the macro file. */ overrideMacroPath?: string; } | { /** Path to a custom macro file. */ file: string; }; ``` Example: ```typescript theme={null} import { device, flow } from "@qawolf/flows/android"; export default flow( "Scan barcode", { target: "Android - Pixel", launch: true }, async ({ driver, test }) => { await test("place barcode and animate camera", async () => { await device.setVirtualSceneImage({ image: "/path/to/barcode.jpg", location: "table", }); await device.playAutomation({ macro: "Walk_to_image_room", }); }); }, ); ``` The Android emulator determines the available built-in macros. `Walk_to_image_room` is the macro used to animate the virtual camera toward a placed image. See [Android Emulator camera support](https://developer.android.com/studio/run/emulator-use-camera#arcore) for more detail on virtual scene automation. ## `setProxy` Configures a proxy for the Android emulator's network traffic. ```typescript theme={null} function setProxy(options: ProxyOptions): Promise; type ProxyOptions = { /** Proxy URL. Include credentials in the URL if authentication is required (e.g. `http://username:password@proxy.example.com:8080`). */ url: string; }; ``` Example: ```typescript theme={null} import { device, flow } from "@qawolf/flows/android"; export default flow("Set proxy", "Android - Pixel", async () => { await device.setProxy({ url: "http://proxy.example.com:8080", }); }); ``` ## `clearProxy` Removes any proxy configuration from the Android emulator. ```typescript theme={null} function clearProxy(): Promise; ``` Example: ```typescript theme={null} import { device, flow } from "@qawolf/flows/android"; export default flow("Clear proxy", "Android - Pixel", async () => { await device.clearProxy(); }); ``` Call `clearProxy()` at the end of any flow that sets a proxy to avoid affecting subsequent flows. ## `passAudioAsMicrophoneInput` Plays an audio file on the runner host so the emulator receives it as microphone input. Use this to test recording, voice, or speech-recognition features with known audio. ```typescript theme={null} function passAudioAsMicrophoneInput(options: PassAudioAsMicrophoneInputOptions): Promise; type PassAudioAsMicrophoneInputOptions = { /** File path on the runner host, or URL, for the audio to play. */ data: string; /** Delay in seconds before playback starts. Awaited inline, so it does not let the call return early. */ delaySeconds?: number; /** Maximum playback duration in seconds. Omit to play the whole file. */ durationSeconds?: number; }; ``` Playback is synchronous: the promise resolves only once the audio has finished playing. Your app has to be recording **before** you call this. Otherwise, the file plays into a microphone nobody is listening to, and the recording that follows captures silence. `delaySeconds` does not work around this, since it is awaited inline as well. Example: ```typescript theme={null} import { device, flow } from "@qawolf/flows/android"; export default flow( "Inject microphone audio", { target: "Android - Pixel", launch: true }, async ({ driver, test }) => { await test("play audio into the microphone", async () => { // Start the app listening first await driver.$(`//*[@text='Record']`).click(); await device.passAudioAsMicrophoneInput({ data: `${process.env.TEAM_STORAGE_DIR}/audio.mp3`, durationSeconds: 10, }); await driver.$(`//*[@text='Done']`).click(); }); }, ); ``` If your app needs audio already flowing when it starts listening, hold the promise and await it once the app is ready: ```typescript theme={null} const playback = device.passAudioAsMicrophoneInput({ data: `${process.env.TEAM_STORAGE_DIR}/audio.mp3`, durationSeconds: 10, }); await driver.$(`//*[@text='Record']`).click(); await playback; ``` The command timeout (1 minute by default) bounds playback. For audio longer than a minute, pass `durationSeconds`. See [Microphone injection](/android-audio-injection) for a full walkthrough. ## `adb` Runs an `adb` command against the emulator and returns its combined stdout/stderr. Use this as an escape hatch for emulator or device operations not covered by a dedicated `device` method — such as `dumpsys`, `emu sensor set`, or `pull`. Pass the arguments either as a single string (split on whitespace; quote a single value that contains spaces) or as an array (preferred when building arguments programmatically). The command does not go through a host shell, so it does not expand variables or run pipes. ```typescript theme={null} function adb(command: string | string[]): Promise; ``` Example: ```typescript theme={null} import { device, flow } from "@qawolf/flows/android"; export default flow( "Run adb commands", { target: "Android - Pixel", launch: true }, async ({ driver, test }) => { await test("send a keyevent and pull a file", async () => { await device.adb("shell input keyevent 26"); // quote a value that contains spaces, or pass an array to avoid quoting await device.adb([ "pull", "/sdcard/Recordings/My recording 1.m4a", `${process.env.TEAM_STORAGE_DIR}/recorded.m4a`, ]); }); }, ); ``` Example — jump the device clock to test time-gated features (disable auto time, move the clock, then restore): ```typescript theme={null} import { device, flow } from "@qawolf/flows/android"; export default flow( "Verify a time-gated feature can't be gamed", { target: "Android - Pixel", launch: true }, async ({ driver, test }) => { await test("jump the device clock forward", async () => { // Disable automatic time so the manual clock sticks await device.adb("shell settings put global auto_time 0"); // Move the device clock forward 2 days const targetMillis = Date.now() + 2 * 24 * 60 * 60 * 1000; await device.adb(`shell cmd alarm set-time ${targetMillis}`); // ... assert the time-gated feature (rewards/streaks) behaves correctly ... // Restore automatic time await device.adb("shell settings put global auto_time 1"); }); }, ); ``` Prefer a dedicated `device` method when one exists. See [Measure performance](/android-performance) and [Mock hardware sensors](/android-sensor-mocking) for `adb` usage in context. # Node Reference Source: https://docs.qawolf.com/libraries/flows/api-reference/cli Reference for @qawolf/flows/cli, the Node-based entry point for headless flows that handle setup, teardown, notifications, and file work. `@qawolf/flows/cli` is the smallest platform entry point in `@qawolf/flows`. Node flows run in Node and have no browser or mobile driver. Use them for work that needs to coordinate with other flows in a run but doesn't require UI interaction — data setup, teardown, sending notifications, or processing files. Because Node flows run in Node, you can use the Node standard library directly for file, process, and network work: ```ts theme={null} import { flow } from "@qawolf/flows/cli"; import { readFile } from "node:fs/promises"; export default flow("Read build metadata", "Basic", async ({ test }) => { await test("read metadata", async () => { const metadata = await readFile("build-metadata.json", "utf8"); console.log(JSON.parse(metadata)); }); }); ``` ## Primary exports * `flow(...)` * `expect` * `testContextDependencies` It also exports Node-specific target, callback context, and flow definition types. Example: ```ts theme={null} import { flow } from "@qawolf/flows/cli"; export default flow( "Prepare release metadata", "Basic", async ({ inputs, setOutput, test }) => { await test("create release payload", async () => { setOutput("RELEASE", { generatedAt: new Date().toISOString(), inputs, status: "ready", }); }); }, ); ``` ## Target model The target type is: ```ts theme={null} type CliFlowTarget = "Basic"; ``` Accepted flow target input: ```ts theme={null} type CliFlowTargetInput = CliFlowTarget | { target: CliFlowTarget }; ``` Example: ```ts theme={null} import { flow } from "@qawolf/flows/cli"; export const stringTargetFlow = flow("String target", "Basic", async () => {}); export const objectTargetFlow = flow( "Object target", { target: "Basic" }, async () => {}, ); ``` ## Flow callback context The callback receives the Node flow context. Public callback parameters: * `inputs` — values published by an upstream flow in the same run * `setOutput(...)` — publishes values for downstream flows to read * `test(...)` — wraps named sub-steps that appear in your results Node flows do not receive `page`, `driver`, or any other launch object. `test(...)` can be omitted for simple flows where grouping steps into named sub-steps doesn't add value. For most flows, wrapping steps in `test(...)` is recommended — the label appears in your results and makes failures easier to locate. `inputs` and `setOutput(...)` are demonstrated in the [Primary exports example above](#primary-exports). See [Passing data between flows](/Pass-data-between-flows) for how to use them to coordinate with other flows in a run. ## `testContextDependencies` `testContextDependencies` is exported for runner and tooling integration. Flow authors should usually use the public callback parameters above instead of depending on the raw runner dependency list. ## `expect` The exported `expect` is a re-export of the [`expect`](https://www.npmjs.com/package/expect) package — the same assertion library Jest uses. Use Jest's [`expect` reference](https://jestjs.io/docs/expect) for the full matcher list (`toBe`, `toEqual`, `toMatchObject`, `toThrow`, etc.). Always import `expect` from `@qawolf/flows/cli` rather than from `expect` or `jest` directly, so that future QA Wolf extensions stay in effect. Example: ```ts theme={null} import { expect, flow } from "@qawolf/flows/cli"; export default flow("Validate release payload", "Basic", async ({ inputs, test }) => { await test("payload has expected shape", async () => { expect(inputs).toMatchObject({ status: "ready", generatedAt: expect.any(String), }); }); }); ``` # iOS Source: https://docs.qawolf.com/libraries/flows/api-reference/ios Reference for @qawolf/flows/ios, the entry point for defining iOS flows with simulator and device controls, launch options, and assertions. `@qawolf/flows/ios` defines iOS flows and advanced simulator or device control. QA Wolf runs iOS flows on a shared device pool, resigning your app during install so it can run unreleased builds. See [iOS Device Pools](/libraries/flows/api-reference/ios-device-pools) for how that works and when you need different device access. ## Primary exports * `flow(...)` * `launch(...)` * `device` * `expect` * `testContextDependencies` It also exports iOS-specific target, launch, device, callback context, and flow definition types. Example: ```ts theme={null} import { flow } from "@qawolf/flows/ios"; export default flow( "Open iOS app", { target: "iOS - iPhone 15 (iOS 26)", launch: true }, async ({ driver, test }) => { await test("app launches", async () => { await driver.pause(1000); }); }, ); ``` ## Flow callback context All iOS flow callbacks receive: * `inputs` * `setOutput(...)` * `test(...)` Launch-enabled iOS flows also receive `driver`. `test(...)` can be omitted for simple flows where grouping steps into named sub-steps doesn't add value. For most flows, wrapping steps in `test(...)` is recommended — the label appears in your results and makes failures easier to locate. ## `testContextDependencies` `testContextDependencies` is exported for runner and tooling integration. Flow authors should usually use the public callback parameters above instead of depending on the raw runner dependency list. ## Target model The target input model is: ```ts theme={null} type IosFlowTargetInput = | IosFlowTarget | { target: IosFlowTarget; launch?: false | undefined; } | { target: IosFlowTarget; launch: true | LaunchOptions; }; ``` Pass either: * a target directly for the common path * `{ target, launch }` when startup behavior should be part of the flow Example: ```ts theme={null} import { flow } from "@qawolf/flows/ios"; export const targetOnlyFlow = flow( "Target-only path", "iOS - iPhone 15 (iOS 26)", async () => { // call launch() explicitly when startup should happen in the callback }, ); export const launchedFlow = flow( "Launch-enabled path", { target: "iOS - iPhone 15 (iOS 26)", launch: true }, async ({ driver, test }) => { await test("launch app", async () => { await driver.pause(1000); }); }, ); ``` ## `flow(...)` Use `flow(...)` for iOS authoring. * without launch, the callback receives `inputs`, `setOutput(...)`, and `test(...)` * with `launch: true`, the flow calls `launch()` with default iOS startup * with `launch: `, the flow calls `launch(options)` * when launch is enabled, the callback also receives `driver` Example: ```ts theme={null} import { flow } from "@qawolf/flows/ios"; export default flow( "Launch in flow definition", { target: "iOS - iPhone 15 (iOS 26)", launch: true }, async ({ driver, test }) => { await test("launch app", async () => { await driver.pause(1000); }); }, ); ``` ## `launch(...)` Starts iOS automation for the active flow. Returns: ```ts theme={null} type LaunchResult = { driver: Awaited>; }; ``` This API is only available while a flow is running. Example: ```ts theme={null} import { flow, launch } from "@qawolf/flows/ios"; export default flow( "Launch explicitly", "iOS - iPhone 15 (iOS 26)", async () => { const { driver } = await launch(); await driver.pause(1000); }, ); ``` ### Launch shape ```ts theme={null} type LaunchOptions = { app?: { path?: string; env?: string; url?: string; }; autoAcceptAlerts?: boolean; autoDismissAlerts?: boolean; browserName?: string; bundleId?: string; capabilities?: Record; noReset?: boolean; platformVersion?: string; respectSystemAlerts?: boolean; snapshotMaxDepth?: number; udid?: string; waitForIdleTimeout?: number; webDriverAgentUrl?: string; }; ``` Example: ```ts theme={null} import { flow, launch } from "@qawolf/flows/ios"; export default flow( "Launch app bundle", "iOS - iPhone 15 (iOS 26)", async () => { const { driver } = await launch({ app: { path: "ios/MyApp.app" }, bundleId: "com.example.ios", snapshotMaxDepth: 500, }); await driver.pause(1000); }, ); ``` ### Launch defaults The implementation applies these defaults: * when `app` is omitted, launch falls back to the runner-provided executable input path and then to installed-app startup through `bundleId` * `respectSystemAlerts` defaults to `true` * `snapshotMaxDepth` defaults to `999` * `noReset` defaults to `false` Example: ```ts theme={null} import { flow, launch } from "@qawolf/flows/ios"; export default flow( "Use iOS defaults", "iOS - iPhone 15 (iOS 26)", async () => { const { driver } = await launch({ bundleId: "com.example.ios", }); await driver.pause(1000); }, ); ``` ### App resolution When your CI pipeline uploads an iOS build, QA Wolf sets `RUN_INPUT_PATH` to the uploaded file before the flow runs. Omit `app` in your launch call and QA Wolf will use that path automatically — you only need to provide the `bundleId`. When `app` is provided, the resolution order is: 1. `app.path` 2. `app.env` 3. `app.url` When `app` is omitted, launch falls back to `RUN_INPUT_PATH`. QA Wolf resolves relative paths against `RUN_INPUTS_EXECUTABLES_DIR` when that environment variable is present. Explicit app source examples: ```ts theme={null} await launch({ app: { path: "ios/MyApp.app" } }); await launch({ app: { env: "IOS_APP_PATH" } }); await launch({ app: { url: "https://example.com/MyApp.zip" } }); ``` `RUN_INPUT_PATH` fallback example: ```ts theme={null} // The runner sets RUN_INPUT_PATH before the flow starts. await launch({ bundleId: "com.example.ios", }); ``` If `app` is present but does not resolve to a value, launch does not fall back to `RUN_INPUT_PATH`. ### Advanced Appium capabilities The named launch options above cover the most common settings. For anything else, use the `capabilities` escape hatch: its entries are spread into the underlying [webdriver.io `remote`](https://webdriver.io/docs/api) capabilities and passed straight to the iOS driver. `startIos` is a thin wrapper around webdriver.io's `remote`, so capabilities that QA Wolf does not set itself are forwarded as-is. See the Appium references for the full list of available keys: * [Appium core capabilities](https://appium.io/docs/en/2.11/guides/caps/) * [XCUITest (iOS) driver capabilities](https://github.com/appium/appium-xcuitest-driver/blob/v8.3.1/docs/reference/capabilities.md) ```ts theme={null} import { flow, launch } from "@qawolf/flows/ios"; export default flow( "Launch with custom capabilities", "iOS - iPhone 15 (iOS 26)", async () => { const { driver } = await launch({ // Named options cover the common knobs: respectSystemAlerts: true, waitForIdleTimeout: 10, // Anything else is passed through verbatim: capabilities: { "appium:resetOnSessionStartOnly": true, "appium:settings[useFirstMatch]": "true", "appium:waitForQuiescence": "false", }, }); await driver.pause(1000); }, ); ``` Named options take precedence over `capabilities` for the same key. In particular, `noReset`, `respectSystemAlerts`, and `snapshotMaxDepth` are always applied (using their defaults when omitted), so configure those through the named options rather than `capabilities`. ### Migrating from `wdio.startIos` Older flows obtained a `wdio` handle from the test context and called `startIos` directly. With the flow API, pass the same configuration through the flow's `launch` options instead — map the settings that have a named option, and route the rest through `capabilities`. The `driver` you receive is the same webdriver.io `Browser` handle, so there is no separate "raw" driver to obtain. ```ts theme={null} // Before: raw wdio.startIos const driver = await wdio.startIos({ "appium:app": process.env.IOS_PATH, "appium:settings[respectSystemAlerts]": true, "appium:resetOnSessionStartOnly": true, "appium:settings[useFirstMatch]": "true", "appium:waitForQuiescence": "false", "appium:waitForIdleTimeout": 10, }); ``` ```ts theme={null} // After: flow launch options import { flow } from "@qawolf/flows/ios"; export default flow( "Open iOS app", { target: "iOS - iPhone 15 (iOS 26)", launch: { app: { env: "IOS_PATH" }, respectSystemAlerts: true, waitForIdleTimeout: 10, capabilities: { "appium:resetOnSessionStartOnly": true, "appium:settings[useFirstMatch]": "true", "appium:waitForQuiescence": "false", }, }, }, async ({ driver }) => { await driver.pause(1000); }, ); ``` `app: { env: "IOS_PATH" }` reads the path from the environment variable named `IOS_PATH`, replacing the raw `"appium:app": process.env.IOS_PATH` form. ## `device` `device` is a runtime proxy over the iOS simulator or device API. Use it for device-level operations — such as installing configuration profiles, simulating sensors, or managing device state — that sit outside app UI interactions. Use `driver` for interacting with the app itself. See [iOS Device Controls](/libraries/flows/api-reference/ios-device-reference) for the full method list. ## `expect` The exported `expect` is the assertion helper for iOS flows, backed by [`expect-webdriverio`](https://webdriver.io/docs/api/expect-webdriverio). All WebdriverIO matchers are available — including auto-retrying element matchers like `toBeDisplayed`, `toExist`, `toHaveText`, and `toHaveAttribute`. See the [expect-webdriverio matchers reference](https://webdriver.io/docs/api/expect-webdriverio) for the full list. Always import `expect` from `@qawolf/flows/ios` rather than from `expect-webdriverio` directly, so that QA Wolf's defaults (timeout, `toHaveScreenshot`) stay in effect. Example: ```ts theme={null} import { expect, flow } from "@qawolf/flows/ios"; export default flow( "Verify welcome screen", { target: "iOS - iPhone 15 (iOS 26)", launch: true }, async ({ driver, test }) => { await test("welcome heading is visible", async () => { const heading = driver.$("~welcome-heading"); await expect(heading).toBeDisplayed(); await expect(heading).toHaveText("Welcome"); }); }, ); ``` For visual regression assertions with `expect(driver).toHaveScreenshot(...)`, see [Native mobile screenshots](/visual-diffing-native-app). # iOS Device Pools Source: https://docs.qawolf.com/libraries/flows/api-reference/ios-device-pools Choose between QA Wolf's shared device pool, allowlisted devices, and private devices for iOS tests. By default, QA Wolf runs iOS tests on real, physical devices shared across customers, resigning your app during installation so it can install unreleased builds without a per-app provisioning profile. Two situations need different device access instead: * **A feature can't be tested on a resigned app** — push notifications and universal links (associated domains) depend on capabilities tied to a provisioning profile for your app's bundle ID, which only you can produce. Sign the app yourself for QA Wolf's devices instead — these are [allowlisted devices](#allowlisted-devices). * **A test needs full device control, or data/configuration to persist across runs** — for example, MDM (Mobile Device Management) testing. QA Wolf can dedicate devices to your team — these are [private devices](#private-devices). Contact your QA Wolf representative to enable allowlisted or private devices for your team. ## Shared vs. allowlisted vs. private | | Shared (default) | Allowlisted | Private | | ------------------ | ---------------------------- | ----------------------------------- | ---------------------------------- | | Device pool | Shared across customers | Part of the shared pool | Dedicated to your team | | App signing | Resigned by QA Wolf | Signed by you for QA Wolf's devices | Signed by you; resigning skippable | | State between runs | Cleaned up between every run | Cleaned up between every run | Can persist across runs | Private devices can do anything allowlisted devices can. You can share the UDIDs of private devices and skip resigning when that's needed for your tests. ## Allowlisted devices ### Sign the app for QA Wolf's devices QA Wolf shares a set of device UDIDs with you. Add these devices to the provisioning profile for your app's bundle ID. Build and sign the app for those devices using that profile. ### Include QA Wolf instrumentation When QA Wolf resigns an app, it injects an instrumentation library that lets your tests mock system behavior. When you sign the app yourself, include this library so mocking still works. * **Fastlane users** — use the `inject_qawolf_instrumentation` action from the [QA Wolf Fastlane plugin](https://github.com/qawolf/fastlane-plugin-qawolf). * **Non-Fastlane users** — use the injection [script in the plugin repository](https://github.com/qawolf/fastlane-plugin-qawolf/tree/main/scripts). After injecting the instrumentation library, resign the IPA. Fastlane users can use the [Fastlane resign action](https://docs.fastlane.tools/actions/resign/); otherwise resign the IPA with whatever your pipeline already uses. ### Target allowlisted devices Set `targetDevices` to `Allowlisted Device` in your workflow's target configuration so the run is scheduled on the devices you signed the app for. `deviceModel` and `iosVersion` depend on the devices QA Wolf allocated for your team. ```typescript theme={null} { platform: "ios", schemaVersion: 1, meta: { deviceModel: "iPhone 15", iosVersion: "26", targetDevices: "Allowlisted Device", }, } ``` ### Disable resigning Because you signed the app yourself, disable QA Wolf's resigning when the app launches by setting the `qawolf:disableResigning` capability: ```typescript theme={null} import { flow } from "@qawolf/flows/ios"; export default flow( "Test push notifications", { target: { platform: "ios", schemaVersion: 1, meta: { deviceModel: "iPhone 15", iosVersion: "26", targetDevices: "Allowlisted Device", }, }, launch: { app: { env: "IPA_BUILD_LOCATION" }, capabilities: { "qawolf:disableResigning": true, }, }, }, async ({ driver, test }) => { await test("notification is received", async () => { // your test steps }); }, ); ``` ## Private devices Set `targetDevices` to `Team Dedicated Device` in your workflow's target configuration so the run is scheduled on your team's devices. `deviceModel` and `iosVersion` depend on the devices QA Wolf allocated for your team. ```typescript theme={null} { platform: "ios", schemaVersion: 1, meta: { deviceModel: "iPhone 15", iosVersion: "26", targetDevices: "Team Dedicated Device", }, } ``` # iOS Device Controls Source: https://docs.qawolf.com/libraries/flows/api-reference/ios-device-reference Reference notes for the `device` object from `@qawolf/flows/ios`. `device` is a runtime proxy over the iOS simulator and device API. Use it for device-level operations — such as injecting camera or audio input, managing photos, recording audio, simulating network conditions, or installing configuration profiles — that sit outside app UI interactions. Use `driver` for interacting with the app itself. `device` is only available while a flow is running. ```typescript theme={null} import { device, flow } from "@qawolf/flows/ios"; ``` ## `injectCamera` Replaces the app's camera input with a provided image or video. Affects photo capture, video data output, video recording, and preview layers. ```typescript theme={null} function injectCamera( driver: Browser, bundleId: string, source: CameraSource ): Promise<() => Promise>; type CameraSource = { /** File path, URL, or data URI for the media to inject. */ data: string; /** Media type. Default: inferred from file extension. */ type?: "image" | "video"; /** Delay in seconds before injection starts. */ delaySeconds?: number; }; ``` Returns a cleanup function that removes the injection config and media file. Cannot be used simultaneously with `injectAudio` — they share the same config file. Example: ```typescript theme={null} import { device, flow, launch } from "@qawolf/flows/ios"; export default flow( "Test camera feature", { target: "iOS - iPhone 15 (iOS 26)", launch: true }, async ({ driver, test }) => { await test("inject camera image", async () => { const cleanup = await device.injectCamera(driver, "com.example.app", { data: "/path/to/image.jpg", type: "image", }); // interact with your app's camera feature await cleanup(); }); }, ); ``` ## `injectAudio` Replaces the app's microphone input with a provided audio file. Affects `AVAudioRecorder` and `AVCaptureAudioDataOutput`. ```typescript theme={null} function injectAudio( driver: Browser, bundleId: string, source: AudioSource ): Promise<() => Promise>; type AudioSource = { /** File path, URL, or data URI for the audio to inject. */ data: string; /** Delay in seconds before injection starts. */ delaySeconds?: number; }; ``` Returns a cleanup function that removes the injection config and audio file. Cannot be used simultaneously with `injectCamera` — they share the same config file. Example: ```typescript theme={null} import { device, flow, launch } from "@qawolf/flows/ios"; export default flow( "Test voice input", { target: "iOS - iPhone 15 (iOS 26)", launch: true }, async ({ driver, test }) => { await test("inject audio", async () => { const cleanup = await device.injectAudio(driver, "com.example.app", { data: "/path/to/audio.mp3", }); // interact with your app's microphone feature await cleanup(); }); }, ); ``` ## `injectBarcode` Injects a barcode or QR code detection into the app's `AVCaptureMetadataOutput`. The injected barcode is delivered to the app's metadata output delegate as if scanned by the camera. The config file is auto-consumed after delivery. ```typescript theme={null} function injectBarcode( driver: Browser, bundleId: string, barcodes: BarcodeConfig | BarcodeConfig[] ): Promise<() => Promise>; type BarcodeConfig = { /** AVMetadataObjectType constant. Default: "org.iso.QRCode". */ type?: string; /** The barcode or QR code value to inject. */ value: string; /** Normalized bounds (0.0–1.0) for the detected barcode position. */ bounds?: { x: number; y: number; width: number; height: number; }; /** Corner points (0.0–1.0) for the detected barcode shape. */ corners?: { x: number; y: number }[]; /** * Raw binary payload for `AVMetadataMachineReadableCodeObject.rawValue` (iOS 13+). * Accepts a Buffer or base64-encoded string. Defaults to the UTF-8 encoding of `value`. */ rawValue?: Buffer | string; }; ``` Returns a cleanup function that removes the injection config. Example: ```typescript theme={null} import { device, flow, launch } from "@qawolf/flows/ios"; export default flow( "Scan QR code", { target: "iOS - iPhone 15 (iOS 26)", launch: true }, async ({ driver, test }) => { await test("inject QR code", async () => { const cleanup = await device.injectBarcode(driver, "com.example.app", { value: "https://example.com", }); // your app's scanner receives the QR code await cleanup(); }); }, ); ``` ## `injectBeacon` Injects iBeacon detections into the app's `CLLocationManager`. Always triggers a region-entry callback. When `beacons` is provided, also triggers ranging callbacks. ```typescript theme={null} function injectBeacon( driver: Browser, bundleId: string, config: BeaconConfig ): Promise<() => Promise>; type BeaconConfig = { /** Beacon region UUID. */ uuid: string; /** * Beacons to simulate. When omitted or empty, only a region-entry callback is triggered. */ beacons?: { major: number | string; minor: number | string; }[]; }; ``` Returns a cleanup function that removes the injection config files. Example: ```typescript theme={null} import { device, flow, launch } from "@qawolf/flows/ios"; export default flow( "Test beacon detection", { target: "iOS - iPhone 15 (iOS 26)", launch: true }, async ({ driver, test }) => { await test("inject beacon", async () => { const cleanup = await device.injectBeacon(driver, "com.example.app", { uuid: "8613BEAD-5465-4515-8F9C-AEEA717484C9", beacons: [{ major: 1, minor: 7 }], }); // your app receives region-entry and ranging callbacks await cleanup(); }); }, ); ``` ## `installConfigurationProfile` Installs a configuration profile on the iOS simulator or device. Returns a cleanup function that removes the profile when called. ```typescript theme={null} function installConfigurationProfile( driver: Browser, profileString: string ): Promise<() => Promise>; ``` Example: ```typescript theme={null} import { device, flow, launch } from "@qawolf/flows/ios"; const profile = ` `; export default flow("Install profile", "iOS - iPhone 15 (iOS 26)", async () => { const { driver } = await launch(); const cleanup = await device.installConfigurationProfile(driver, profile); // run your tests await cleanup(); }); ``` ## `setWebViewDebugging` Enables or disables WebView debugging (Safari Web Inspector) for all `WKWebView` instances in the app. When enabled, WKWebViews become inspectable via Safari DevTools. The default behavior is enabled. ```typescript theme={null} function setWebViewDebugging( driver: Browser, bundleId: string, enabled: boolean ): Promise<() => Promise>; ``` Returns a cleanup function that removes the config file, reverting to the default (enabled). Example: ```typescript theme={null} import { device, flow, launch } from "@qawolf/flows/ios"; export default flow( "Disable WebView debugging", { target: "iOS - iPhone 15 (iOS 26)", launch: true }, async ({ driver, test }) => { await test("disable debugging", async () => { const cleanup = await device.setWebViewDebugging(driver, "com.example.app", false); // run your tests await cleanup(); }); }, ); ``` ## `savePhoto` Saves an image file to the device's Photos library. ```typescript theme={null} function savePhoto( driver: Browser, filePath: string ): Promise; type SavePhotoResult = { success: boolean; message?: string; }; ``` Example: ```typescript theme={null} import { device, flow, launch } from "@qawolf/flows/ios"; export default flow( "Save photo to library", { target: "iOS - iPhone 15 (iOS 26)", launch: true }, async ({ driver, test }) => { await test("save photo", async () => { const result = await device.savePhoto(driver, "/path/to/image.jpg"); if (!result.success) throw new Error(result.message); }); }, ); ``` ## `listPhotos` Returns a list of all photo and video assets in the device's Photos library. ```typescript theme={null} function listPhotos(driver: Browser): Promise; type ListPhotosResult = { success: boolean; assets: PhotoAsset[]; totalCount: number; }; type PhotoAsset = { localIdentifier: string; mediaType: string; mediaSubtype: string; creationDate: string; modificationDate: string; pixelWidth: number; pixelHeight: number; duration: number; isFavorite: boolean; isHidden: boolean; }; ``` ## `deleteAllPhotos` Deletes all photos and videos from the device's Photos library. Use this for test teardown to restore a clean state between runs. ```typescript theme={null} function deleteAllPhotos(driver: Browser): Promise; type DeletePhotosResult = { success: boolean; deletedCount: number; message?: string; }; ``` Example: ```typescript theme={null} import { device, flow, launch } from "@qawolf/flows/ios"; export default flow( "Clean up photos", { target: "iOS - iPhone 15 (iOS 26)", launch: true }, async ({ driver, test }) => { await test("delete all photos", async () => { const result = await device.deleteAllPhotos(driver); console.log(`Deleted ${result.deletedCount} photos`); }); }, ); ``` ## `startSpeakerRecording` Starts a recording session that captures audio output from the device speaker. Returns a session object with the ID needed to stop the recording. ```typescript theme={null} function startSpeakerRecording(driver: Browser): Promise; type SpeakerRecordingSession = { id: string; status: string; }; ``` ## `stopSpeakerRecording` Stops an active speaker recording session. Also calculates the audio fingerprint automatically. Returns the recorded file details. ```typescript theme={null} function stopSpeakerRecording( driver: Browser, sessionId: string ): Promise; type SpeakerRecordingFile = { filename: string; fingerprint?: number[]; duration?: number; }; ``` ## `downloadSpeakerRecording` Downloads the recorded audio file as a Buffer in WAV format. ```typescript theme={null} function downloadSpeakerRecording( driver: Browser, filename: string ): Promise; ``` Example combining `startSpeakerRecording`, `stopSpeakerRecording`, and `downloadSpeakerRecording`: ```typescript theme={null} import { device, flow, launch } from "@qawolf/flows/ios"; export default flow( "Record and verify speaker audio", { target: "iOS - iPhone 15 (iOS 26)", launch: true }, async ({ driver, test }) => { await test("record audio", async () => { const session = await device.startSpeakerRecording(driver); // trigger audio playback in your app const file = await device.stopSpeakerRecording(driver, session.id); const buffer = await device.downloadSpeakerRecording(driver, file.filename); // assert against the buffer or fingerprint }); }, ); ``` ## `calculateAudioFingerprint` Calculates an audio fingerprint from a WAV audio buffer or file path. Use this to compare recorded audio against a known reference without byte-for-byte comparison. ```typescript theme={null} function calculateAudioFingerprint( driver: Browser, audioData: Buffer | string ): Promise; type AudioFingerprint = { fingerprint: number[]; duration: number; }; ``` ## `simulateNetworkCondition` Simulates a degraded network condition on the device. Returns a cleanup function that restores normal network conditions when called. ```typescript theme={null} function simulateNetworkCondition( config: NetworkConditionConfig ): Promise<() => Promise>; type NetworkConditionConfig = { bandwidthKbps?: number; latencyMs?: number; jitterMs?: number; packetLossPercent?: number; }; ``` Built-in presets: | Preset | Bandwidth | Latency | Jitter | Packet loss | | ------------------------------- | --------- | ------- | ------ | ----------- | | `device.NETWORK_2G_EDGE` | 240 Kbps | 300ms | 100ms | 1.5% | | `device.NETWORK_3G` | 1.8 Mbps | 100ms | 30ms | 0.5% | | `device.NETWORK_4G_LTE` | 12 Mbps | 30ms | 10ms | 0.1% | | `device.NETWORK_5G` | 100 Mbps | 10ms | 3ms | 0.01% | | `device.NETWORK_SATELLITE` | 5 Mbps | 600ms | 50ms | 1% | | `device.NETWORK_WIFI_CONGESTED` | 2 Mbps | 50ms | 40ms | 3% | | `device.NETWORK_VERY_BAD` | 100 Kbps | 500ms | 200ms | 10% | | `device.NETWORK_OFFLINE` | — | — | — | 100% | Example: ```typescript theme={null} import { device, flow, launch } from "@qawolf/flows/ios"; export default flow( "Test on slow network", { target: "iOS - iPhone 15 (iOS 26)", launch: true }, async ({ driver, test }) => { const cleanup = await device.simulateNetworkCondition(device.NETWORK_3G); await test("load content on 3G", async () => { // your test steps }); await cleanup(); }, ); ``` ## `getNetworkCondition` Returns the current network simulation state. ```typescript theme={null} function getNetworkCondition(): Promise; type NetworkConditionStatus = { enabled: boolean; config?: { bandwidthKbps?: number; latencyMs?: number; jitterMs?: number; packetLossPercent?: number; }; interface?: string; }; ``` ## `routeTraffic` Routes network traffic for specific apps or domains through a tunnel. Returns a cleanup function that restores default routing when called. ```typescript theme={null} function routeTraffic(config: RouteTrafficConfig): Promise<() => Promise>; type RouteTrafficConfig = { apps: string[]; domains?: string[]; tunnel: | { type: "direct" } | { type: "http-proxy"; host: string; port: number; username?: string; password?: string } | { type: "wireguard"; configPath: string } | { type: "openvpn"; configPath: string } | { type: "relay"; host: string; port: number; username: string; password: string; dialer?: "tls" | "tcp"; secure?: boolean }; socksHost?: string; socksPort?: number; inspect?: boolean; }; ``` Example: ```typescript theme={null} import { device, flow } from "@qawolf/flows/ios"; export default flow("Route traffic through proxy", "iOS - iPhone 15 (iOS 26)", async () => { const cleanup = await device.routeTraffic({ apps: ["com.example.app"], tunnel: { type: "http-proxy", host: "proxy.example.com", port: 8080, }, }); // run your tests await cleanup(); }); ``` ## `getNetworkStatus` Returns the current network routing status including VPN state, active tunnels, and routed apps. ```typescript theme={null} function getNetworkStatus(): Promise; type NetworkStatus = { route: "direct" | "http-proxy" | "wireguard" | "openvpn" | "relay"; tunnels: Record; routingTables?: { activeTable: string; tables: Record }; traffic?: { routes: Record; interfaces: Record; }; vpnApp: { bundleId: string; installed: boolean; pid?: number }; routedApps: string[]; routedDomains: string[]; }; ``` ## `subscribeNetworkLogs` Subscribes to real-time network log events streamed from the Appium plugin. Returns a subscription object with `on()` and `close()` methods. ```typescript theme={null} function subscribeNetworkLogs(): NetworkLogSubscription; interface NetworkLogSubscription { on(handler: (entry: RecordedEntry) => void): void; close(): void; } ``` Requires `routeTraffic()` to have been called first with `inspect: true`. Example: ```typescript theme={null} import { device, flow } from "@qawolf/flows/ios"; export default flow("Inspect network traffic", "iOS - iPhone 15 (iOS 26)", async () => { const cleanup = await device.routeTraffic({ apps: ["com.example.app"], tunnel: { type: "direct" }, inspect: true, }); const subscription = device.subscribeNetworkLogs(); subscription.on((entry) => { if (entry.http) console.log(`${entry.http.method} ${entry.http.uri} → ${entry.http.statusCode}`); if (entry.dns) console.log(`DNS ${entry.dns.name} → ${entry.dns.answer}`); }); // trigger network activity in your app subscription.close(); await cleanup(); }); ``` The `RecordedEntry` object contains the following fields: ```typescript theme={null} interface RecordedEntry { service: string; network: string; remote?: string; local?: string; host?: string; proto?: string; http?: { host: string; method: string; proto: string; scheme: string; uri: string; statusCode: number; request?: { contentLength: number; header: Record; body: string | null }; response?: { contentLength: number; header: Record; body: string | null }; }; websocket?: { from: string; fin: boolean; rsv1: boolean; rsv2: boolean; rsv3: boolean; opcode: number; masked: boolean; maskKey: number; length: number; payload: string; }; tls?: { serverName: string; cipherSuite: string; version: string; proto?: string }; dns?: { id: number; name: string; class: string; type: string; question: string; answer: string; cached: boolean }; sid: string; time: string; duration: number; } ``` # Top-Level Source: https://docs.qawolf.com/libraries/flows/api-reference/top-level Reference notes for the top-level @qawolf/flows entry point. The top-level `@qawolf/flows` entry point exposes cross-platform helpers and types. ## Primary exports * `platform.target` * `configureTarget(...)` * `getCurrentScope()` * `resetTarget()` * `FailWithoutRetryError` It also re-exports: * `type Target` * `type TargetScope` ## Target literals The target string must exactly match one of the supported values or your flow will fail to initialize. ```ts theme={null} type Target = | "Basic" | "Web - Chrome" | "Web - Chrome (GPU)" | "Web - Firefox" | "Web - Firefox (GPU)" | "Web - Safari" | "Web - Safari (GPU)" | "Android - Pixel" | "Android - Pixel 2 (Android 14)" | "Android - Pixel 9" | "Android - Pixel 9 (Android 14)" | "Android - Pixel 9 (Android 15)" | "Android - Pixel 9 (Android 16)" | "Android - Pixel Tablet (Android 14)" | "Android - Tablet" | "iOS - Allowlisted iPhone" | "iOS - iPad" | "iOS - iPad 11 (iOS 18)" | "iOS - iPad 11 (iOS 26)" | "iOS - iPhone 15 (iOS 17)" | "iOS - iPhone 15 (iOS 18)" | "iOS - iPhone 15 (iOS 26)" | "iOS - iPhone 15 (iOS 26) (allowlisted)" | "iOS - iPhone 15 (iOS 26) (private)" | "iOS - iPhone 17 (iOS 26)" | "iOS 26 - Any iPad" | "iOS 26 - Any iPhone" | "Latest iOS (iPad)" | "Latest iOS (iPhone)"; ``` See [iOS Device Pools](/libraries/flows/api-reference/ios-device-pools) for when to use the iOS allowlisted/private targets above and how to set them up — Android has no equivalent. Example: ```ts theme={null} import { FailWithoutRetryError, configureTarget, getCurrentScope, platform, resetTarget, } from "@qawolf/flows"; import type { Target } from "@qawolf/flows"; declare const target: Target; configureTarget({ target }); const currentTarget = platform.target; const currentScope = getCurrentScope(); if (!currentScope) { throw new FailWithoutRetryError(); } resetTarget(); ``` ## `platform.target` `platform.target` is a getter on a frozen object. Every access reads the currently configured target. ```ts theme={null} import { platform } from "@qawolf/flows"; const target = platform.target; ``` Example with setup: ```ts theme={null} import { configureTarget, platform } from "@qawolf/flows"; import type { Target } from "@qawolf/flows"; declare const target: Target; configureTarget({ target }); console.log(platform.target); ``` It throws if no target has been configured. It always reflects the latest value set through `configureTarget(...)`. ## `configureTarget(...)` Use this to set the active target in local execution or tests. ```ts theme={null} import { configureTarget } from "@qawolf/flows"; import type { Target } from "@qawolf/flows"; declare const target: Target; configureTarget({ target }); ``` The input shape is: ```ts theme={null} type TargetScope = { target: Target; }; ``` ## `getCurrentScope()` Returns the current configured target scope, or `undefined` when no target has been configured yet. Example: ```ts theme={null} import { getCurrentScope } from "@qawolf/flows"; const scope = getCurrentScope(); if (scope) { console.log(scope.target); } ``` ## `resetTarget()` Clears the configured target scope. Use this in tests to avoid leaking target state across cases. Example: ```ts theme={null} import { configureTarget, getCurrentScope, resetTarget } from "@qawolf/flows"; import type { Target } from "@qawolf/flows"; declare const target: Target; configureTarget({ target }); resetTarget(); console.log(getCurrentScope()); // undefined ``` ## `FailWithoutRetryError` This is a dedicated error class whose message is `"failWithoutRetry"`. Use it when flow execution should fail immediately rather than being treated as retryable by the surrounding runtime. Example: ```ts theme={null} import { FailWithoutRetryError } from "@qawolf/flows"; throw new FailWithoutRetryError(); ``` # Web Source: https://docs.qawolf.com/libraries/flows/api-reference/web Reference for @qawolf/flows/web, the entry point for defining web flows with launch behavior, page assertions, and browser context helpers. `@qawolf/flows/web` defines web flows and optional explicit launch behavior. ## Primary exports * `flow(...)` * `launch(...)` * `expect` * `isAnonymous(...)` * `isPersistent(...)` * `isElectron(...)` * `testContextDependencies` It also exports web-specific target, launch, callback context, and flow definition types. Example: ```ts theme={null} import { expect, flow } from "@qawolf/flows/web"; export default flow( "Open homepage", { target: "Web - Chrome", launch: true }, async ({ page, test }) => { await test("open homepage", async () => { await page.goto("https://example.com"); await expect(page).toBeDefined(); }); }, ); ``` ## Target model Web flows accept web targets only. Use the CLI entry point for `Basic`. ```ts theme={null} type WebFlowTargetInput = | WebFlowTarget | { target: WebFlowTarget; launch?: false | undefined; } | { target: WebBrowserFlowTarget; launch: true | BrowserLaunchOptions; } | { target: WebElectronFlowTarget; launch: ElectronFlowLaunchOptions; }; ``` Examples: ```ts theme={null} import { flow, isElectron, isPersistent, launch } from "@qawolf/flows/web"; export const explicitLaunchFlow = flow("Open later", "Web - Chrome", async () => { const launchResult = await launch(); if (isElectron(launchResult)) throw new Error("Expected browser launch"); const page = isPersistent(launchResult) ? launchResult.page : await launchResult.context.newPage(); await page.goto("https://example.com"); }); export const launchedFlow = flow( "Open immediately", { target: "Web - Chrome", launch: true }, async ({ page, test }) => { await test("open page", async () => { await page.goto("https://example.com"); }); }, ); ``` ## Launch shapes Browser launch accepts the generated Playwright browser and context launch options, excluding Playwright's raw `persistentContext` and `userDataDir` fields. QA Wolf adds `browserContext`, reintroduces `userDataDir` for persistent launches, and maps those options back to the underlying Playwright shape. ```ts theme={null} type BrowserLaunchOptions = { kind?: "browser"; browser?: "chrome" | "chromium" | "firefox" | "msedge" | "webkit"; browserContext?: "incognito" | "persistent"; userDataDir?: string; // Generated Playwright browser and context options are also accepted, // except for Playwright's raw persistentContext and userDataDir fields. headless?: boolean; permissions?: string[]; geolocation?: { latitude: number; longitude: number; accuracy?: number; }; polyfills?: { intlListFormat?: boolean; }; // ...plus other generated browser/context options. }; type LaunchOptions = | BrowserLaunchOptions | { kind: "electron"; executablePath: string; }; type ElectronFlowLaunchOptions = { executablePath: string; }; type LaunchResult = | AnonymousLaunchResult | PersistentLaunchResult | ElectronLaunchResult; ``` QA Wolf maps `browserContext: "persistent"` to Playwright's persistent context mode. Pass `userDataDir` with `browserContext: "persistent"` when you want to reuse a profile directory. Browser example: ```ts theme={null} import { flow, launch } from "@qawolf/flows/web"; export default flow( "Open with persistent profile", "Web - Chrome", async () => { const { page } = await launch({ browserContext: "persistent", userDataDir: "/tmp/qawolf-profile", }); await page.goto("https://example.com"); }, ); ``` Electron declarative launch example: ```ts theme={null} import { flow } from "@qawolf/flows/web"; export default flow( "Open Electron app", { target: "Electron", launch: { executablePath: "/Applications/MyApp.app/Contents/MacOS/MyApp", }, }, async ({ page, test }) => { await test("open app", async () => { await page.goto("about:blank"); }); }, ); ``` Explicit `launch(...)` calls are not target-typed. Include `kind: "electron"` and use `firstWindowPage` from the result: ```ts theme={null} import { flow, launch } from "@qawolf/flows/web"; export default flow("Open Electron app", "Web - Chrome", async () => { const { firstWindowPage } = await launch({ kind: "electron", executablePath: "/Applications/MyApp.app/Contents/MacOS/MyApp", }); await firstWindowPage.getByRole("button", { name: "Sign in" }).click(); }); ``` Static launch options infer a narrow return type when they determine the result shape: * `launch({ browserContext: "persistent" })` returns `PersistentLaunchResult` * `launch({ browserContext: "incognito" })` returns `AnonymousLaunchResult` * `launch({ kind: "electron", executablePath })` returns `ElectronLaunchResult` * `launch()` returns the full `LaunchResult` union because startup depends on the active flow target Use `isAnonymous(...)`, `isPersistent(...)`, and `isElectron(...)` to narrow dynamic results before reading shape-specific fields. ## Defaults * default `kind` is browser launch * default `browserContext` is `"incognito"` * default `browser` comes from the flow target * GPU launch behavior is derived from the flow target * Electron declarative launch requires `target: "Electron"` and `executablePath` Example: ```ts theme={null} import { flow, isElectron, isPersistent, launch } from "@qawolf/flows/web"; export default flow( "Use target browser defaults", "Web - Firefox", async () => { const launchResult = await launch(); if (isElectron(launchResult)) throw new Error("Expected browser launch"); const page = isPersistent(launchResult) ? launchResult.page : await launchResult.context.newPage(); await page.goto("https://example.com"); }, ); ``` ## Flow callback context All web flow callbacks receive: * `inputs` * `setOutput(...)` * `test(...)` Launch-enabled browser flows also receive: * `page` * `context` * optional `browser` Electron launch-enabled flows receive the first Electron window as `page`. `test(...)` can be omitted for simple flows where grouping steps into named sub-steps doesn't add value. For most flows, wrapping steps in `test(...)` is recommended — the label appears in your results and makes failures easier to locate. ## `testContextDependencies` `testContextDependencies` is exported for runner and tooling integration. Flow authors should usually use the public callback parameters above instead of depending on the raw runner dependency list. ## `expect` The exported `expect` is the assertion helper for web flows, backed by [Playwright's `expect`](https://playwright.dev/docs/test-assertions). All Playwright matchers are available — including auto-retrying locator matchers like `toBeVisible`, `toHaveText`, `toHaveURL`, and `toHaveScreenshot`. See [Playwright's locator assertions reference](https://playwright.dev/docs/api/class-locatorassertions) for the full list. Always import `expect` from `@qawolf/flows/web` rather than from `@playwright/test` directly, so that QA Wolf's defaults (custom timeout, visual diffing) stay in effect. Example: ```ts theme={null} import { expect, flow } from "@qawolf/flows/web"; export default flow( "Verify homepage", { target: "Web - Chrome", launch: true }, async ({ page, test }) => { await test("hero heading is visible", async () => { await page.goto("https://example.com"); await expect(page.getByRole("heading", { name: "Example Domain" })).toBeVisible(); }); }, ); ``` For visual regression assertions with `expect(page).toHaveScreenshot(...)`, see [Web & Mobile web screenshots](/visual-diffing). # Troubleshooting Source: https://docs.qawolf.com/libraries/flows/troubleshooting Troubleshoot common issues when authoring or running @qawolf/flows, including target configuration errors and missing launch context. ## `platform.target` throws Cause: The flow read the active target before configuring it. Check: * the runner is setting the target before flow code executes * tests and local tooling call `configureTarget(...)` explicitly ## Callback is missing `page` or `driver` Cause: The flow uses the no-`launch` path, so QA Wolf does not inject launched runtime objects into the callback automatically. Check: * whether the flow should use `{ target, launch: true }` * whether the flow should call `launch()` explicitly inside the callback ## Runtime API called outside the flow callback Cause: Your code called `launch()`, `device`, or `platform.target` at the module level, outside the flow callback. Check: * that all runtime API calls are inside the flow callback, not at the top of the file * that module-level code is limited to imports, constants, and pure helper functions # POM Reference Source: https://docs.qawolf.com/libraries/pom/api-reference/index API reference for @qawolf/pom: page object base classes, the page registry, typed construction, and page hooks. `@qawolf/pom` provides Page Object Model (POM) infrastructure for [Playwright](https://playwright.dev): base classes for page objects, a registry that constructs page objects by name and collects their page hooks, and automatic installation of popup and route-interception hooks. ## Requirements * **Node.js** `>=22.22` * **ES modules** — the package is ESM-only (`"type": "module"`). Import it from ESM code and use explicit `.js` specifiers in relative imports. * **Peer dependencies** * `@qawolf/flows` — used by `EntryPointPageObject` to launch the browser. * `playwright` — for the `Page` / `Locator` types your page objects use. ## Install ```sh theme={null} npm install @qawolf/pom ``` ## Defining a page object Extend `BasePageObject`. It stores the Playwright `Page` (available as `this.page`) and provides two ways to construct another page object: the static `createFromPage(page)` factory on a class you imported, and `this.create(name)` for a registered name. Keep selectors in a private `locators` getter. ```ts theme={null} import { BasePageObject } from "@qawolf/pom"; import { DashboardPage } from "./dashboard-page.js"; export class LoginPage extends BasePageObject { private get locators() { return { email: this.page.getByLabel("Email"), password: this.page.getByLabel("Password"), submit: this.page.getByRole("button", { name: "Sign in" }), } as const; } async signIn(email: string, password: string) { await this.locators.email.fill(email); await this.locators.password.fill(password); await this.locators.submit.click(); return DashboardPage.createFromPage(this.page); } } ``` `dashboard-page.js` may import `login-page.js` back, so two page objects that navigate to each other can both expose the trip. What Node cannot load is a page object that `extend`s a class in a file importing it back — see [Troubleshooting](/libraries/pom/troubleshooting). ## The page registry The registry does two things: it constructs a page object from a name, and it is the list `installPageHooks()` walks to collect popup handlers and route interceptors. A direct import replaces the first job but not the second, so a page object that declares `popupHandlers()` or `routeInterceptors()` needs a registry entry however its instances get built. Constructing by name also keeps a page object's module out of the calling file's import graph until the first construction, which is worth having when a workspace holds many page objects. ### Registering Create one module that registers every page object, and import it for its side effects **before** you construct any page object. Register lazily with a module loader so a page object's module is only loaded when it is first used: ```ts theme={null} // register-pages.ts import { registerPage } from "@qawolf/pom"; registerPage("LoginPage", () => import("./pages/login-page.js")); registerPage("DashboardPage", () => import("./pages/dashboard-page.js")); ``` The loader must resolve to a module that exports the class under the **same name** it was registered with. Eager registration also works when you already hold the class value: ```ts theme={null} import { LoginPage } from "./pages/login-page.js"; registerPage("LoginPage", LoginPage); ``` ### Constructing `createPage` builds a registered page object for a given `Page`. Import the registration module first so the registry is populated: ```ts theme={null} import "./register-pages.js"; import { createPage } from "@qawolf/pom"; const loginPage = await createPage("LoginPage", page); await loginPage.signIn("user@example.com", "hunter2"); ``` Inside a page object, use the protected `this.create(...)` instead — it shares the current `Page` and goes through the same registry. Import the sibling's type for the return annotation: ```ts theme={null} import { BasePageObject } from "@qawolf/pom"; import type { SettingsPage } from "./settings-page.js"; export class DashboardPage extends BasePageObject { async openSettings(): Promise { await this.page.getByRole("link", { name: "Settings" }).click(); return this.create("SettingsPage"); } } ``` `create` / `createPage` are async because lazily registered modules load on first use. Importing `SettingsPage` and calling `SettingsPage.createFromPage(this.page)` is the synchronous alternative, and needs no registry entry and no separate return-type annotation. ### Typing `this.create(...)` By default `this.create("LoginPage")` returns `any`. Augment the `RegisteredPages` interface — declared for exactly this purpose — to make the name-to-type mapping known, and `create` becomes fully typed. This is incremental: names you don't list keep the `any` fallback. ```ts theme={null} import type { LoginPage } from "./pages/login-page.js"; import type { DashboardPage } from "./pages/dashboard-page.js"; declare module "@qawolf/pom" { interface RegisteredPages { LoginPage: LoginPage; DashboardPage: DashboardPage; } } ``` Now `await this.create("LoginPage")` is typed as `LoginPage`. ## Entry points and page hooks An **entry point** is the page object that owns browser startup. Extend `EntryPointPageObject` and expose a `create()` that launches the browser (`initializeBrowser`), builds the instance (`createFromPage`), and installs page hooks: ```ts theme={null} import { EntryPointPageObject } from "@qawolf/pom"; export class AppEntryPoint extends EntryPointPageObject { static async create(): Promise { const page = await AppEntryPoint.initializeBrowser(); const entryPoint = AppEntryPoint.createFromPage(page); await entryPoint.installPageHooks(); return entryPoint; } async openLogin() { await this.goto("/login"); return this.create("LoginPage"); } } ``` A page object can own popups to auto-dismiss or routes to intercept by overriding `popupHandlers()` / `routeInterceptors()` on its class: ```ts theme={null} import { BasePageObject, type PopupHandlerDef } from "@qawolf/pom"; export class CookieBannerPage extends BasePageObject { override popupHandlers(): PopupHandlerDef[] { return [ { name: "cookie-banner", trigger: this.page.getByRole("button", { name: "Accept cookies" }), dismiss: async () => { await this.page .getByRole("button", { name: "Accept cookies" }) .click(); }, }, ]; } } ``` `installPageHooks()` collects these across **every** registered page object, not just the entry point — so register a page object that owns popups or routes even when your code only ever constructs it from a direct import. Its hooks never install otherwise. The registry detects overrides with an own-property check on the registered class's prototype, so declare `popupHandlers()` / `routeInterceptors()` directly on the class you register — it does not pick up an override inherited from a base class. When registering lazily, pass `{ providesPageHooks: false }` for page objects you know declare no hooks, so hook installation can skip loading their modules: ```ts theme={null} registerPage("StaticPage", () => import("./pages/static-page.js"), { providesPageHooks: false, }); ``` ## Exports | Export | Description | | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | | `BasePageObject` | Base class for page objects. Holds `this.page`; provides `this.create(name)` and the static `createFromPage(page)` factory. | | `SubPageObject` | Base class for a page object scoped to a parent region/component. | | `EntryPointPageObject` | Base class for the entry point; owns browser launch (`initializeBrowser`), `goto`, and `installPageHooks`. | | `registerPage(name, classOrLoader, options?)` | Register a page object by name, eagerly (class) or lazily (`() => import(...)`). | | `createPage(name, page)` | Construct a registered page object for a `Page`. Returns a promise. | | `PopupHandler` | Manages add/remove of popup handlers on a page. | | `NetworkMonitor` | Observes network activity and surfaces `NetworkError`s. | | `RegisteredPages` | Empty interface you augment to type `create(name)`. | | `PopupHandlerDef`, `RouteInterceptorDef`, `PageSetupOptions` | Hook and setup types. | | `callPlatformAPI` | Client for the QA Wolf platform API. | | `assertPricesClose`, `moneyToNumber`, `numberToMoney` | Money/price test-data helpers. | | `reportCleanupFailed`, `reportCleanupFailure` | Report failures from test cleanup steps. | # Troubleshooting Source: https://docs.qawolf.com/libraries/pom/troubleshooting Troubleshoot common issues when using @qawolf/pom, including unregistered pages, duplicate registrations, and stale page-hook flags. ## `Unknown page: ` when calling `create` Cause: The module that calls `registerPage` for that name has not been imported, so the registry is empty for it. Check: * the workspace's `register-pages` module is side-effect imported before any `create(name)` / `createPage(name, ...)` call * the name passed to `create` exactly matches the name passed to `registerPage` ## `Page "" is already registered.` Cause: `registerPage` was called twice for the same name. The registry throws on a duplicate key rather than silently overwriting. Check: * the name is registered in exactly one place * two page objects are not registered under the same name ## `Cannot access '' before initialization` Cause: Two files import each other, and one of them uses the other's class while that file is still loading — almost always a page object that `extend`s a class declared in a file importing it back. Two page objects that import each other are fine; extending across that pair is not, because the file that loads second evaluates `extends` before the class exists. Check: * the base class lives in a file that imports neither of the two page objects * nothing else in the cycle runs at module top level against an imported binding — using it inside a method body is fine, since both files have finished loading by then ## A page object's popup or route hook never runs Cause: The page object is not registered, or its hook override isn't detected — see [Entry points and page hooks](/libraries/pom/api-reference/index#entry-points-and-page-hooks) for how hook collection actually works. Check: * the page object is passed to `registerPage`, even if your code only ever constructs it from a direct import — `installPageHooks()` walks the registry and cannot see an unregistered class * `popupHandlers()` / `routeInterceptors()` is declared directly on the class passed to `registerPage`, not only on a base class it extends * the entry point calls `installPageHooks()` before navigating ## `providesPageHooks: false` contradicts the class Cause: A page object registered with `{ providesPageHooks: false }` declares a `popupHandlers()` / `routeInterceptors()` override. The registry throws so the stale flag surfaces instead of silently skipping hook installation — at `registerPage` time for an eagerly registered class, on first load for a lazy one. Check: * drop `{ providesPageHooks: false }` from that registration so its hooks are installed ## `this.create("X")` is typed as `any` Cause: The name is not present in the `RegisteredPages` map. Check: * the workspace augments `RegisteredPages` (via `declare module "@qawolf/pom"`) with an `X: X` entry * the augmentation module is included in the TypeScript program ## Two registries / pages invisible to each other Cause: More than one copy of `@qawolf/pom` resolved at runtime. The registry is module-level state, so each copy has its own — a page registered through one is invisible to the other. Check: * a single installed copy of `@qawolf/pom` resolves at runtime (no duplicate in a nested `node_modules`) * workspace code does not mix `@qawolf/pom` with a locally vendored copy of the POM library # Client Source: https://docs.qawolf.com/libraries/testkit/api-reference/client Reference for @qawolf/testkit/client, the runner-facing entry point used to create and configure the testkit client for flow helpers. `@qawolf/testkit/client` is the runner-facing entry point. ## Primary export * `createTestkitClient(...)` Example: ```ts theme={null} import { createTestkitClient } from "@qawolf/testkit/client"; const client = createTestkitClient({ mountCifsShare: async () => "/Volumes/shared", startOpenVpn: async () => "vpn-started", startWireGuard: async () => "wireguard-started", }); ``` ## Input ports The client is built from environment-specific ports such as: * `mountCifsShare` * `saveSnapshot` * `startOpenVpn` * `startWireGuard` If `saveSnapshot` is present, the created client exposes `saveBaselineScreenshot(...)`. Otherwise, that helper remains unavailable. Example with screenshot support: ```ts theme={null} import { createTestkitClient } from "@qawolf/testkit/client"; const client = createTestkitClient({ mountCifsShare: async ({ mountPoint }) => mountPoint, saveSnapshot: async (name, bytes) => { console.log(name, bytes.length); }, startOpenVpn: async ({ configPath }) => configPath, startWireGuard: async ({ configPath }) => configPath, }); await client.saveBaselineScreenshot?.( { screenshot: async () => Buffer.from("image"), }, "login-page", ); ``` # Core Source: https://docs.qawolf.com/libraries/testkit/api-reference/core Reference notes for the top-level @qawolf/testkit entry point. The top-level package exposes: * `otp.fromUri(...)` * `configureTestkitClient(...)` * `getCurrentTestkitClient(...)` * `resetTestkitClient(...)` * `mountCifsShare(...)` * `startOpenVpn(...)` * `saveBaselineScreenshot(...)` * `saveEnvironmentVariable(...)` * `reloadEnvironmentVariables(...)` `mountCifsShare(...)`, `startOpenVpn(...)`, and `saveBaselineScreenshot(...)` all require a client configured with `configureTestkitClient(...)` first — see [Client](/libraries/testkit/api-reference/client) for how to create one. Example: ```ts theme={null} import { configureTestkitClient, mountCifsShare, otp, reloadEnvironmentVariables, saveEnvironmentVariable, startOpenVpn, } from "@qawolf/testkit"; import { createTestkitClient } from "@qawolf/testkit/client"; // see Client for how to build this configureTestkitClient(createTestkitClient(runnerPorts)); const code = otp.fromUri("otpauth://totp/QA%20Wolf?secret=JBSWY3DPEHPK3PXP"); await mountCifsShare({ mountPoint: "/Volumes/shared", password: "secret", share: "//server/share", username: "wolf", }); await startOpenVpn({ configPath: "/tmp/test.ovpn" }); await saveEnvironmentVariable("SESSION_TOKEN", "abc123"); await reloadEnvironmentVariables(); console.log(code); ``` ## Environment variables These helpers are intended for QA Wolf runner environments. They require the runner-provided `QAWOLF_API_URL`, `QAWOLF_ENVIRONMENT_ID`, and `QAWOLF_TEAM_API_KEY` environment variables. Use `saveEnvironmentVariable(name, value)` to save a string value to the current QA Wolf environment and update `process.env[name]` for the running flow. Use `reloadEnvironmentVariables()` to fetch the latest environment variables for the current QA Wolf environment and copy string values into `process.env`. ```ts theme={null} import { reloadEnvironmentVariables, saveEnvironmentVariable, } from "@qawolf/testkit"; await saveEnvironmentVariable("LOGIN_TOKEN", token); await reloadEnvironmentVariables(); console.log(process.env["LOGIN_TOKEN"]); ``` ## Error behavior Client-dependent helpers — `mountCifsShare(...)`, `startOpenVpn(...)`, `saveBaselineScreenshot(...)` — throw when no client has been configured: ```ts theme={null} import { mountCifsShare, resetTestkitClient } from "@qawolf/testkit"; resetTestkitClient(); await mountCifsShare(/* same options as the example above */); // throws: no client configured ``` Environment variable helpers throw when the QA Wolf API configuration is missing or when the API request fails. `saveBaselineScreenshot(...)` also throws when the configured client does not provide screenshot support — see [Client](/libraries/testkit/api-reference/client#input-ports) for the `saveSnapshot` port that enables it. # Types Source: https://docs.qawolf.com/libraries/testkit/api-reference/types Reference notes for the public types used by @qawolf/testkit. These are the main public types exposed through `@qawolf/testkit` and `@qawolf/testkit/client`. ## `MountCifsShareOptions` ```ts theme={null} type MountCifsShareOptions = { mountPoint: string; password: string; share: string; username: string; }; ``` Used by: * `mountCifsShare(...)` * `TestkitPorts["mountCifsShare"]` Example: ```ts theme={null} const options: MountCifsShareOptions = { mountPoint: "/Volumes/shared", password: "secret", share: "//server/share", username: "wolf", }; ``` ## `StartOpenVpnOptions` ```ts theme={null} type StartOpenVpnOptions = { configPath: string; routeHosts?: string[]; routeIps?: string[]; routeNoPull?: boolean; }; ``` Used by: * `startOpenVpn(...)` * `TestkitPorts["startOpenVpn"]` Example: ```ts theme={null} const options: StartOpenVpnOptions = { configPath: "/tmp/test.ovpn", routeHosts: ["internal.example.com"], }; ``` ## `StartWireGuardOptions` ```ts theme={null} type StartWireGuardOptions = { configPath: string; }; ``` Used by: * `TestkitPorts["startWireGuard"]` This type is exposed through the client ports, but there is no top-level `startWireGuard(...)` helper export. Example: ```ts theme={null} const options: StartWireGuardOptions = { configPath: "/tmp/test.conf", }; ``` ## `Screenshotable` ```ts theme={null} type Screenshotable = { screenshot: (screenshotOptions?: unknown) => Promise; }; ``` Used by `saveBaselineScreenshot(...)`. Any page or locator-like object that supports this screenshot method shape can be used. Example: ```ts theme={null} const pageLike: Screenshotable = { screenshot: async () => Buffer.from("image"), }; ``` ## `SaveBaselineScreenshot` ```ts theme={null} type SaveBaselineScreenshot = ( pageOrLocator: Screenshotable, name: string, screenshotOptions?: unknown, ) => Promise; ``` Example: ```ts theme={null} await saveBaselineScreenshot(page, "login-page"); await saveBaselineScreenshot(locator, "login-form", { animations: "disabled", }); ``` ## `SaveSnapshot` ```ts theme={null} type SaveSnapshot = (name: string, bytes: Buffer) => Promise; ``` This is the lower-level runner port used to implement `saveBaselineScreenshot(...)`. Example: ```ts theme={null} const saveSnapshot: SaveSnapshot = async (name, bytes) => { console.log(name, bytes.length); }; ``` ## `TestkitPorts` ```ts theme={null} type TestkitPorts = { mountCifsShare: (options: MountCifsShareOptions) => Promise; saveSnapshot?: SaveSnapshot; startOpenVpn: (options: StartOpenVpnOptions) => Promise; startWireGuard: (options: StartWireGuardOptions) => Promise; }; ``` These are the environment-specific ports that runners pass to `createTestkitClient(...)`. See [Client](/libraries/testkit/api-reference/client#input-ports) for a full working set of ports. ## `TestkitClient` `TestkitClient` is a union of: * a client with baseline screenshot support * a client without baseline screenshot support If `saveSnapshot` is provided, the created client exposes `saveBaselineScreenshot(...)`. Otherwise that method is absent. Example — a client built without the `saveSnapshot` port (see [Client](/libraries/testkit/api-reference/client#input-ports) for a full set of ports): ```ts theme={null} import { createTestkitClient } from "@qawolf/testkit/client"; const client = createTestkitClient(portsWithoutSaveSnapshot); console.log(client.saveBaselineScreenshot); // undefined without saveSnapshot ``` # Web Source: https://docs.qawolf.com/libraries/testkit/api-reference/web Reference for @qawolf/testkit/web, including the readQRCode helper that decodes QR codes from a page locator during a flow. `@qawolf/testkit/web` exposes `readQRCode(page, selector)`. ## Current signature ```ts theme={null} readQRCode(page: Page, selector: string): Promise ``` Example: ```ts theme={null} import { readQRCode } from "@qawolf/testkit/web"; const code = await readQRCode(page, '[data-testid="login-qr"]'); ``` ## Behavior The helper: * screenshots the locator matched by `selector` * parses the result as PNG * decodes the QR code if one is present * returns `undefined` when no QR code is found Example: ```ts theme={null} import { readQRCode } from "@qawolf/testkit/web"; await page.goto("https://example.com"); const qrValue = await readQRCode(page, '[data-testid="qr-code"]'); if (qrValue) { console.log(qrValue); } ``` # Troubleshooting Source: https://docs.qawolf.com/libraries/testkit/troubleshooting Troubleshoot common @qawolf/testkit problems, including missing client configuration and baseline screenshot errors during flow runs. ## Helper throws because no client is configured Cause: The flow called a client-dependent helper before the runner configured the testkit client. Check: * the runner calls `configureTestkitClient(...)` before flow execution * tests call `resetTestkitClient()` between cases ## `saveBaselineScreenshot()` throws Cause: The configured client was created without `saveSnapshot`. Check: * the runtime passed snapshot support into `createTestkitClient(...)` ## QR code helper returns `undefined` Check: * the selected element actually contains a QR code * the screenshot is stable and large enough to decode reliably ## Update a baseline screenshot programmatically If the QA Wolf editor doesn't let you promote a new screenshot as the updated baseline, you can do it in flow code instead. Add `saveBaselineScreenshot` to the flow temporarily, run it once to save the new baseline, then remove it. ```typescript theme={null} import { saveBaselineScreenshot } from "@qawolf/testkit"; await saveBaselineScreenshot(page, "revenue-chart"); ``` The name must exactly match the name used in the corresponding `toHaveScreenshot` call. Remove this line once the baseline has been updated — it is not meant to run in normal test execution. # Lint rules Source: https://docs.qawolf.com/lint-rules Configure the lint rules QA Wolf runs on your flows and page objects with an .eslintrc.json file at the root of your repository. QA Wolf lints every JavaScript and TypeScript file in your workspace as you edit it, and again while an AI job writes code. Findings appear inline in the editor, the same way a type error does. A set of base rules always runs — the ones that catch real mistakes, such as an unhandled promise, an unreachable branch, or a duplicate object key. On top of those you can turn on QA Wolf's page object model rules, and set the severity of any rule yourself. ## Configure rules Add an `.eslintrc.json` file at the root of your repository. Without one, only the base rules run. ```json .eslintrc.json theme={null} { "extends": ["@qawolf/eslint-plugin-pom"] } ``` That turns on QA Wolf's [page object model rules](#page-object-model-rules), each at the severity it ships with. A rule ships at `error` when the code it reports breaks at runtime, and at `warn` when it marks a convention. ## Page object model rules These come from [`@qawolf/eslint-plugin-pom`](https://github.com/qawolf/eslint-plugin-pom), which is bundled into QA Wolf — you do not install anything. Each rule id is prefixed with `@qawolf/pom-lint/` when you name it under `rules`, which is also the prefix you see in the editor. ### How a rule finds its subject A rule checks either a directory or a kind of file, and the **Where** column below says which: * **`src/pages/`** — the rule reads `.ts` files under your page-object directory and ignores everything else. * **flow** — the rule recognizes a flow from its code: a module that imports `flow` from `@qawolf/flows` (any subpath) or default-exports a `flow(...)` call. The `.flow.ts` filename is not the signal, so a flow kept elsewhere is still checked. * **page object** — the rule recognizes a class extending `BasePageObject`, `SubPageObject` or `EntryPointPageObject`, wherever the file lives. * **anywhere** — the rule checks every file. ### Rules | Rule | Level | Where | Reports | | --------------------------------------- | ----- | ------------------- | --------------------------------------------------------------------------------- | | `no-raw-page-in-flows` | error | flow | `page.goto()`, `page.click()`, … in a flow | | `no-selectors-in-flows` | error | flow | `locator()` / `getBy*()` / `frameLocator()` in a flow | | `no-expect-in-flows` | warn | flow | `expect()` in a flow, rather than an `assert*()` page-object method | | `no-fetch-axios-in-flows` | error | flow | `fetch()` or an `axios` import in a flow | | `no-any-shared-state` | error | flow | a `let` in the flow callback typed `any`, or not typed at all | | `flow-export-structure` | error | flow | a flow module without `export default flow(name, target, callback)` | | `no-code-between-steps` | error | flow | a statement after the first `await test(...)` that is not itself one | | `test-aaa-comments` | warn | flow | a step with no Arrange / Act / Assert comment | | `aaa-banner-format` | warn | flow | an Arrange / Act / Assert marker that is not the three-line banner | | `assert-expect-pairing` | warn | `src/pages/` | `expect()` in a page-object method not named `assert*` | | `correct-base-class` | warn | `src/pages/` | a class that reads `this.page` but extends no page-object base | | `entry-point-factory` | warn | `src/pages/` | an `EntryPointPageObject` subclass with no `static create()` | | `no-direct-pom-construction` | warn | `src/pages/` | `new OtherPage(this.page)` instead of `this.create("OtherPage")` | | `no-inline-locator-in-page-object` | warn | `src/pages/` | a locator built from `this.page` outside the `locators` getter | | `no-legacy-selectors` | warn | `src/pages/` | XPath, a `css=` / `text=` / `id=` prefix, or a `>>` chain in a `locator()` string | | `no-mutable-state-in-pom` | warn | `src/pages/` | an instance field that is not `readonly` | | `no-public-constructor` | warn | `src/pages/` | a redeclared constructor that is not `protected` | | `no-wait-for-timeout-in-poms` | warn | `src/pages/` | `waitForTimeout()` / `waitForSelector()` in a page object | | `selector-getter-shape` | warn | `src/pages/` | a `locators` holder that is public, a field, a method, or missing `as const` | | `typed-create-return` | warn | `src/pages/` | a method returning `this.create("Name")` with no return type naming `Name` | | `web-first-assertions` | warn | `src/pages/` | `expect(await locator.isVisible()).toBe(true)` and its siblings | | `require-locator-jsdoc` | warn | page object | an entry in the `locators` map with no `/** … */` above it | | `require-env-pattern` | error | flow or page object | `process.env.X` instead of the workspace's `requireEnv()` | | `require-value-import-for-created-page` | error | anywhere | `this.create("Name")` where `Name` is bound by a type-only import | | `file-naming-convention` | warn | anywhere | a file under `src/` whose name is not kebab-case | | `no-non-null-assertion` | error | anywhere | a postfix `!` | | `no-parameter-properties` | error | anywhere | `constructor(private x: T)` | The [plugin's README](https://github.com/qawolf/eslint-plugin-pom#rules) explains each rule with examples of what it reports and what it expects instead. `warn` marks a convention rather than a defect. If your workspace is not ready for one, set that rule to `"off"` in `.eslintrc.json` rather than disabling it at each site. ## Set a rule's severity List a rule under `rules` with the severity you want: `"off"`, `"warn"` or `"error"` — or the matching number `0`, `1` or `2`. ```json theme={null} { "extends": ["@qawolf/eslint-plugin-pom"], "rules": { "@qawolf/pom-lint/no-wait-for-timeout-in-poms": "error", "@qawolf/pom-lint/assert-expect-pairing": "off", "no-debugger": "error" } } ``` Rules you do not list keep their shipped severity. A page object model rule only takes effect once the plugin is enabled. If you set a severity on one without listing `@qawolf/eslint-plugin-pom` in `extends` or `plugins`, the rule stays off and the editor says so. ## Turn on any other ESLint rule You are not limited to the rules QA Wolf enables by default. Name any core ESLint or [typescript-eslint](https://typescript-eslint.io/rules/) rule and it runs. ```json theme={null} { "rules": { "@typescript-eslint/no-deprecated": "warn", "eqeqeq": "error" } } ``` `@typescript-eslint/no-deprecated` is a useful one to know about: it flags calls to any API whose documentation marks it `@deprecated`. That includes the Playwright methods that have been superseded — `page.type()`, for example. Rules that need type information — `no-deprecated` among them — report where the types they need are loaded. In the editor that includes the types of the packages your code imports. While an AI job runs, it does not resolve imports from npm packages, so a rule of this kind reports less there. ## What QA Wolf reads QA Wolf reads three keys from `.eslintrc.json`: `extends`, `plugins` and `rules`. Anything else — `settings`, `parserOptions`, `overrides`, `env` — has no effect, and the editor flags it so the file does not quietly lie about what is running. Two more limits worth knowing: * **Options are not supported.** In `"no-redeclare": ["error", { … }]` the severity applies, and QA Wolf drops the options after it. * **`@qawolf/eslint-plugin-pom` is the only plugin supported.** Naming any other plugin in `extends` or `plugins` has no effect, and the editor tells you QA Wolf skipped it. QA Wolf can support additional plugins — ask, and QA Wolf will look at adding the one you need. Four spellings all enable the plugin, so use whichever reads best to you: `@qawolf/eslint-plugin-pom`, `@qawolf/pom`, `@qawolf/pom-lint`, or `plugin:@qawolf/pom/recommended`. They work in `extends` and in `plugins` alike. ## When the file cannot be read If `.eslintrc.json` is not valid JSON, is not a JSON object, or is longer than 131,072 characters, QA Wolf ignores the whole file and enables none of the page object model rules. The base rules still run, and the editor explains why QA Wolf skipped the file. # Authenticate the QA Wolf CLI Source: https://docs.qawolf.com/local-execution/authenticate Install the qawolf CLI and connect it to your workspace so it can pull and run your flows. Before the CLI can pull flows from your workspace or run flows that depend on environment variables, it needs an API key. You can log in interactively for local development, or pass the key through an environment variable for CI. ## Install the CLI The CLI ships in two forms: an npm package and a precompiled standalone binary. ### Install with npm Node.js 22.12 or later is required. ```bash theme={null} npm install -g @qawolf/cli ``` ### Install a precompiled binary Download the binary for your platform from [GitHub Releases](https://github.com/qawolf/cli/releases). Builds are published for Linux (x64 and arm64), macOS (Apple Silicon and Intel), and Windows x64. The binary has no Node.js runtime dependency. Extract the archive, then move the binary into a directory on your `PATH`. ### Verify ```bash theme={null} qawolf --version ``` ## Log in for local development Generate an API key in your QA Wolf workspace under **Workspace settings → API keys**. Run the login command: ```bash theme={null} qawolf auth login ``` The CLI prompts for your API key, validates it against the platform, and stores it in your system keychain. When a keychain is not available, the CLI falls back to a local config file in your user profile directory. Confirm the connection: ```bash theme={null} qawolf auth whoami ``` The output shows the team name, ID, and where the credential was loaded from. ## Authenticate in CI CI environments cannot prompt for input. Set the `QAWOLF_API_KEY` environment variable instead: ```bash theme={null} export QAWOLF_API_KEY= qawolf flows pull --env ``` The CLI prefers `QAWOLF_API_KEY` over any credential stored by `qawolf auth login`, so the same machine can switch between accounts by setting or unsetting the variable. ## Log out ```bash theme={null} qawolf auth logout ``` `qawolf auth logout` only removes credentials stored by `qawolf auth login` — see [`auth logout`](/libraries/cli/api-reference/commands#auth-logout) for exact behavior. To remove a `QAWOLF_API_KEY` credential, unset the variable in your shell instead. ## Switch the API URL By default the CLI talks to `https://app.qawolf.com`. Override this with `QAWOLF_API_URL` if your workspace uses a different host: ```bash theme={null} export QAWOLF_API_URL=https://qawolf.example.com ``` See the [Environment variables reference](/libraries/cli/api-reference/environment-variables) for the full list of variables the CLI reads. # Diagnose problems running flows Source: https://docs.qawolf.com/local-execution/diagnose-problems Use qawolf doctor to check your environment, find missing dependencies, and read the CLI's log file. When a flow run fails for reasons that aren't in the flow itself — a missing browser, an Android SDK that isn't on the path, a Node.js version mismatch — `qawolf doctor` is the first thing to run. ## Run the diagnostics From the project root, run: ```bash theme={null} qawolf doctor ``` The CLI checks: * the CLI version * the Node.js version against the required minimum * your API key and connectivity to QA Wolf and the npm registry * the Playwright installation, if any flow targets a browser * browser availability for each target the project uses * the Android SDK, if any flow targets Android * flow references to file assets that may not be available locally Read the output. Each check reports a pass, a warning, or a failure, with a remediation hint when it fails. ## Check every platform `qawolf doctor` only checks the platforms your project actually uses. Pass `--all` to run every check, including platforms not used by the current project: ```bash theme={null} qawolf doctor --all ``` This is useful when copying the project to a new machine and you want a single command that surfaces every missing dependency. ## Read the log file The CLI writes structured logs to the platform's log directory: * macOS: `~/Library/Logs/qawolf/cli.log` * Linux: `$XDG_STATE_HOME/qawolf/cli.log` or `~/.local/state/qawolf/cli.log` Run with `--verbose` to mirror debug logs to stderr in real time: ```bash theme={null} qawolf flows run --verbose ``` ## Common issues The [CLI Troubleshooting reference](/libraries/cli/troubleshooting) covers the specific errors the CLI emits, including authentication failures, missing Android SDK, expired download links, and the `@qawolf/testkit` resolution error. # Install runtime dependencies Source: https://docs.qawolf.com/local-execution/install-dependencies Install the Playwright browsers, Android system images, and drivers your flows need to run. [`qawolf flows run --env `](/local-execution/run-flows-locally) installs npm dependencies and Playwright browsers automatically before its first run, so web-only users rarely invoke `qawolf install` directly. A run doesn't install Android dependencies; run `qawolf install android` before running Android flows. Use `qawolf install` to install ahead of time (for example, to warm a CI cache between the checkout step and the run step) or when running flows from a [local-only project](/local-execution/set-up-a-project). ## Install everything the project needs From the project root, run: ```bash theme={null} qawolf install ``` The CLI scans the project for flows, looks at each flow's `target`, and installs the dependencies required by every target it finds. ## Install only for specific flows Pass a pattern to limit which flows are considered: ```bash theme={null} qawolf install "checkout/**" ``` This is useful in CI when a job only runs a subset of the suite. ## Install browsers only ```bash theme={null} qawolf install browsers qawolf install browsers "flows/web/**" ``` Equivalent to running `playwright install` for the browsers your web flows target. ## Install Android dependencies only Set `ANDROID_HOME` (or `ANDROID_SDK_ROOT`) to the path of your Android SDK before running this command. Install the SDK through Android Studio's **SDK Manager** or the standalone `cmdline-tools` package. ```bash theme={null} qawolf install android qawolf install android "flows/mobile/**" ``` The command installs the Appium uiautomator2 driver, downloads the Android system images for each target the matching flows use, and creates the corresponding AVDs. ## iOS support The CLI cannot yet execute iOS flows. `qawolf install` skips them with a warning. iOS support requires macOS with Xcode when it ships. # Pull your team's flows Source: https://docs.qawolf.com/local-execution/pull-flows Download an environment's flows into a local cache without running them. In most cases, [`qawolf flows run --env `](/local-execution/run-flows-locally) is enough — it pulls the environment's flows for you when they are not already cached locally. Use `qawolf flows pull` when you want to download the flow files without running them: to inspect them, commit them to a repository, or refresh a stale cache. Authenticate the CLI first. See [Authenticate the QA Wolf CLI](/local-execution/authenticate). ## Pull an environment Find the environment's ID in the QA Wolf app. Open **Workspace settings → Environments** and select the environment. Copy the `id` query parameter from the page URL: `https://app.qawolf.com//settings/environments?id=`. Use that value, not the environment's display name. Pull the flows: ```bash theme={null} qawolf flows pull --env ``` The CLI writes flows to `.qawolf//` by default. To use a different destination, pass `--out`: ```bash theme={null} qawolf flows pull --env --out ./snapshot ``` List the pulled flows: ```bash theme={null} qawolf flows list ``` ## What `pull` writes Inside `.qawolf//`, the CLI stores: * the flow source files * a manifest tracking what was pulled and when * an `assets/` directory with the environment's file assets from team storage, wired up so flows resolve them locally * a `.env` file with the environment's variables, loaded automatically by `qawolf flows run` ## Refresh a pulled environment Run `qawolf flows pull --env ` again. The CLI prompts before overwriting any file you've modified locally. To skip the prompt and overwrite, pass `--yes`: ```bash theme={null} qawolf flows pull --env --yes ``` ## List flows on the platform To see what is available on the platform without pulling, use `--remote`: ```bash theme={null} qawolf flows list --remote qawolf flows list "checkout/**" --remote ``` ## What's not pulled QA Wolf doesn't download mobile app binaries. Mobile flows reference the APK or IPA build through an environment variable, so make sure that path points to a build available on the machine before running. Flows that read paths from runner-only `QAWOLF_*_DIR` environment variables also cannot resolve those paths locally; `qawolf doctor` flags both cases. # Run flows locally Source: https://docs.qawolf.com/local-execution/run-flows-locally Run your team's flows on your own machine — the CLI pulls, installs, and runs in one command. `qawolf flows run --env ` is the recommended path. It runs your team's flows from the local `.qawolf//` cache, pulling them first only if they are not already cached locally, then installs the npm dependencies and Playwright browsers they need and runs them. Android flows require installing the Android tooling first with `qawolf install android`. See [Install dependencies](/local-execution/install-dependencies). Authenticate the CLI first. See [Authenticate the QA Wolf CLI](/local-execution/authenticate). ## Run an environment Find the environment's ID — see [Pull an environment](/local-execution/pull-flows#pull-an-environment). From any directory, run: ```bash theme={null} qawolf flows run --env ``` The CLI: 1. checks the local `.qawolf//` cache, and pulls the environment's flows only if they are not already cached 2. loads the environment's `.env` file 3. installs the npm dependencies and Playwright browsers the flows need 4. runs every flow To refresh the local cache against the platform, run `qawolf flows pull --env `. See [Pull your team's flows](/local-execution/pull-flows). ## Run a subset Pass a glob pattern to limit which flows run: ```bash theme={null} qawolf flows run --env "checkout/**" qawolf flows run --env "src/flows/login.flow.ts" ``` With `--env`, the CLI matches patterns against the pulled cache under `.qawolf//`. Without `--env`, it matches patterns against both the current directory and the pulled cache. ## Watch the browser Pass `--headed` to see the browser window during a web run: ```bash theme={null} qawolf flows run --env --headed ``` `--headed` does not apply to Android flows. ## Capture artifacts on failure By default, the CLI records no video or trace. To keep artifacts only when a flow fails, set the mode to `retain-on-failure`: ```bash theme={null} qawolf flows run --env --video retain-on-failure --trace retain-on-failure ``` Artifacts land in `qawolf-output/` (or the directory set by `--output-dir`). To record HAR files of network traffic: ```bash theme={null} qawolf flows run --env --har retain-on-failure ``` By default the HAR captures headers and timing only. To include response bodies, pass `--har-content full`. Response bodies use significantly more memory and disk. ## Retry failing flows ```bash theme={null} qawolf flows run --env --retries 2 ``` The CLI retries each failing flow up to the given number of times. It counts a flow that passes on retry as a pass for exit-code purposes. ## Stop after the first failure ```bash theme={null} qawolf flows run --env --bail ``` `--bail` is useful when iterating on a single flow and you want to fail fast. ## Run web flows in parallel ```bash theme={null} qawolf flows run --env --workers 4 ``` `--workers` controls how many web flows run concurrently. Android flows only run one at a time — see [`--workers`](/libraries/cli/api-reference/commands#flows-run-pattern) for the exact rule. ## Write a JUnit XML report ```bash theme={null} qawolf flows run --env --junit ``` The CLI writes a JUnit XML report to `qawolf-output/junit-report.xml` (or under the directory set by `--output-dir`). Pass an explicit path to override the default: ```bash theme={null} qawolf flows run --env --junit ./reports/results.xml ``` The CLI writes the XML report alongside the console output, independent of `--json` and `--agent`. ## Run flows you authored locally If you've scaffolded a [local-only project](/local-execution/set-up-a-project) with `qawolf init`, run without `--env`: ```bash theme={null} qawolf flows run ``` The CLI discovers flows matching `**/*.flow.{ts,js}` in the current directory and runs them against locally-installed runtime dependencies. See [Install dependencies](/local-execution/install-dependencies) if you need to install browsers or Android tooling explicitly. ## Exit codes `qawolf flows run` exits with `0` when every flow passes and `1` when one or more fail. See [Exit codes](/libraries/cli/api-reference/index#exit-codes) for the full list. ## Limitations * Android flows must run with `--workers 1`. Only web flows run in parallel. * The CLI does not execute iOS flows. It skips them with a warning. * Flows that target the legacy `"Basic"` platform pull successfully, but the CLI cannot execute them. * The CLI skips flows where `target` is a computed value rather than a string literal, because it cannot determine the platform ahead of time. # Set up a local-only project Source: https://docs.qawolf.com/local-execution/set-up-a-project Scaffold a project for hand-authoring flows that don't live on the QA Wolf platform. `qawolf init` scaffolds a project for writing flow files by hand. Most users should [pull flows from QA Wolf](/local-execution/pull-flows) instead — the platform is where flow creation, AI-powered flow generation, and team collaboration live, and `qawolf flows pull` brings those flows into a local cache the CLI can run from. Use `qawolf init` only when you want to author flows locally without the platform. ## Scaffold a new project Open a terminal in the directory you want to use as the project root: ```bash theme={null} cd path/to/your/project ``` Run the init command: ```bash theme={null} qawolf init ``` The CLI prompts before overwriting any existing files. To skip the prompts, pass `--yes`. The command creates the following files: * `qawolf.config.ts` — a project configuration file generated for future use; the CLI does not read it yet. * `src/flows/example.flow.ts` — a minimal web flow you can edit or delete. * `.qawolf/.gitignore` — ignores the contents of `.qawolf/`, such as flows pulled from the platform. It doesn't cover run artifacts in `qawolf-output/`. If your directory has no `package.json`, the CLI creates one with `"type": "module"`, the `@qawolf/flows` dependency, and a `test:e2e` script that runs `qawolf flows run`. If a `package.json` already exists, the CLI only adds the `test:e2e` script. ## Write a flow Flow files live anywhere in your project as long as they match the glob `**/*.flow.{ts,js}`. The example flow is a good starting point: ```typescript theme={null} import { expect, flow } from "@qawolf/flows/web"; export default flow( "Example", { launch: true, target: "Web - Chrome" }, async ({ page, test }) => { await test("navigate to example.com", async () => { await page.goto("https://example.com"); await expect(page).toHaveTitle(/Example/); }); }, ); ``` For the full flow authoring surface, see the [@qawolf/flows Top-Level Reference](/libraries/flows/api-reference/top-level). ## Verify the setup ```bash theme={null} qawolf flows list ``` The command lists every flow the CLI can find in the project. If the example flow appears, the project is ready to run. # Use your network drive Source: https://docs.qawolf.com/mount-a-file-share Mount your CIFS/SMB file share onto the runner so your flow can read and write files during a test run. This helper requires runner support. In QA Wolf-managed runs, the runner configures this automatically. If you're building a custom runner, see [Testkit Client](/libraries/testkit/api-reference/client). ## Examples **Mount your file share** ```typescript theme={null} import { mountCifsShare } from "@qawolf/testkit"; const mountPoint = await mountCifsShare({ share: "//server/share", mountPoint: "/mnt/share", username: process.env["SHARE_USERNAME"]!, password: process.env["SHARE_PASSWORD"]!, }); ``` **Read a file from your file share** ```typescript theme={null} import { mountCifsShare } from "@qawolf/testkit"; import { readFileSync } from "fs"; const mountPoint = await mountCifsShare({ share: "//server/share", mountPoint: "/mnt/share", username: process.env["SHARE_USERNAME"]!, password: process.env["SHARE_PASSWORD"]!, }); const data = readFileSync(`${mountPoint}/test-data.csv`, "utf-8"); ``` ## When to use * Your app reads or writes files that live on your network drive. * Your flow needs test fixtures or input data from your network drive that can't be committed to a repository. * Your app writes output files to your network drive and your flow needs to verify them. * Your flow needs to upload files to your app that are sourced from your network drive. ## Full example ```typescript theme={null} import { flow } from "@qawolf/flows/web"; import { mountCifsShare } from "@qawolf/testkit"; export default flow( "Upload file from your file share", { target: "Web - Chrome", launch: true }, async ({ page, test }) => { await test("mount file share", async () => { const mountPoint = await mountCifsShare({ share: "//server/share", mountPoint: "/mnt/share", username: process.env["SHARE_USERNAME"]!, password: process.env["SHARE_PASSWORD"]!, }); await page.goto(process.env["BASE_URL"]!); await page.setInputFiles('[type="file"]', `${mountPoint}/upload.csv`); }); await test("verify upload", async () => { await expect(page.getByText("Upload complete")).toBeVisible(); }); }, ); ``` # Connect to a VPN (iOS) Source: https://docs.qawolf.com/network-connectivity Route iOS test device traffic through VPN tunnels to reach internal services, staging environments, or geo-restricted content. By default, QA Wolf tests run on our cloud infrastructure, which means your app's network requests originate from our servers — not your corporate network. If your app needs to reach internal services (staging environments, internal APIs, services behind a firewall), you'll need to route your test device's traffic through a tunnel. `device.routeTraffic()` sets up a per-app VPN on the test device that intercepts and redirects network traffic before your test runs. You can target specific apps by bundle ID, or limit routing to specific domains when testing with Safari. Call `device.routeTraffic()` before starting your WebDriver session so the tunnel is active from the first network request. ## Examples **Route a native app's traffic** (all traffic from the app is intercepted): ```js theme={null} // the app property makes sure all traffic // from app with bundle id `com.example.myapp` will be rerouted import { device } from "@qawolf/flows/ios"; const cleanup = await device.routeTraffic({ apps: ["com.example.myapp"], tunnel: ..., }); // ... run tests ... await cleanup(); ``` **Route Safari traffic for specific domains only:** ```js theme={null} // Only traffic to *.example.com and api.staging.io // goes through the tunnel. All other Safari traffic goes direct. import { device } from "@qawolf/flows/ios"; const cleanup = await device.routeTraffic({ apps: [], domains: ["*.example.com", "api.staging.io"], tunnel: ..., }); await cleanup(); ``` **Route multiple apps simultaneously:** ```js theme={null} import { device } from "@qawolf/flows/ios"; const cleanup = await device.routeTraffic({ apps: ["com.example.myapp", "com.example.companion"], tunnel: ..., }); // Both apps' traffic is routed through the VPN await cleanup(); ``` **Check the current routing status:** ```js theme={null} import { device } from "@qawolf/flows/ios"; const cleanup = await device.routeTraffic({ apps: ["com.example.myapp"], tunnel: { type: "wireguard", configPath: "/configs/us-west.conf" }, }); const status = await device.getNetworkStatus(); // status.route === "wireguard" // status.tunnels[...].status === "up" once the tunnel is connected // status.routedApps includes "com.example.myapp" await cleanup(); ``` ## When to use * Your app connects to internal or staging services that aren't publicly accessible. * You need to test geo-restricted content by routing through a VPN endpoint in a specific region. * You want to verify how your app behaves when traffic passes through a proxy (authentication, filtering, latency). * You need to test Safari against specific internal domains without affecting other browsing. ## Tunnel types Choose a tunnel type based on how your network is set up. ### WireGuard Use if your team already has a WireGuard VPN or you need to simulate traffic from a specific geographic region. ```js theme={null} import { device } from "@qawolf/flows/ios"; const cleanup = await device.routeTraffic({ apps: ["com.apple.mobilesafari"], tunnel: { type: "wireguard", configPath: "/configs/us-west.conf" }, }); // ... test geo-restricted content ... await cleanup(); ``` ### OpenVPN Use if your organization uses a standard VPN setup with `.ovpn` config files. ```js theme={null} import { device } from "@qawolf/flows/ios"; const cleanup = await device.routeTraffic({ apps: ["com.example.app"], tunnel: { type: "openvpn", configPath: "/configs/client.ovpn" }, }); // ... test against staging servers behind VPN ... await cleanup(); ``` ### HTTP Proxy Use this if your organization provides an HTTP/HTTPS proxy endpoint for accessing internal services, or if you're using a third-party geolocation proxy service. ```js theme={null} import { device } from "@qawolf/flows/ios"; const cleanup = await device.routeTraffic({ apps: ["com.apple.mobilesafari"], tunnel: { type: "http-proxy", host: "proxy.corp.example.com", port: 8080, username: "testuser", password: "testpass", }, }); // ... run tests behind corporate proxy ... await cleanup(); ``` ### GOST Relay (3rd-party VPN alternative) Use this if your organization uses a third-party VPN that can't be configured as WireGuard or OpenVPN on our side. A QA Wolf team member will set up a relay VM that runs your VPN client and forwards traffic to your test device. ```js theme={null} import { device } from "@qawolf/flows/ios"; const cleanup = await device.routeTraffic({ apps: ["com.example.app"], tunnel: { type: "relay", host: "relay.corp.example.com", port: 443, username: "user", password: "pass", }, }); // ... test with traffic routed through remote relay ... await cleanup(); ``` ## Full sample test (http-proxy) ```js theme={null} import { flow, device } from "@qawolf/flows/ios"; export default flow( "iOS - Residential Proxy With Safari", { target: "iOS - iPhone 15 (iOS 26)", launch: { browserName: "safari" } }, async ({ driver, test }) => { await test("open google", async () => { // Discover your IP address await driver.url("https://ip.me"); await driver.pause(5000); // Configure and start QA Wolf's tunnel client const cleanup = await device.routeTraffic({ apps: [], domains: ["ip.me", "qawolf.com"], tunnel: { type: "http-proxy", username: process.env.PROXY_USER, password: process.env.PROXY_PASSWORD, host: process.env.PROXY_HOST, port: 8006, }, }); // Start the iOS Safari app await driver.activateApp('com.apple.mobilesafari'); await driver.url("https://qawolf.com"); // Check your IP address again. It should have changed await driver.url("https://ip.me"); }); }, ); ``` # Network simulation (iOS) Source: https://docs.qawolf.com/network-simulation Simulate degraded network conditions on iOS devices to test how your app handles slow connections, high latency, or no connectivity. ## Examples **Using a predefined condition:** ```js theme={null} import { device } from "@qawolf/flows/ios"; // Traffic must be routed through the gateway before applying // network conditions — use direct mode if you don't need a VPN const routeCleanup = await device.routeTraffic({ apps: ["com.example.app"], tunnel: { type: "direct" }, }); const conditionCleanup = await device.simulateNetworkCondition(device.NETWORK_3G); // ... verify the app handles slow network gracefully ... await conditionCleanup(); await routeCleanup(); ``` **Custom conditions:** ```js theme={null} import { device } from "@qawolf/flows/ios"; const cleanup = await device.simulateNetworkCondition({ bandwidthKbps: 5000, latencyMs: 600, jitterMs: 50, packetLossPercent: 1.0, }); // ... verify timeouts, retries, offline banners ... await cleanup(); ``` **Offline mode (no connectivity):** ```js theme={null} import { device } from "@qawolf/flows/ios"; // Simulates offline mode by dropping all packets. // Note: this may not trigger every app's offline UI — // some apps only show offline states when the connection drops abruptly const cleanup = await device.simulateNetworkCondition(device.NETWORK_OFFLINE); // ... verify offline error handling, cached content ... await cleanup(); ``` **Check the currently applied condition:** ```js theme={null} import { device } from "@qawolf/flows/ios"; const cleanup = await device.simulateNetworkCondition(device.NETWORK_3G); const status = await device.getNetworkCondition(); // status.enabled === true // status.config reflects NETWORK_3G's bandwidthKbps, latencyMs, jitterMs, packetLossPercent await cleanup(); ``` ## When to use * Testing how your app behaves when loading is slow — spinners, timeouts, retry logic. * Verifying your app shows the right error messages when connectivity is poor or lost. * Testing video or audio streaming features that adapt quality based on available bandwidth (adaptive bitrate streaming). * Reproducing issues customers have reported on slow or unreliable connections. * Making sure your app degrades gracefully for users on 2G or 3G networks. ## Notes * Before calling `device.simulateNetworkCondition()`, you must call `device.routeTraffic()`; otherwise, you will get an error. * Always call cleanup functions in reverse order: restore network conditions first, then stop routing. * Bandwidth limiting applies to TCP traffic only. * Latency, jitter, and packet loss apply to all traffic (TCP and UDP). * `NETWORK_OFFLINE` simulates no connectivity by dropping all packets. Requests will time out rather than fail immediately — this may behave differently from a true offline state on some devices. ## Full sample test ```js theme={null} import { flow, device } from "@qawolf/flows/ios"; export default flow( "iOS Network Simulation with fast.com", { target: "iOS - iPhone 15 (iOS 26)", launch: { browserName: "safari" } }, async ({ driver, test }) => { await test("Install app", async () => { // Start VPN const cleanupProxy = await device.routeTraffic({ apps: [], tunnel: { type: "direct", }}); let cleanUpNetworkCondition = await device.simulateNetworkCondition(device.NETWORK_5G) console.log(await device.getNetworkCondition()) await driver.url("https://fast.com"); // switch to webview const contexts = await driver.getContexts(); const webViewContext = contexts.reverse().find(context => context.toLowerCase().startsWith("webview_")); await driver.switchContext(webViewContext); // wait for the test result for 5G await driver.$(`//*[@id='your-speed-message']`).waitForDisplayed({timeout: 30_000}); // clean-up and set 2G await cleanUpNetworkCondition(); cleanUpNetworkCondition = await device.simulateNetworkCondition(device.NETWORK_2G_EDGE) console.log(await device.getNetworkCondition()) await driver.url("https://fast.com"); // wait for the test result for 2G await driver.$(`//*[@id='your-speed-message']`).waitForDisplayed({timeout: 30_000}); }); }, ); ``` # Non-deterministic AI assertions Source: https://docs.qawolf.com/non-deterministic-ai-testing Validate AI-generated responses using a judge model and assert on a structured JSON verdict. Add `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` to your environment variables before using these helpers. `askClaudeTextValidation` and `askChatGPTTextValidation` share the same signature and return shape, and can be swapped with no other changes to your test. ## Examples **Assert that an AI response is valid and within the token budget** ```typescript theme={null} const { validation, usage } = await askClaudeTextValidation( "You are a strict but fair evaluator. Focus on accuracy and completeness.", originalPrompt, aiResponseText, ); // Hard gate — judge model must deem the response valid expect( validation.isValidForPrompt, `Response invalid.\nMissing: ${JSON.stringify(validation.issues.missingRequirements)}\n` + `Incorrect: ${JSON.stringify(validation.issues.incorrectInformation)}\n` + `Explanation: ${validation.explanation}`, ).toBe(true); // Soft gate — tune threshold per client expect( validation.score, `Score ${validation.score} below threshold.\nExplanation: ${validation.explanation}`, ).toBeGreaterThanOrEqual(0.8); // No contradictions expect( validation.issues.incorrectInformation.length, `Incorrect info flagged: ${JSON.stringify(validation.issues.incorrectInformation)}`, ).toBe(0); // Token budget — tune per client and model expect(usage.input_tokens).toBeLessThanOrEqual(2000); expect(usage.output_tokens).toBeLessThanOrEqual(500); ``` **Use ChatGPT as the judge model instead** ```typescript theme={null} // Drop-in replacement — identical signature and return shape const { validation, usage } = await askChatGPTTextValidation( "You are a strict but fair evaluator. Focus on accuracy and completeness.", originalPrompt, aiResponseText, ); ``` ## When to use * Your app surfaces AI-generated content (summaries, prep notes, chat responses) that must be checked for accuracy. * Your app's AI feature must not introduce contradictions or hallucinations relative to source material. * Your app has a quality bar for AI output that a simple string match cannot enforce. * Your app sends AI prompts that could drift in cost and you need to assert token budgets. * Your app uses different AI providers and you want a consistent validation interface across both. ## Helpers ### `askClaudeTextValidation` Sends the original prompt and candidate response to Claude and returns a structured verdict. Conforms to the [Anthropic Messages API](https://docs.anthropic.com/en/api/messages). ````typescript theme={null} async function askClaudeTextValidation( systemPrompt: string, originalPrompt: string, aiResponseText: string, ) { const SCHEMA_INSTRUCTIONS = ` You MUST respond with ONLY a valid JSON object — no markdown, no explanation, no backticks. { "isValidForPrompt": boolean, "score": number (0.00–1.00), "issues": { "missingRequirements": [string], "incorrectInformation": [string], "offTopicContent": [string], "formattingProblems": [string], "safetyOrPolicyConcerns": [string] }, "explanation": string }`; const resp = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", headers: { "x-api-key": process.env.ANTHROPIC_API_KEY, "anthropic-version": "2023-06-01", "content-type": "application/json", }, body: JSON.stringify({ model: "claude-sonnet-4-6", max_tokens: 1024, system: systemPrompt + "\n\n" + SCHEMA_INSTRUCTIONS, messages: [{ role: "user", content: "PROMPT:\n" + originalPrompt + "\n\nCANDIDATE ANSWER:\n" + aiResponseText + "\n\nReturn the JSON verdict.", }], }), }); if (!resp.ok) throw new Error(`Anthropic error ${resp.status}: ${await resp.text()}`); const data = await resp.json(); const rawText = data.content?.find((b: any) => b.type === "text")?.text ?? ""; const validation = JSON.parse(rawText.replace(/```json|```/g, "").trim()); const usage = data.usage; // { input_tokens, output_tokens } return { validation, usage }; } ```` ### `askChatGPTTextValidation` Same signature and return shape as `askClaudeTextValidation`. Uses the OpenAI Responses API with structured JSON output. Conforms to the [OpenAI Responses API](https://platform.openai.com/docs/api-reference/responses/create). NPM needed: `openai@latest` ```typescript theme={null} import OpenAI from "openai"; async function askChatGPTTextValidation( systemPrompt: string, originalPrompt: string, aiResponseText: string, ) { const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); const resp = await client.responses.create({ model: "gpt-4.1", instructions: systemPrompt, input: [{ role: "user", content: "PROMPT:\n" + originalPrompt + "\n\nCANDIDATE ANSWER:\n" + aiResponseText + "\n\nReturn the JSON verdict.", }], text: { format: { type: "json_schema", name: "TextPromptValidation", strict: true, schema: { type: "object", additionalProperties: false, required: ["isValidForPrompt", "score", "issues", "explanation"], properties: { isValidForPrompt: { type: "boolean" }, score: { type: "number", minimum: 0, maximum: 1, multipleOf: 0.01 }, issues: { type: "object", additionalProperties: false, required: ["missingRequirements", "incorrectInformation", "offTopicContent", "formattingProblems", "safetyOrPolicyConcerns"], properties: { missingRequirements: { type: "array", items: { type: "string" } }, incorrectInformation: { type: "array", items: { type: "string" } }, offTopicContent: { type: "array", items: { type: "string" } }, formattingProblems: { type: "array", items: { type: "string" } }, safetyOrPolicyConcerns: { type: "array", items: { type: "string" } }, }, }, explanation: { type: "string" }, }, }, }, }, }); const validation = JSON.parse(resp.output_text); const usage = { input_tokens: resp.usage?.input_tokens ?? 0, output_tokens: resp.usage?.output_tokens ?? 0, }; return { validation, usage }; } ``` *** ## Return shape Both helpers return `{ validation, usage }` with the same structure. | Field | Type | Description | | ----------------------------- | ---------- | ----------------------------------------------------------- | | `validation.isValidForPrompt` | `boolean` | Hard gate — did the response satisfy the prompt? | | `validation.score` | `number` | Quality score 0–1. Assert `>= 0.8` as a starting threshold. | | `validation.issues.*` | `string[]` | Arrays of flagged issues by category. | | `validation.explanation` | `string` | Step-by-step reasoning from the judge model. | | `usage.input_tokens` | `number` | Tokens consumed by the prompt. | | `usage.output_tokens` | `number` | Tokens consumed by the response. | *** ## Full sample test ```typescript theme={null} import { flow, expect } from "@qawolf/flows/web"; import { llms } from "./helpers/llm-helper.t"; export default flow( "AI response is valid, scored, and within token budget", { target: "Web - Chrome", launch: true }, async ({ page }) => { //-------------------------------- // Arrange //-------------------------------- // The prompt sent to the AI feature under test const originalPrompt = "Summarize the key action items from the meeting transcript below " + "in a bulleted list. Be concise and accurate.\n\n" + "Transcript:\n" + "Alice: We need to ship the new onboarding flow by Friday.\n" + "Bob: I'll own the front-end changes.\n" + "Alice: Great. Carol, can you handle QA?\n" + "Carol: Yes, I'll have test cases ready by Thursday.\n" + "Alice: Perfect. Also, let's schedule a retro for next Monday at 10am."; const { askChatGPTTextValidation, askClaudeTextValidation } = await llms(); //-------------------------------- // Act //-------------------------------- await page.getByRole("link", { name: "AI Assistant" }).click(); await page.getByRole("textbox", { name: "Ask anything" }).fill(originalPrompt); const [response] = await Promise.all([ page.waitForResponse( (res) => res.url().includes("/api/chat") && res.request().method() === "POST", { timeout: 60_000 }, ), page.getByRole("button", { name: "Send" }).click(), ]); expect(response.status()).toBe(200); const aiResponseContainer = page.locator("[data-testid='ai-response']:last-of-type"); await expect(aiResponseContainer).toBeVisible({ timeout: 15_000 }); const aiResponseText = await aiResponseContainer.innerText(); //-------------------------------- // Assert //-------------------------------- const { validation, usage } = await askClaudeTextValidation( "You are a strict but fair evaluator of AI-generated meeting summaries. " + "Focus on whether the response captures the correct action items, owners, and deadlines.", originalPrompt, aiResponseText, ); // Structure expect(typeof validation.isValidForPrompt).toBe("boolean"); expect(typeof validation.score).toBe("number"); expect(Array.isArray(validation.issues.missingRequirements)).toBe(true); expect(Array.isArray(validation.issues.incorrectInformation)).toBe(true); expect(Array.isArray(validation.issues.offTopicContent)).toBe(true); expect(Array.isArray(validation.issues.formattingProblems)).toBe(true); expect(Array.isArray(validation.issues.safetyOrPolicyConcerns)).toBe(true); expect(typeof validation.explanation).toBe("string"); // Hard gate expect( validation.isValidForPrompt, `Response invalid.\n` + `Missing: ${JSON.stringify(validation.issues.missingRequirements)}\n` + `Incorrect: ${JSON.stringify(validation.issues.incorrectInformation)}\n` + `Off-topic: ${JSON.stringify(validation.issues.offTopicContent)}\n` + `Explanation: ${validation.explanation}`, ).toBe(true); // Soft gate expect( validation.score, `Score ${validation.score} below threshold.\nExplanation: ${validation.explanation}`, ).toBeGreaterThanOrEqual(0.8); // No contradictions expect( validation.issues.incorrectInformation.length, `Incorrect info: ${JSON.stringify(validation.issues.incorrectInformation)}`, ).toBe(0); expect( validation.issues.offTopicContent.length, `Off-topic content: ${JSON.stringify(validation.issues.offTopicContent)}`, ).toBe(0); // Token budget expect( usage.input_tokens, `Input tokens ${usage.input_tokens} exceeded budget of 2000`, ).toBeLessThanOrEqual(2000); expect( usage.output_tokens, `Output tokens ${usage.output_tokens} exceeded budget of 500`, ).toBeLessThanOrEqual(500); }, ); ``` # How to integrate with our SDK Source: https://docs.qawolf.com/other-ci-node Trigger QA Wolf test runs from any CI system that supports Node.js. Use this guide if your CI system supports Node.js but does not have a dedicated QA Wolf integration page. If your CI system does not support Node.js, use [Other CI (webhook)](/other-ci-webhook) instead. Make sure you have: * A CI pipeline that runs after a successful deployment. * Node.js 18 or later available in your CI environment. * Admin access to your CI system's secret or environment variable storage. * At least one QA Wolf environment already configured. * A QA Wolf API key. ## Find the QAWOLF\_API\_KEY Open the **Workspace name** dropdown in QA Wolf and click **Workspace Settings**. Choose **Integrations** and click the icon to the right of **API Key** under **API Access**. Store the key as a secret in your CI system named `QAWOLF_API_KEY`. ## Add the notify script to your repository Create a file at `.ci/notifyQaWolf.mjs` in the repository that corresponds to the deployments QA Wolf will be testing. ```javascript theme={null} import assert from "assert"; import { makeQaWolfSdk } from "@qawolf/ci-sdk"; const apiKey = process.env.QAWOLF_API_KEY; assert(apiKey, "QAWOLF_API_KEY is required"); const sha = process.env.GIT_COMMIT_SHA; assert(sha, "GIT_COMMIT_SHA is required"); const branch = process.env.GIT_BRANCH; assert(branch, "GIT_BRANCH is required"); const { attemptNotifyDeploy } = makeQaWolfSdk({ apiKey }); const result = await attemptNotifyDeploy({ branch, sha, deploymentType: "staging", hostingService: "GitHub", // or "GitLab" repository: { name: "your-repo-name", owner: "your-org-name", }, }); if (result.outcome !== "success") { throw new Error(`Failed to notify QA Wolf: ${JSON.stringify(result)}`); } ``` Replace `GIT_COMMIT_SHA` and `GIT_BRANCH` with the environment variable names your CI system provides for the current commit SHA and branch. Replace `deploymentType` with the value your QA Wolf representative provides. Set `hostingService` to where your code is hosted — `"GitHub"` or `"GitLab"` — not where your pipeline runs. ## Run the notify script in your pipeline Add a step to your CI pipeline that runs after your deployment is healthy: ```bash theme={null} npm install @qawolf/ci-sdk node .ci/notifyQaWolf.mjs ``` ## Verify the integration Trigger your CI pipeline with a new deployment. Confirm the notify step completes without errors. Open QA Wolf and confirm a new run appears under the expected environment. ## Related * [`@qawolf/ci-sdk`](/libraries/ci-sdk/api-reference) # How to integrate with webhooks Source: https://docs.qawolf.com/other-ci-webhook Trigger QA Wolf test runs from any CI system using the deploy webhook directly. Use this guide if your CI system cannot run Node.js. This includes environments such as ArgoCD, locked-down runners, and minimal containers. If your CI system supports Node.js, use [Other CI (Node)](/other-ci-node) instead. Make sure you have: * A CI pipeline that runs after a successful deployment. * The ability to make HTTP requests from your CI environment (e.g. `curl`). * Admin access to your CI system's secret or environment variable storage. * At least one QA Wolf environment already configured. * A QA Wolf API key. ## Find the QAWOLF\_API\_KEY Open the **Workspace name** dropdown in QA Wolf and click **Workspace Settings**. Choose **Integrations** and click the icon to the right of **API Key** under **API Access**. Store the key as a secret in your CI system named `QAWOLF_API_KEY`. ## Add the deploy notification to your pipeline Add a step to your CI pipeline that runs after your deployment is healthy: ```bash theme={null} curl -X POST https://app.qawolf.com/api/webhooks/deploy_success \ -H "Authorization: Bearer $QAWOLF_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "branch": "$GIT_BRANCH", "sha": "$GIT_COMMIT_SHA", "deployment_type": "staging", "hosting_service": "GitHub" }' ``` Replace `$GIT_BRANCH` and `$GIT_COMMIT_SHA` with the environment variable names your CI system provides for the current branch and commit SHA. Replace `deployment_type` with the value your QA Wolf representative provides. Set `hosting_service` to where your code is hosted — `"GitHub"` or `"GitLab"` — not where your pipeline runs. A 200 response does not guarantee a run was created. Inspect the response body to confirm. See [webhooks/deploy\_success](/deploy-success) for full response details. ## Verify the integration Trigger your CI pipeline with a new deployment. Confirm the notify step completes without errors. Open QA Wolf and confirm a new run appears under the expected environment. ## Related * [webhooks/deploy\_success](/deploy-success) * [REST API](/rest-overview) # Integrate a mobile build with QA Wolf's REST API Source: https://docs.qawolf.com/other-web-hook-for-mobile Upload mobile build artifacts and trigger test runs from any CI system without Node.js. Use this guide if your CI system cannot run Node.js. This includes environments such as ArgoCD, locked-down runners, and minimal containers. Make sure you have: * A CI pipeline that produces a mobile build artifact (APK, AAB, or IPA). * The ability to make HTTP requests from your CI environment (e.g. `curl`). * Admin access to your CI system's secret or environment variable storage. * A QA Wolf API key. Before mobile test runs can execute, QA Wolf must enable mobile triggers for your workspace. QA Wolf will handle this and may ask you for: * Which environments you want to test. * Whether PR testing is enabled. * The artifact naming conventions you are using. Until this step is complete, CI jobs can upload artifacts and send deployment notifications, but mobile test runs will not start automatically. ## Find the QAWOLF\_API\_KEY Open the **Workspace name** dropdown in QA Wolf and click **Workspace Settings**. Choose **Integrations**. Generate your **QAWOLF\_API\_KEY** by clicking the icon to the right of **API Key** under **API Access**. Store the key as a secret in your CI system named `QAWOLF_API_KEY`. ## Artifact naming conventions Mobile build artifacts must follow consistent naming conventions so QA Wolf can correctly associate each build with the right environment. ### Static environments **Format** ```text theme={null} - ``` **Example** ```text theme={null} app-staging ``` ### PR (ephemeral) environments **Format** ```text theme={null} ---pr ``` **Example** ```text theme={null} app-myorg-myrepo-pr123 ``` QA Wolf applies the file extension (.apk, .aab, or .ipa) automatically based on the uploaded artifact. You only need to provide the basename. ## Upload the build artifact ```bash theme={null} curl "https://app.qawolf.com/api/v0/run-inputs-executables-signed-urls?file=app-staging" \ -H "Authorization: Bearer $QAWOLF_API_KEY" ``` On success, you'll receive: ```json theme={null} { "fileLocation": "$TEAM_ID/app-staging", "playgroundFileLocation": "app-staging", "signedUrl": "https://..." } ``` Copy the `playgroundFileLocation` value — you'll use it in the notify step. ```bash theme={null} curl -X PUT \ --header "Content-Type: application/octet-stream" \ --data-binary @./path/to/build.apk \ "$SIGNED_URL" ``` ## Trigger a test run After uploading the artifact, notify QA Wolf that a new deployment is ready for testing. Pass the `playgroundFileLocation` from the upload step as `RUN_INPUT_PATH`: ```bash theme={null} curl -X POST https://app.qawolf.com/api/webhooks/deploy_success \ -H "Authorization: Bearer $QAWOLF_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "deployment_type": "staging", "variables": { "RUN_INPUT_PATH": "" } }' ``` Replace `deployment_type` with the value your QA Wolf representative provides. Replace `` with the value returned in the upload step. A 200 response does not guarantee a run was created. Inspect the response body to confirm. See [webhooks/deploy\_success](/deploy-success) for full response details. If mobile triggers have not yet been enabled, this step will complete without starting a test run. ## Verify the integration Run the CI job. Verify that the artifact upload completes successfully. Confirm that the deployment notification step runs without errors. Once mobile triggers are enabled, check the **Runs** tab for the test run that was triggered. ## Troubleshooting and common issues * **If uploads succeed but no runs start:** Mobile triggers may not yet be enabled. Contact QA Wolf to complete platform configuration. * **If the artifact is not found during execution:** Verify that the artifact basename matches your naming conventions, and that `RUN_INPUT_PATH` is set to the `playgroundFileLocation` value from the upload step. * **If you see authentication errors:** Verify that **QAWOLF\_API\_KEY** is configured correctly in your CI environment. ## Related * [webhooks/deploy\_success](/deploy-success) * [v0/run-inputs-executables-signed-urls](/run-inputs-executables-signed-urls) * [Other CI (Node)](/Mobile-build-with-the-QA-Wolf-SDK) # iOS photo library management Source: https://docs.qawolf.com/photo-library-management Add, list, and delete photos in the device photo library to set up test state or verify your app saved photos. ## Examples **Save, list, and delete photos:** ```javascript theme={null} import { device } from "@qawolf/flows/ios"; // driver is provided by the flow callback when using: // launch: { bundleId: "com.apple.mobileslideshow" } // Save a photo to the library const imagePath = "/home/wolf/files/large.jpg"; await device.savePhoto(driver, imagePath); // Verify the photo was saved const photosAfterSave = await device.listPhotos(driver); expect(photosAfterSave.success).toBe(true); expect(photosAfterSave.totalCount).toBe(1); // Clean up await device.deleteAllPhotos(driver); const photosAfterDelete = await device.listPhotos(driver); expect(photosAfterDelete.success).toBe(true); expect(photosAfterDelete.totalCount).toBe(0); ``` ## When to use * Your app saves photos and you need to verify they appear in the library. * Your app reads from the photo library and you need to set up known state before the test runs. * You need to clean up photos between test runs to avoid state carrying over. `deleteAllPhotos` permanently removes all photos from the device library. Use it only on test devices. ## Full sample test ```js theme={null} import { flow, device, expect } from "@qawolf/flows/ios"; export default flow( "iOS Media - Photo management in gallery", { target: "iOS - iPhone 15 (iOS 26)", launch: { bundleId: "com.apple.mobileslideshow" }, }, async ({ driver, test }) => { await test("Photo push and save and list and delete", async () => { const baseDir = process.env.IMAGE_BASE_DIR; const fileName = "large.jpg"; const imagePath = `${baseDir}/${fileName}`; await device.savePhoto(driver, imagePath); const photosAfterPush = await device.listPhotos(driver); expect(photosAfterPush.success).toBe(true); expect(photosAfterPush.totalCount).toBe(1); await device.deleteAllPhotos(driver); const photosAfterDelete = await device.listPhotos(driver); expect(photosAfterDelete.success).toBe(true); expect(photosAfterDelete.totalCount).toBe(0); }); }, ); ``` # Coverage Mapping Source: https://docs.qawolf.com/product-mapping As the QA Wolf agent autonomously explores your application, it identifies and outlines end-to-end workflows that you should test before each release. # QA Wolf explores your app and generates test outlines The QA Wolf agent can create a comprehensive map of your application's end-to-end workflows, organized by feature or product area. The map gives teams and agents a clear view of where you have test coverage, where test coverage is missing, and where regressions most often appear. As you make changes to the application and ship new features, you can ask the agent to explore the changes and make updates to the affected tests so that your test coverage stays current. # How it works If you're using the QA Wolf platform, you'll see the agent open a browser and navigate to your application. From there, the agent will begin to explore. As the agent identifies workflows to test, it generates a plain-English outline in the AAA format. The agent has three modes: ## Autonomous exploration The agent will use computer-use models to explore each feature of your application, documenting the workflows that it discovers. ## Guided by a test plan The agent can use your existing test plans as a guide. It will replicate your existing tests, and recommend additional ones where you might be missing coverage. **Accepted filetypes** * CSV * JSON * Markdown * plain text * YAML ## Guided by you You can take control of the browser and click through a workflow yourself while the agent watches. # FAQ ## How long does it take to explore and outline an entire application? If the agent is mapping your application from scratch, it can take anywhere from 30 minutes to several hours. It all depends on the size of your application. ## How many flows does the agent generate? It depends. The agent is designed to explore your entire application. The exact number of flows it will outline depends on how complex your application is, and what kind of access the agent is given. For example, a "Member" seat might have fewer flows than an "Admin" seat in your application. ## How often does the agent explore my application? At the moment the agent only explores when prompted. We recommend you have it explore as often as once a sprint to ensure that you're maintaining comprehensive coverage. # Test QR code authentication Source: https://docs.qawolf.com/qrcode Read a QR code from the page and use its value to complete authentication. ## Examples **Read a QR code from the page** ```typescript theme={null} import { readQRCode } from "@qawolf/testkit/web"; const qrValue = await readQRCode(page, "[data-testid='qr-code']"); if (!qrValue) throw new Error("QR code was not visible"); ``` **Follow a QR code login link** ```typescript theme={null} import { readQRCode } from "@qawolf/testkit/web"; const qrValue = await readQRCode(page, "[data-testid='qr-code']"); if (!qrValue) throw new Error("QR code was not visible"); await page.goto(qrValue); ``` ## When to use * Your app displays a QR code that a user scans to authenticate * Your app uses QR codes to initiate a login session on a second device * Your flow needs to extract a URL or token encoded in a QR code * Your app generates a QR code as part of an MFA or SSO flow ## Full example ```typescript theme={null} import { flow } from "@qawolf/flows/web"; import { readQRCode } from "@qawolf/testkit/web"; export default flow( "Log in via QR code", { target: "Web - Chrome", launch: true }, async ({ page, test }) => { await test("navigate to login", async () => { await page.goto(process.env["BASE_URL"]!); }); await test("read and follow QR code", async () => { const qrValue = await readQRCode(page, "[data-testid='qr-code']"); if (!qrValue) throw new Error("QR code was not visible"); await page.goto(qrValue); }); await test("verify authenticated", async () => { await expect(page).toHaveURL(/dashboard/); }); }, ); ``` # Quick start Source: https://docs.qawolf.com/quick-start ## Install the QA Wolf MCP Recommended After clicking "Install" you'll be redirected to create an account or sign in. Use your work email when creating a new account.  If you're not automatically redirected, return to Codex and click Install again. Paste this prompt into the chat session for the project you're working on: ```text theme={null} Onboard this app to QA Wolf ``` Follow the [client-specific setup guide](https://github.com/qawolf/agent-plugins/blob/main/plugins/qawolf/skills/qawolf/references/platforms.md). ## Use the web app Use your work email when creating a new account.  You'll need to give the agent access to the application you want to test. You can send this prompt with the missing info completed: ```text theme={null} Go to {URL of your app} and sign in with username {username} and password {password} ``` Or navigate to your application through the streaming browser. If you know what test you want automated, describe it for the agent. If you want the agent to build a test suite for you, paste this prompt into the chat: ```text theme={null} Explore this application and automate a comprehensive test suite. ``` # Next The QA Wolf agent will recommend E2E tests to automate.  The QA Wolf agent will automate and validate E2E tests. Learn how to configure your workspace. # Overview Source: https://docs.qawolf.com/rest-overview The QA Wolf REST API provides direct HTTP access to core platform actions. For most use cases, [`@qawolf/ci-sdk`](/libraries/ci-sdk/api-reference) is recommended over direct endpoint access. ## Authentication All endpoints require a `QAWOLF_API_KEY` passed as a Bearer token. ```bash theme={null} Authorization: Bearer $QAWOLF_API_KEY ``` ### Kinds of API key | Kind | Acts as | Where to create it | | ------------ | ------------------------------------------------------------------ | -------------------------------------------------- | | Workspace | One workspace | **Workspace Settings → Integrations → API Access** | | Organization | The whole organization, across every workspace in it | **Workspace Settings → API Keys** | | User | The member who owns the key, across every workspace they can reach | **Workspace Settings → API Keys** | To reach either settings page, open the **Workspace Name** dropdown in QA Wolf. ### Which workspace a request acts on A workspace API key implies its workspace, so requests never name one. An organization or user API key does not, so a request that is scoped to a single workspace resolves it in this order: 1. The workspace named by a resource in the request, such as `environmentId`. 2. The workspace named by `workspaceId`. 3. Your organization's first-created workspace. Creating another workspace never changes step 3, so a pipeline that relies on it keeps running. Even so, pass `workspaceId` when your organization has more than one workspace: it states which workspace you meant, and it keeps working if the first-created workspace is ever deleted or disabled. Copy the ID from **Workspace Settings → API Keys**. ## Base URL ```text theme={null} https://app.qawolf.com ``` ## Endpoints | Endpoint | Method | Description | | ----------------------------------------------------------------------------------- | ------------- | ----------------------------------------------------------------- | | [`/api/webhooks/deploy_success`](/deploy-success) | `POST` | Notify QA Wolf of a successful deployment to trigger a run. | | [`/api/webhooks/environment_terminated`](/environment_terminated) | `POST` | Notify QA Wolf that an ephemeral environment has been terminated. | | [`/api/v0/ci-greenlight/{root-run-id}`](/v0-ci-greenlight) | `GET` | Poll for the outcome of a run to gate a pipeline. | | [`/api/v0/run-inputs-executables-signed-urls`](/run-inputs-executables-signed-urls) | `GET` + `PUT` | Generate a signed URL and upload an executable file. | # Add skills the AI reuses across sessions Source: https://docs.qawolf.com/reusable-ai-knowledge Package knowledge about your app in SKILL.md files that the AI loads in every session. Prompts teach the AI about your app one session at a time. A skill makes that knowledge permanent. QA Wolf loads SKILL.md files in every autonomous creation, guided creation, and outlining session, so the AI starts with your context instead of asking you to repeat it. These are skills stored in your QA Wolf workspace for QA Wolf's AI. To install QA Wolf skills in an external coding agent, use the [coding-agent plugin guide](/coding-agents/get-started). ## What a skill looks like A skill is a Markdown file with two parts: frontmatter that names and describes it, and a body with the knowledge itself. ```markdown theme={null} --- name: acme-conventions description: Domain terms and test conventions for the Acme app --- # Logging in Always sign in through the SSO tile. The email and password form is deprecated and fails for most test users. # Domain terms - A "quote" is a rate quote, not a chat message. - "Members" and "subscribers" are the same object in the UI. # Conventions - Structure every flow header around Arrange, Act, Assert. - Prefer unique generated values over hardcoded ones for new records. ``` ## What belongs in a skill Good skill content is anything you would otherwise repeat across many prompts: * Domain vocabulary and what each term maps to in the UI * Environment quirks, such as which login method works and which test users are seeded * Team conventions, such as [structuring flow headers around AAA](/test-automation#structure-the-flow-header-around-arrange-act-assert) * Links to deeper documentation the AI should fetch when it becomes relevant Keep each skill focused on one topic. Knowledge that matters to a single flow belongs in that flow's prompt or header, not in a skill. ## Where skills live A skill can live anywhere in your workspace. The best practice is to give each SKILL.md its own folder: ```text theme={null} your-workspace/ ├── skills/ │ ├── acme-conventions/ │ │ └── SKILL.md │ └── payments-testing/ │ └── SKILL.md ├── src/ │ ├── flows/ │ └── pages/ ├── package.json └── tsconfig.json ``` ## How each surface uses skills **Autonomous creation.** The quality of an unattended run depends on the context you front-load. A skill carries the standing context, so your prompt only needs to describe the flow itself. **Guided creation.** You stop re-explaining the same background at every step. The AI already knows your domain terms and conventions, so short prompts land correctly. **Outlining.** The agent explores your app already knowing what the features are called and which areas matter, which produces more accurate flow stubs. # Reuse state between runs Source: https://docs.qawolf.com/reuse-state-between-runs Use environment variables to carry state from one run into the next, so flows can skip work they've already done. ## Examples **Save a value for later runs** ```typescript theme={null} import { saveEnvironmentVariable } from "@qawolf/testkit"; await saveEnvironmentVariable("SESSION_TOKEN", token); ``` **Read a value saved by a previous run** ```typescript theme={null} import { reloadEnvironmentVariables } from "@qawolf/testkit"; await reloadEnvironmentVariables(); const sessionToken = process.env["SESSION_TOKEN"]; ``` ## When to use * Your app requires a login or setup step that produces a value other flows need in future runs. * A flow generates a token, ID, or credential that should persist across runs. * You want to skip repeated setup work by reusing state from a previous run. * Your test environment requires a value that changes at runtime and needs to be available to later runs. ## Full example ```typescript theme={null} import { flow } from "@qawolf/flows/web"; import { reloadEnvironmentVariables, saveEnvironmentVariable } from "@qawolf/testkit"; export default flow( "Login and save session token", { target: "Web - Chrome", launch: true }, async ({ page, test }) => { await reloadEnvironmentVariables(); if (!process.env["SESSION_TOKEN"]) { await test("log in and save session token", async () => { await page.goto(process.env["BASE_URL"]!); await page.fill('[name="email"]', process.env["EMAIL"]!); await page.fill('[name="password"]', process.env["PASSWORD"]!); await page.click('[type="submit"]'); const token = await page.evaluate(() => localStorage.getItem("session_token") ); await saveEnvironmentVariable("SESSION_TOKEN", token!); }); } }, ); ``` # v0/run-inputs-executables-signed-urls Source: https://docs.qawolf.com/run-inputs-executables-signed-urls Generate a signed URL and upload an executable file to QA Wolf. `GET https://app.qawolf.com/api/v0/run-inputs-executables-signed-urls` QA Wolf only accepts the following file types: `.apk`, `.aab`, `.deb`, `.ipa`, `.zip`, `.csv`, `.pdf` ```bash theme={null} curl "https://app.qawolf.com/api/v0/run-inputs-executables-signed-urls?file=$DESTINATION_FILE_PATH" \ -H "Authorization: Bearer $QAWOLF_API_KEY" ``` Returns a `signedUrl` for the next step, plus `fileLocation` and `playgroundFileLocation` — see below for the full parameter and response reference. `PUT {signedUrl}` The previous request returns the signed URL. It is not a fixed `/api/` endpoint — it points directly to Google Cloud Storage. ```bash theme={null} curl -X PUT \ --header "Content-Type: application/octet-stream" \ --data-binary @some_file.zip \ $SIGNED_URL ``` ## Query parameters | Parameter | Description | | --------- | -------------------------------------------------------------------------------------- | | `file` | Destination file path. At minimum the filename and extension. May include directories. | ## Response ```json theme={null} { "fileLocation": "$TEAM_ID/$DESTINATION_FILE_PATH", "playgroundFileLocation": "$DESTINATION_FILE_PATH", "signedUrl": "https://..." } ``` | Field | Description | | ------------------------ | ---------------------------------------------------------------------------------- | | `fileLocation` | Full path including team ID. Use this to reference the file in run configurations. | | `playgroundFileLocation` | Path without team ID. Use this to reference the file in the playground. | | `signedUrl` | Pre-signed URL for uploading the file. Use in the next step. | ## Response codes | Code | Description | | ----- | ------------------------------------------------------------- | | `200` | Success. | | `401` | Missing or invalid API key. | | `403` | Forbidden. Usually indicates a disabled workspace. | | `500` | Internal server error. Contact support if the issue persists. | ## Related * [Upload files](/Uploading-manually) * [`@qawolf/ci-sdk`](/libraries/ci-sdk/api-reference) * [Notify deploy](/deploy-success) # Smart Smoke Suites Source: https://docs.qawolf.com/smart-smoke-suites Let QA Wolf automatically select the flows affected by your PR. A Smart Smoke Suite is a targeted selection of flows identified by the QA Wolf agent based on the description of your PR, giving engineers a faster release signal in the review cycle. Smart Smoke Suites are designed to layer onto your existing coverage strategies. Combined with tags and branch rules, Smart Smoke Suites provide another way to customize your runs in a way that balances speed and coverage. Smart Smoke Suites is currently in alpha and not available to all customers. Early customers running it in production work closely with QA Wolf to validate the experience and tune selection quality. # How it works 1. QA Wolf reads the PR title and description from GitHub. 2. The agent determines which of your flows to run based on the features affected. 3. Selected flows are run in a preview environment. 4. Results post back through the platform, CLI, Slack, etc. # Requirements * **QA Wolf GitHub App** installed in the target repository. * **Stable preview environments** for PRs, with deployments registered as GitHub Deployments or notified to QA Wolf via the deployment notification action. Once configured, every new preview deployment automatically starts a targeted test run. Enabling and disabling is a single toggle on the trigger, so you can turn it off instantly to revert to standard PR testing. # FAQ ## How does QA Wolf decide which flows to run? The QA Wolf agent builds Smart Smoke Suites based on the PR title and description. ## Can we add specific tests to always run on PRs? Yes. If you've configured your run settings to include specific flows whenever a PR is merged, those will run concurrently alongside any others selected by the agent as part of your Smart Smoke Suite. ## Do we have to write new flows specifically for Smart Smoke Suites? No. The agent will select from the flows you already have. # Solutions Source: https://docs.qawolf.com/solutions Recipes and helpers for testing specific scenarios in your app. Send, receive, and verify emails and SMS messages inside your test flows. Use OTPs & QR codes to authenticate. Test network conditions and connectivity. Coordinate state and data across multiple users, sessions, and devices. Inject audio and capture speaker output into mobile test flows. Inject photos, camera feeds, barcodes, and video into mobile test flows. Validate accessibility compliance for web and native apps. Measure and assert on web performance metrics inside your flows. Catch unintended visual regressions across web and mobile web. Validate AI-generated responses and assert on quality, accuracy, and token usage. Test Electron applications. Mock geolocation and other mobile device sensors. # Configure Single Sign-On (SSO) Source: https://docs.qawolf.com/sso Set up SSO via SAML 2.0 and OpenID Connect # Requirements The administrator configuring SSO must be able to: * Verify your organization domain, such as by adding a TXT record. * Create applications in the identity provider. * Configure SAML or OIDC authentication settings. * Assign users or groups to applications. # Set up ### Verify your email domain SSO Admin permissions are granted by Membership Managers.  Add domains under **Workspace Settings.** Configuration values are listed in the **SSO settings page**. / # Troubleshooting ## I do not see the SSO setup page Confirm that: * You have been granted the **SSO Admin** role by a user with the **Membership Manager** role. * You signed out of QA Wolf and signed back in after your role was updated. ## A user cannot sign in Check that: * The user is assigned to the QA Wolf application. * The user is signing in with the correct email address. * The SSO configuration has been saved. ## Authentication fails Verify that the following values match between QA Wolf and your identity provider: * ACS URL * Audience / entity ID * Issuer * Login URL * Certificate ## Users are not redirected back to QA Wolf Ensure the redirect or callback URL configured in your identity provider matches the value shown in the QA Wolf SSO settings page. # Tags Source: https://docs.qawolf.com/tags Use tags to create custom suites for nightly runs, smoke suites, etc. # How tags work The primary purpose of tags is to create custom suites — subsets of flows that run according to [Run Rules](/Run-Rules) or schedules that you define. Tags exist at the Workspace level, can be applied to any flow, and are available to all users in your workspace. # FAQ ## What tags should I create? Common tags include test type (e.g., *sanity* or *smoke*) and expected duration (e.g., *long-running*). Since QA Wolf runs all tests in parallel by default, you do not need to use tags to shard your flows. # Test Automation Source: https://docs.qawolf.com/test-automation The QA Wolf agent automates E2E tests in Playwright (for web apps) and Appium (for iOS and Android apps). AI flow creation lets you create, edit, and debug flows by describing what you want in plain language, typed or spoken — you don't need to know the flow syntax to get started. To import a third-party library, just ask the agent for the package you need; it handles the process automatically. This covers AI creation inside QA Wolf. To create flows from Claude Code, Codex, or another coding agent, see [QA Wolf plugin](/coding-agents/get-started). It works best when you give the agent enough context to understand what you want and where to put it. It supports two ways of working: No human in the loop. Front-load all the context into one prompt, hit run, and review the finished flow. A copilot workflow. Prompt for one small change at a time, discovering the app together with the AI. Both modes benefit from context you write down once. If you find yourself repeating the same background across sessions, put it in a [SKILL.md](/reusable-ai-knowledge). ## Autonomous creation In this mode the creation agent works unattended, so everything it needs to know has to be in the prompt before you hit run. ### Write a specific prompt Vague prompts produce unreliable results. A prompt like "Create a test for the rate quote flow" gives the agent very little to work with. A stronger prompt includes: * Where to start (URL and login credentials) * What the user does, step by step * What a successful outcome looks like * Anything specific you want asserted **Example:** ```handlebars wrap theme={null} Create a test for the rate quote application. Go to {{your app URL}} and sign in with {{test user email}} and {{test user password}}. Navigate to the rate quote form, fill in the required fields, and submit. Assert that the confirmation message appears and that the new rate quote is visible in the list. ``` ### Include assertions explicitly If there's something specific you need verified, say so. The agent won't always infer what matters to you — telling it what to assert produces more useful tests. ### Link your app's documentation If a flow depends on setup that is only explained in your documentation, include the link in the flow description or your prompt. The AI reads linked documentation before it starts building and uses it to complete configuration steps it cannot discover from the UI alone. ```handlebars wrap theme={null} Create a test for connecting a Jira project. Follow the setup guide at {{your docs URL}} to configure the integration first. ``` ### Structure the flow header around Arrange, Act, Assert When the agent completes a flow on its own, the comment block at the top of the flow is its specification. Structuring that header around [Arrange, Act, Assert](/anatomy-of-a-qa-wolf-test-mobile-edition#aaa-framework) tells the agent how far each verification should go. For example, a flow that disables a feature could reload the page and check that the toggle is still off. It could also go further and sign in as a different user to verify the feature is no longer accessible. An AAA header states which one you want, so the agent doesn't have to guess: ```typescript theme={null} /** * Goal: Editor project transfers * * Test 1: * Arrange: Log in as an enterprise owner and an enterprise editor. As the * owner, enable editor project transfers in workspace settings under * Privacy and Security. * Act: As the editor, open the owner's project and go to project settings. * Assert: The Move button is enabled. * * Test 2: * Arrange: As the owner, disable editor project transfers. * Act: As the editor, reload project settings. * Assert: The Move button is disabled; check the tooltip content. */ ``` ### Use descriptive flow names Before building a new flow, the AI searches your workspace for similar flows and reuses the parts that overlap instead of starting from scratch. Descriptive names help it find the right ones. If you want it to work from a specific example, [point it at a working reference](#point-the-agent-at-a-working-reference). *** ## Guided creation In this mode you stay in the loop, hand-holding the creation agent to completion. Use it when you don't have the full context yourself and are discovering the app at the same time as the AI, so it makes sense to work the problem together. ### Start prompts with "Insert code to" When adding code at a specific point in a test, beginning your prompt with "Insert code to" helps the agent understand it should add rather than replace. ```text theme={null} Insert code to click the Submit button and wait for the confirmation message. ``` ### Control where code is inserted The agent inserts code relative to the current cursor position. If code ends up in the wrong place: * Click on the line where you want the code inserted before prompting * Or tell it explicitly: "Insert code on line 42 to..." ### Describe elements in plain language You don't need to tell the agent which Playwright locator to use. It's better at finding the most relevant locator itself. If you prescribe a locator and it turns out not to work, the agent gets stuck between obeying your instruction and producing a working test, and the result suffers. Say *"Click the Save button in the billing settings panel"*, not *"Click `page.locator('#btn-save-2')`"*. ### Hover before you prompt for elements When targeting an element inside a container — for example, the first link within a specific panel — hover over the container for a second, then click the element. This gives the agent locators for both, and you can ask it to chain them: ```text theme={null} Click the first link within the Recent Activity panel. ``` If you hover only on the element itself, the agent may use a less precise locator. ### Trigger actions before prompting For interactions that produce a result (a download, a new page, a modal), trigger the action yourself first and let it complete. Then tell the agent what happened. This helps it understand the outcome, not only the selector. **Example:** 1. Click the download button and wait for the download to start 2. Then prompt: "Click the download button" ### Use descriptive language for context A few phrases that help the agent understand what it's working with: * **"in the row"** — when an element is inside a table row * **"file"** — for file-related actions like clicking download or handling file inputs ### Refactor whole-file changes For changes that apply across the entire test — updating all comments, removing hardcoded values, renaming variables — use "refactor" to signal that the change is global: ```text theme={null} Refactor to replace all hardcoded email addresses with the TEST_USER_EMAIL environment variable. ``` ### Move code by selecting it first If you need the agent to move a block of code, select all the lines you want moved before prompting. This removes ambiguity about what should be relocated. ### Fix errors directly If a run produces an error, you can ask the agent to address it without explaining the cause: ```text theme={null} Fix the error. ``` The agent will read the error output and attempt a fix. ### Point the agent at a working reference If you have a working example that you want the agent to follow, give it the path to that file and tell it what you'd like it to copy. ```handlebars theme={null} Use the new apiLogin method as exemplified in this file: {{path to file}} ``` This will prevent having to re-describe the change each time. # Test MFA with an authenticator app Source: https://docs.qawolf.com/test-mfa Generate a time-based OTP code in your flow to complete multi-factor authentication. Store your authenticator app's OTP URI as a QA Wolf environment variable named `OTP_URI` before running this flow. ## Examples **Complete MFA during login** ```typescript theme={null} import { otp } from "@qawolf/testkit"; const code = otp.fromUri(process.env["OTP_URI"]!); await page.getByLabel("Verification code").fill(code); ``` ## When to use * Your app requires a time-based one-time password (TOTP) to complete login * Your app prompts for an MFA code after username and password are accepted * Your flow needs to authenticate as a user who has MFA enabled * Your test environment enforces MFA and cannot be bypassed for test accounts ## Full example ```typescript theme={null} import { flow } from "@qawolf/flows/web"; import { otp } from "@qawolf/testkit"; export default flow( "Log in with MFA", { target: "Web - Chrome", launch: true }, async ({ page, test }) => { await test("log in", async () => { await page.goto(process.env["BASE_URL"]!); await page.fill('[name="email"]', process.env["EMAIL"]!); await page.fill('[name="password"]', process.env["PASSWORD"]!); await page.click('[type="submit"]'); }); await test("complete MFA", async () => { const code = otp.fromUri(process.env["OTP_URI"]!); await page.getByLabel("Verification code").fill(code); await page.click('[type="submit"]'); }); }, ); ``` # v0/ci-greenlight Source: https://docs.qawolf.com/v0-ci-greenlight Returns the outcome of a run for use as a pipeline gate. `GET https://app.qawolf.com/api/v0/ci-greenlight/{root-run-id}` If your build server supports `node`, use [`@qawolf/ci-sdk`](/libraries/ci-sdk/api-reference) instead of calling this endpoint directly. ## Request ```bash theme={null} curl https://app.qawolf.com/api/v0/ci-greenlight/ \ -H "Authorization: Bearer $QAWOLF_API_KEY" ``` ## Request headers | Header | Value | | --------------- | ----------------------- | | `Authorization` | `Bearer ` | ## Response | Field | Description | | ---------------------------------- | ------------------------------------------------------------------------------------ | | `greenlight` | `true` when `runStage` is `"completed"` and there are zero blocking bugs. | | `relevantRunId` | ID of the most recent superseding run, if one exists. Otherwise matches `rootRunId`. | | `relevantRunUrl` | URL to the relevant run in QA Wolf. | | `relevantRunWithBugsUrl` | URL to the relevant run filtered to workflows diagnosed as bugs. | | `rootRunId` | ID of the run passed in the request. | | `rootRunUrl` | URL to the root run in QA Wolf. | | `runStage` | One of `"initializing"`, `"underReview"`, `"completed"`, `"canceled"`. | | `workflowsDisabledAfterRunCount` | Number of workflows disabled after review. | | `workflowsInRunCount` | Total number of workflows in this run. | | `workflowsUnderInvestigationCount` | Number of failed workflows requiring investigation. | ### Conditional fields Present when `runStage` is `"underReview"` or `"completed"`, except where noted. | Field | Description | | ---------------------------- | ----------------------------------------------------------------------------------------- | | `blockingBugsCount` | Number of blocking bugs found after review. Present when `runStage` is `"completed"`. | | `nonBlockingBugsCount` | Number of non-blocking bugs found after review. Present when `runStage` is `"completed"`. | | `workflowsAutoRetryingCount` | Number of workflows still auto-retrying. Present when `runStage` is `"underReview"`. | | `blockingBugUrls` | Array of URLs to blocking bugs. | | `nonBlockingBugUrls` | Array of URLs to non-blocking bugs. | | `reproducedBugs` | Array of detailed bug objects. | ## Interpreting `greenlight` `greenlight` is `true` only when: * `runStage` is `"completed"`, and * There are zero blocking bugs. A bug is blocking if its priority is `"high"` or unset. Newly found bugs are unset by default and count as blocking. You can lower a bug's priority in the QA Wolf UI to make it non-blocking, then retry the greenlight poll. ## Run stages | Stage | Description | | -------------- | --------------------------------------------------------------------------- | | `initializing` | Run is starting up. | | `underReview` | Run is in progress and under review. | | `completed` | Run has finished. | | `canceled` | Run was canceled. Polling should stop — a canceled run cannot be recovered. | ## Query parameters | Parameter | Values | Description | | ----------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `outcomeWhenBlockingBugsInOtherWorkflows` | `green` \| `red` | Defaults to `green`. When set to `red`, `greenlight` will be `false` if workflows not executed in this run have blocking bugs. Experimental. | ## Superseding logic If a newer run has superseded the root run (identified by matching `deduplication_key`), this endpoint returns the greenlight status for the most recent superseding run. When `relevantRunId` differs from `rootRunId`, you are seeing the superseding run's status. When polling, use `relevantRunId` rather than `rootRunId` for faster queries. ## Response codes | Code | Description | | ----- | ------------------------------------------------------------- | | `200` | Success. | | `401` | Missing or invalid API key. | | `403` | Forbidden. Usually indicates a disabled workspace. | | `404` | Run not found. | | `405` | Method not allowed. Use `GET`. | | `410` | Gone. Returned for old or legacy runs. | | `500` | Internal server error. Contact support if the issue persists. | ## Related * [`@qawolf/ci-sdk`](/libraries/ci-sdk/api-reference) * [Notify deploy](/deploy-success) # Video injection Source: https://docs.qawolf.com/video-injection Inject video into your app's camera input to test video features without real hardware. Video injection is only available for apps that QA Wolf resigns during installation. It is not available for system apps or Safari. ## Example **Inject a video into the camera feed:** ```javascript theme={null} import { device } from "@qawolf/flows/ios"; const bundleId = process.env.BUNDLE_ID; // Bundle ID of app being tested const storagePath = process.env.STORAGE_PATH; // QA Wolf remote storage const videoPath = `${storagePath}/wolf.mp4`; const cleanup = await device.injectCamera(driver, bundleId, { data: videoPath, type: "video", // optional — inferred from .mp4/.mov/.m4v/.avi }); // ... run your assertions while the camera feed plays the video ... await cleanup(); ``` ## When to use * Your app records video and you need to verify the recording pipeline with deterministic content. * You need to run the same scenario repeatedly with consistent inputs. ## Supported file types **Video:** Any format supported by AVAsset — MP4, MOV, M4V with H.264/HEVC codecs ## Full sample test ```js theme={null} import { flow, device, expect } from "@qawolf/flows/ios"; export default flow( "iOS Media - Camera Video Injection", { target: "iOS - iPhone 15 (iOS 26)", launch: { app: { env: "IPA_APP_BUILD" }, respectSystemAlerts: true, autoAcceptAlerts: true, }, }, async ({ driver, test }) => { await test("iOS Media - Camera Video Injections", async () => { //-------------------------------- // Arrange: //-------------------------------- // Install and Launch Trot app // Tap "Media" await driver .$( `-ios predicate string:name == 'Media' AND type == 'XCUIElementTypeButton'`, ) .waitForDisplayed(); await driver .$( `-ios predicate string:name == 'Media' AND type == 'XCUIElementTypeButton'`, ) .click(); // Tap Video Recording await driver .$( `-ios predicate string:name == 'Video Recording' AND type == 'XCUIElementTypeButton'`, ) .waitForDisplayed(); await driver .$( `-ios predicate string:name == 'Video Recording' AND type == 'XCUIElementTypeButton'`, ) .click(); // Tap AVCaptureMovieFileOutput await driver .$( `-ios predicate string:name == 'AVCaptureMovieFileOutput' AND type == 'XCUIElementTypeButton'`, ) .waitForDisplayed(); await driver .$( `-ios predicate string:name == 'AVCaptureMovieFileOutput' AND type == 'XCUIElementTypeButton'`, ) .click(); // Observe "Start Recording" button await driver .$( `-ios predicate string:name == 'unifiedRecordButton' AND type == 'XCUIElementTypeButton'`, ) .waitForDisplayed({ timeout: 10000 }); //-------------------------------- // Act: //-------------------------------- // Inject video as the camera feed const storagePath = process.env.STORAGE_PATH await device.injectCamera(driver, "com.qawolf.trot", { data: `${storagePath}/wolf.mp4`, type: "video", }); await driver.pause(3000); //-------------------------------- // Assert: //-------------------------------- // Assert Screenshot of Image File Displaying in Live Preview const previewElement = driver.$( `-ios predicate string:name == 'livePreview' AND type == 'XCUIElementTypeOther'`, ); await previewElement.waitForDisplayed({ timeout: 5000 }); await expect(driver) .toHaveScreenshot( previewElement, "video_recording_video_preview", { maxMisMatchPercentage: 5 }, ); // Click start recording await driver .$( `-ios predicate string:name == 'unifiedRecordButton' AND type == 'XCUIElementTypeButton'`, ) .click(); await driver.pause(3000); // Click stop recording await driver .$( `-ios predicate string:name == 'unifiedRecordButton' AND type == 'XCUIElementTypeButton'`, ) .click(); // Observe a recording await driver .$( `-ios predicate string:name BEGINSWITH 'AVCaptureMovieFileOutput_' AND type == 'XCUIElementTypeStaticText'`, ) .click(); }); }, ); ``` # Assert visual appearance Source: https://docs.qawolf.com/visual-diffing Use toHaveScreenshot to catch layout regressions and verify UI elements that can't be targeted with selectors. This page covers web only, including mobile web using Playwright emulators. To perform a visual comparison, use `toHaveScreenshot`. ```js theme={null} toHaveScreenshot(name, options) ``` * `name` — string used to identify and store the baseline image * `options` — optional object controlling comparison behavior, see Key options below ## Examples **Assert a canvas element:** ```js theme={null} await expect( page.locator(`[class*="chart-canvas"]`) ).toHaveScreenshot( `revenue-chart`, { maxDiffPixelRatio: 0.01, timeout: 60 * 1000, } ); ``` **Assert a specific UI component:** ```js theme={null} await expect( page.locator(`[class*="nav-header"]`) ).toHaveScreenshot( `nav-header`, { maxDiffPixelRatio: 0.01, timeout: 60 * 1000, } ); ``` **Assert a full page:** ```js theme={null} await expect(page).toHaveScreenshot( `checkout-page`, { fullPage: true, maxDiffPixelRatio: 0.01, timeout: 60 * 1000, } ); ``` Omitting a name and `maxDiffPixelRatio` makes the baseline hard to identify and causes minor rendering differences to fail the test. ```js theme={null} // Avoid await expect(page.locator(`[class*="chart-canvas"]`)).toHaveScreenshot(); ``` ## When to use * Your test needs to assert the content or appearance of a `canvas` element — canvas is not part of the DOM and cannot be targeted with locators. * You want to catch layout or style regressions after a redesign or component change. * No selector or functional assertion can verify the visual state you need. * Your app has pixel-perfect design requirements, and you want automated enforcement. Avoid `toHaveScreenshot` when the area contains timestamps, live data, animations, or other frequently-changing content — these cause false failures. Use a functional assertion instead. ## Key options | Option | Description | Recommended | | ------------------- | ------------------------------------------ | ----------- | | `maxDiffPixelRatio` | Fraction of pixels allowed to differ (0–1) | `0.01` | | `maxDiffPixels` | Absolute pixel count allowed to differ | Situational | | `fullPage` | Capture the full scrollable page | `false` | | `timeout` | Max milliseconds to wait for a match | `60000` | ## Notes * On the first run, no baseline exists yet — the screenshot is saved automatically as the expected image. See the instructions below to review it before relying on the test. * To inspect a visual diff result in the editor, collapse the test block — an icon will appear in the right gutter of that line. Click it to open the Image Diff panel, where you can view the **Diff**, **Expected**, **Actual**, and **Compare** tabs for each named snapshot. * On subsequent runs, a new screenshot is compared pixel-by-pixel to the baseline. If the difference exceeds the threshold, the test fails, and a diff image is saved showing exactly which pixels changed. * If a UI change is intentional, promote the new screenshot as the updated baseline in the QA Wolf editor. Future runs will compare against the new image. Visual Diffing 1 ## Full sample test ```js theme={null} import { expect, flow } from "@qawolf/flows/web"; export default flow( "Landing Page Visual Tests - Desktop", { target: "Web - Chrome", launch: true }, async ({ page, test }) => { await test("Schedule Demo Visual Test", async () => { //-------------------------------- // Arrange: //-------------------------------- //! Opening the home page of QA Wolf //!! navigate to the "https://www.qawolf.com/" await page.goto("https://www.qawolf.com/"); //!! wait for loadstate to be "domcontentloaded" await page.waitForLoadState("domcontentloaded"); //-------------------------------- // Assert: //-------------------------------- //! Check that certain elements on the home page match the expected screenshots //!! expect the screen to match "homepage-web" await expect(page).toHaveScreenshot( `homepage-web`, { fullPage: true, maxDiffPixelRatio: 0.01, timeout: 60 * 1000, } ); }); }, ); ``` # Native mobile screenshots Source: https://docs.qawolf.com/visual-diffing-native-app Use toHaveScreenshot to catch visual regressions in native iOS and Android apps. Use `expect(driver).toHaveScreenshot()` to compare a screenshot of the current native app screen against a stored baseline. This works for both iOS and Android. For web and mobile web visual diffing, see [Web & Mobile web screenshots](/visual-diffing). ```typescript theme={null} expect(driver).toHaveScreenshot(name, options) ``` * `name` — string used to identify and store the baseline image * `options` — optional object controlling comparison behavior, see Key options below ## Examples **iOS** ```typescript theme={null} await expect(driver).toHaveScreenshot( "home-screen", { maxMisMatchPercentage: 1 } ); ``` **Android** ```typescript theme={null} await expect(driver).toHaveScreenshot( "home-screen", { maxMisMatchPercentage: 1 } ); ``` **Assert a specific element** Pass a WebdriverIO element instead of `driver` to scope the screenshot to a specific UI component: ```typescript theme={null} await expect( driver.$(`//android.widget.ImageView[@resource-id="com.example.app:id/hero_image"]`) ).toHaveScreenshot( "hero-image", { maxMisMatchPercentage: 1 } ); ``` Omitting a name and `maxMisMatchPercentage` makes the baseline hard to identify and causes minor rendering differences to fail the test. ```typescript theme={null} // Avoid await expect(driver).toHaveScreenshot(); ``` ## When to use * Your app has a rendering-heavy screen like a chart or map that can't be asserted with selectors. * Your app has pixel-perfect design requirements and you want automated enforcement. * Your app has gone through a redesign and you need to catch layout regressions. * Your test needs to verify a visual state that no functional assertion can cover. Avoid `toHaveScreenshot` when the screen contains timestamps, live data, animations, or other frequently-changing content — these cause false failures. Use a functional assertion instead. ## Key options | Option | Description | Recommended | | ----------------------- | ---------------------------------------------- | ----------- | | `maxMisMatchPercentage` | Percentage of pixels allowed to differ (0–100) | `1` | | `skipDiffUI` | Skip the visual diff UI in the editor | `false` | `maxMisMatchPercentage` is a 0–100 percentage, unlike the web API's `maxDiffPixelRatio` which is a 0–1 ratio. ## Notes * On the first run, no baseline exists yet — the screenshot is saved automatically as the expected image. Review it before relying on the test. * To inspect a visual diff result in the editor, collapse the test block — an icon will appear in the right gutter of that line. Click it to open the Image Diff panel, where you can view the **Diff**, **Expected**, **Actual**, and **Compare** tabs for each named snapshot. * On subsequent runs, a new screenshot is compared pixel-by-pixel to the baseline. If the difference exceeds the threshold, the test fails and a diff image is saved showing exactly which pixels changed. * If a UI change is intentional, promote the new screenshot as the updated baseline in the QA Wolf editor. Future runs will compare against the new image. * On iOS, baselines are named by device screen size (e.g. `home-screen-ios-390×844-expected.png`) — a baseline captured on an iPhone 15 will not match an iPhone 15 Plus. * Baselines are stored in team storage under `_screenshots_` and are shared across all flows in the same team. ## Full sample test ```typescript theme={null} import { expect, flow } from "@qawolf/flows/ios"; export default flow( "Visual regression — home screen", { target: "iOS - iPhone 15 (iOS 26)", launch: true }, async ({ driver, test }) => { await test("assert home screen appearance", async () => { await expect(driver).toHaveScreenshot( "home-screen", { maxMisMatchPercentage: 1 } ); }); }, ); ``` # Audio analysis (Web) Source: https://docs.qawolf.com/web-audio-analysis Validate audio streams and files using ffprobe and ffmpeg to assert on codec, bitrate, duration, and silence. Use `ffprobe` and `ffmpeg` via `execa` to inspect audio streams and downloaded files during a flow. Both tools are pre-installed on QA Wolf runners. See the [ffprobe documentation](https://ffmpeg.org/ffprobe.html) and [ffmpeg documentation](https://ffmpeg.org/ffmpeg.html) for the full list of available flags. ## Examples **Inspect a stream URL** ```typescript theme={null} const { execa } = await import("execa"); const streamUrl = await page.locator("audio").getAttribute("src"); const { stdout } = await execa("ffprobe", [ "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "-i", streamUrl, ]); const metadata = JSON.parse(stdout); const audioStream = metadata.streams.find((s) => s.codec_type === "audio"); expect(audioStream.codec_name).toBe("aac"); expect(parseInt(metadata.format.bit_rate) / 1000).toBeGreaterThan(100); ``` **Detect silence in a downloaded file** ```typescript theme={null} const { execa } = await import("execa"); const { stderr } = await execa("ffmpeg", [ "-i", filePath, "-af", "silencedetect=n=-50dB:d=1", "-f", "null", "-", ]); if (stderr.includes("silence_start")) { throw new Error("Audio file contains silence."); } ``` **Assert file metadata** ```typescript theme={null} const { execa } = await import("execa"); const { stdout } = await execa("ffprobe", [ "-v", "error", "-show_entries", "format=bit_rate,duration", "-show_streams", "-of", "json", filePath, ]); const metadata = JSON.parse(stdout); const audioStream = metadata.streams.find((s) => s.codec_type === "audio"); expect(audioStream.codec_name).toBe("aac"); expect(parseInt(metadata.format.bit_rate) / 1000).toBeGreaterThan(100); ``` ## When to use * Your app streams audio and you need to assert on codec, bitrate, or sample rate. * Your app generates or downloads audio files and you need to verify they aren't silent or corrupted. * Your app plays back recordings and you need to confirm the file contains valid audio data. * Your test needs to validate audio properties that can't be asserted through the UI. ## Full sample test ```typescript theme={null} import { flow, launch } from "@qawolf/flows/web"; export default flow( "Validate audio stream", "Web - Chrome", async ({ page, test }) => { await test("navigate to player and assert stream metadata", async () => { // Arrange await page.goto("https://your-app.com/player"); await page.locator("audio").waitFor({ state: "attached" }); // Act const { execa } = await import("execa"); const streamUrl = await page.locator("audio").getAttribute("src"); const { stdout } = await execa("ffprobe", [ "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "-i", streamUrl, ]); // Assert const metadata = JSON.parse(stdout); const audioStream = metadata.streams.find((s) => s.codec_type === "audio"); expect(audioStream).toBeTruthy(); expect(audioStream.codec_name).toBe("aac"); expect(parseInt(metadata.format.bit_rate) / 1000).toBeGreaterThan(100); }); }, ); ``` # Microphone injection (Web) Source: https://docs.qawolf.com/web-audio-injection Replace the browser's microphone input with an audio file to test voice and speech features in web apps. Use Playwright's fake media device flags to inject a pre-recorded audio file as the browser's microphone input. This lets you test voice-activated features, speech recognition, and audio processing in web apps under controlled, repeatable conditions. WAV format is recommended. Store your audio file in team storage and reference it via `process.env.TEAM_STORAGE_DIR`. See [Upload files](/Uploading-manually) for instructions. Playwright initializes the fake audio device once per browser instance. You cannot swap the audio file mid-flow. If you need to test with different audio inputs, launch a new browser instance for each. ## Examples **Inject audio into the browser microphone** ```typescript theme={null} const { context } = await launch({ permissions: ["microphone"], args: [ "--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream", `--use-file-for-fake-audio-capture=${process.env.TEAM_STORAGE_DIR}/voice-input.wav`, ], }); const page = await context.newPage(); await page.goto("https://your-app.com"); // trigger the feature that reads from the microphone ``` ## When to use * Your app has speech recognition and you need to test that specific voice commands are interpreted correctly. * Your app has voice-activated controls and you need to verify they produce the expected behavior. * Your app processes or transforms microphone input and you need to assert on the output. * Your app has voice-controlled accessibility features and you need to confirm they work as expected. ## Full sample test ```typescript theme={null} import { flow, launch } from "@qawolf/flows/web"; export default flow( "Test voice input", "Web - Chrome", async ({ test }) => { const { context } = await launch({ permissions: ["microphone"], args: [ "--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream", `--use-file-for-fake-audio-capture=${process.env.TEAM_STORAGE_DIR}/voice-input.wav`, ], }); const page = await context.newPage(); await test("inject audio and verify voice command response", async () => { // Arrange await page.goto("https://your-app.com/voice"); await page.locator(`[data-testid='mic-button']`).waitFor({ state: "visible" }); // Act await page.locator(`[data-testid='mic-button']`).click(); await page.waitForTimeout(5_000); // Assert await expect(page.locator(`[data-testid='transcript']`)).toContainText("hello"); }); }, ); ``` # Measure web page performance Source: https://docs.qawolf.com/web-performance Run a Lighthouse audit in a QA Wolf flow to spot-check Core Web Vitals and key performance metrics. This recipe covers spot-checking web performance with Lighthouse. For operationalized performance monitoring — scheduled runs, trend tracking, aggregated reports, and alerting — talk to your QA Wolf team about full-service performance testing. ## Examples **Run a Lighthouse audit and assert on the performance score:** ```javascript theme={null} import { expect, flow } from "@qawolf/flows/web"; import { playAudit } from "playwright-lighthouse"; const PERF_SCORE_MIN = 80; export default flow( "Homepage performance", { target: "Web - Chrome", launch: { args: ["--remote-debugging-port=9222"] } }, async ({ page, test }) => { await test("Lighthouse audit", async () => { await page.goto(process.env.BASE_URL); await page.waitForLoadState("networkidle").catch(() => {}); const { lhr } = await playAudit({ page, port: 9222, thresholds: { performance: PERF_SCORE_MIN }, config: { extends: "lighthouse:default", settings: { onlyCategories: ["performance"] }, }, }); const perfScore = Math.round(lhr.categories.performance.score * 100); expect(perfScore).toBeGreaterThanOrEqual(PERF_SCORE_MIN); }); }, ); ``` **Assert on individual Core Web Vitals:** ```javascript theme={null} const lcp = lhr.audits["largest-contentful-paint"].numericValue; const fcp = lhr.audits["first-contentful-paint"].numericValue; const tbt = lhr.audits["total-blocking-time"].numericValue; const cls = lhr.audits["cumulative-layout-shift"].numericValue; const tti = lhr.audits["interactive"].numericValue; const ttfb = lhr.audits["server-response-time"].numericValue; ``` **Save the audit report to team storage:** ```javascript theme={null} const { lhr } = await playAudit({ page, thresholds: { performance: PERF_SCORE_MIN }, reports: { formats: { json: true }, directory: `${process.env.TEAM_STORAGE_DIR}/lighthouse`, name: `homepage-${Date.now()}`, }, config: { extends: "lighthouse:default", settings: { onlyCategories: ["performance"] }, }, }); ``` The double navigation is intentional. Lighthouse measures the second load so that cookie banner dismissal steps don't skew the metrics. ## When to use * Your team wants a quick sanity check that a page meets minimum performance thresholds before or after a deploy. * You want to catch regressions in Core Web Vitals — LCP, CLS, or FCP — introduced by new code. * You need a lightweight performance gate in CI without a full monitoring setup. * You want to verify that a specific page (checkout, landing page, dashboard) hasn't degraded after a significant change. ## Performance thresholds For more on what each metric means and why it matters, see [Web performance metrics, explained](https://www.qawolf.com/blog/web-performance-metrics-explained). ## Notes * The sample test below runs in Google Chrome only, using port `9222`, which is the Chrome DevTools Protocol port. Lighthouse connects to an already-running Chrome instance via CDP rather than launching its own. * `playAudit` requires a Playwright `page` object with an active navigation. Call it after `waitForLoadState` to ensure the page has settled. * Lighthouse runs in a simulated environment — results will vary slightly between runs. Avoid very tight thresholds (e.g. `≤ 100ms` for TBT) that will produce flaky results. * Thresholds are declared as named constants at the top of the file so they are easy to find and adjust per environment. * `onlyCategories: ["performance"]` scopes the audit to performance only. Lighthouse can also score accessibility and SEO. ## Full sample test ```js theme={null} import { expect, flow } from "@qawolf/flows/web"; import { playAudit } from "playwright-lighthouse"; // Thresholds based on qawolf.com/blog/web-performance-metrics-explained const PERF_SCORE_MIN = 80; const LCP_MAX_MS = 2500; const FCP_MAX_MS = 1800; const TBT_MAX_MS = 200; const CLS_MAX = 0.1; const TTI_MAX_MS = 3000; const TTFB_MAX_MS = 150; const PAGE_LOAD_MAX_MS = 2000; export default flow( "Homepage", { target: "Web - Chrome", launch: { args: ["--remote-debugging-port=9222"] } }, async ({ page, test }) => { await test("Performance test for homepage", async () => { //-------------------------------- // Arrange: Launch browser and navigate to homepage //-------------------------------- await page.goto(process.env.BASE_URL); //-------------------------------- // Act: Run Lighthouse performance audit //-------------------------------- await page.goto(process.env.BASE_URL); await page.waitForLoadState("networkidle").catch(() => {}); const { lhr } = await playAudit({ page, port: 9222, thresholds: { performance: PERF_SCORE_MIN, }, reports: { formats: { json: true }, directory: `${process.env.TEAM_STORAGE_DIR}/lighthouse`, name: `homepage-${Date.now()}`, }, config: { extends: "lighthouse:default", settings: { onlyCategories: ["performance"], }, }, }); //-------------------------------- // Assert: Verify all performance metrics are within budget //-------------------------------- const perfScore = Math.round(lhr.categories.performance.score * 100); const lcp = lhr.audits["largest-contentful-paint"].numericValue; const fcp = lhr.audits["first-contentful-paint"].numericValue; const tbt = lhr.audits["total-blocking-time"].numericValue; const cls = lhr.audits["cumulative-layout-shift"].numericValue; const tti = lhr.audits["interactive"].numericValue; const ttfb = lhr.audits["server-response-time"].numericValue; const pageLoadTime = lhr.audits["metrics"].details.items[0].observedLoad; console.log(`Performance score: ${perfScore} (min ${PERF_SCORE_MIN})`); console.log(`LCP: ${Math.round(lcp)}ms (max ${LCP_MAX_MS}ms)`); console.log(`FCP: ${Math.round(fcp)}ms (max ${FCP_MAX_MS}ms)`); console.log(`TBT: ${Math.round(tbt)}ms (max ${TBT_MAX_MS}ms)`); console.log(`CLS: ${cls.toFixed(3)} (max ${CLS_MAX})`); console.log(`TTI: ${Math.round(tti)}ms (max ${TTI_MAX_MS}ms)`); console.log(`TTFB: ${Math.round(ttfb)}ms (max ${TTFB_MAX_MS}ms)`); console.log(`Page load: ${Math.round(pageLoadTime)}ms (max ${PAGE_LOAD_MAX_MS}ms)`); expect(perfScore).toBeGreaterThanOrEqual(PERF_SCORE_MIN); expect(lcp).toBeLessThanOrEqual(LCP_MAX_MS); expect(fcp).toBeLessThanOrEqual(FCP_MAX_MS); expect(tbt).toBeLessThanOrEqual(TBT_MAX_MS); expect(cls).toBeLessThanOrEqual(CLS_MAX); expect(tti).toBeLessThanOrEqual(TTI_MAX_MS); expect(ttfb).toBeLessThanOrEqual(TTFB_MAX_MS); expect(pageLoadTime).toBeLessThanOrEqual(PAGE_LOAD_MAX_MS); }); }, ); ``` # Speaker recording (Web) Source: https://docs.qawolf.com/web-speaker-capture Capture browser audio output during a test to verify that your app plays the correct audio. Use Playwright's `getDisplayMedia` API with Chrome flags to record audio playing in a browser tab. The recording is saved as a `.webm` file that can be downloaded and analyzed. Playwright initializes the fake audio device once per browser instance. You cannot swap audio sources mid-flow. If you need to record different audio content, launch a new browser instance for each. ## Examples **Record audio from a browser tab** ```typescript theme={null} const { rm } = await import("node:fs/promises"); await rm("/tmp/audioRecording.webm", { force: true }); page.on("download", async (download) => { await download.saveAs(`/tmp/${download.suggestedFilename()}`); }); await page.evaluate(async () => { const stream = await navigator.mediaDevices.getDisplayMedia({ video: { displaySurface: "browser" }, audio: { displaySurface: "browser" }, }); const recorder = new MediaRecorder(stream); window.__rec = recorder; const chunks = []; recorder.ondataavailable = (e) => chunks.push(e.data); recorder.onstop = () => { const blob = new Blob(chunks, { type: chunks[0]?.type || "video/webm" }); stream.getVideoTracks()[0]?.stop(); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "audioRecording.webm"; document.body.appendChild(a); a.click(); a.remove(); }; recorder.start(); }); // trigger audio playback in your app await page.waitForTimeout(10_000); await page.evaluate(() => window.__rec.stop()); await page.waitForTimeout(10_000); ``` **Download audio from a page element** If your app exposes audio via a `