├── .changeset ├── README.md └── config.json ├── .eslintrc.js ├── .github └── workflows │ ├── build.yml │ ├── package-version.yml │ ├── publish.yml │ ├── publish_api_reference.yml │ └── test.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── api-reference.md ├── assertions.md ├── basics.md ├── config.md ├── locators.md ├── persistent-device.md └── vision.md ├── example ├── .gitignore ├── README.md ├── appwright.config.ts ├── builds │ ├── wikipedia.apk │ └── wikipedia_ios.zip ├── package-lock.json ├── package.json ├── tests │ └── tests.spec.ts └── tools │ └── extract.js ├── package-lock.json ├── package.json ├── src ├── bin │ └── index.ts ├── config.ts ├── device │ └── index.ts ├── fixture │ ├── index.ts │ └── workerInfo.ts ├── global-setup.ts ├── index.ts ├── locator │ └── index.ts ├── logger.ts ├── providers │ ├── appium.ts │ ├── browserstack │ │ ├── index.ts │ │ └── utils.ts │ ├── emulator │ │ └── index.ts │ ├── index.ts │ ├── lambdatest │ │ ├── index.ts │ │ └── utils.ts │ └── local │ │ └── index.ts ├── reporter.ts ├── tests │ ├── locator.spec.ts │ ├── regex.spec.ts │ └── vitest.config.mts ├── types │ ├── errors.ts │ └── index.ts ├── utils.ts └── vision │ └── index.ts └── tsconfig.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.3/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["@empiricalrun/eslint-config/playwright"], 3 | ignorePatterns: ["example/**"], 4 | }; 5 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | 7 | jobs: 8 | build: 9 | name: Build 10 | timeout-minutes: 5 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out code 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 1 17 | 18 | - name: Setup Node.js environment 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 20 22 | 23 | - name: Install dependencies 24 | run: npm ci 25 | 26 | - name: Build 27 | run: npm run build 28 | 29 | - name: Lint 30 | run: npm run lint 31 | -------------------------------------------------------------------------------- /.github/workflows/package-version.yml: -------------------------------------------------------------------------------- 1 | name: Create package publish PR 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Release PR 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Repo 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup Node.js 20 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 20 22 | 23 | - name: Install Dependencies 24 | run: npm ci 25 | 26 | - name: Create Release Pull Request 27 | uses: changesets/action@v1 28 | with: 29 | title: "chore: update package versions for release" 30 | commit: "chore: update package versions for release" 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish package 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | publish: 7 | name: Publish package 8 | timeout-minutes: 8 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Check out code 12 | uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 2 15 | 16 | - name: Setup Node.js environment 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: 20 20 | 21 | - name: Install dependencies 22 | run: npm ci 23 | 24 | - name: Build 25 | run: npm run build 26 | 27 | - name: Creating .npmrc 28 | run: | 29 | cat << EOF > "$HOME/.npmrc" 30 | //registry.npmjs.org/:_authToken=$NPM_TOKEN 31 | EOF 32 | env: 33 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 34 | 35 | - name: Publish to npm 36 | uses: changesets/action@v1 37 | with: 38 | publish: npm run release 39 | createGithubReleases: true 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 43 | -------------------------------------------------------------------------------- /.github/workflows/publish_api_reference.yml: -------------------------------------------------------------------------------- 1 | name: Publish API References 2 | on: 3 | push: 4 | branches: 5 | - main 6 | workflow_dispatch: 7 | 8 | jobs: 9 | publish-documentation: 10 | permissions: 11 | id-token: "write" 12 | pages: "write" 13 | 14 | environment: 15 | name: "github-pages" 16 | url: "${{ steps.deployment.outputs.page_url }}" 17 | 18 | runs-on: "ubuntu-latest" 19 | steps: 20 | - id: "checkout" 21 | name: "Check out Git repository" 22 | uses: "actions/checkout@v3" 23 | 24 | - id: "setup-node" 25 | name: Setup Node.js environment 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: 20 29 | 30 | - id: "install-dependencies" 31 | name: "Install Node.js dependencies" 32 | run: npm ci 33 | 34 | - id: "build" 35 | name: "Build documentation" 36 | run: | 37 | set -euo pipefail 38 | npm run build:doc 39 | 40 | - id: "upload-documentation" 41 | name: "Upload Pages artifact" 42 | uses: "actions/upload-pages-artifact@v1" 43 | with: 44 | name: "github-pages" 45 | path: "api-references" 46 | 47 | - id: "deployment" 48 | name: "Deploy documentation to GitHub Pages" 49 | uses: "actions/deploy-pages@v1" 50 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | 7 | jobs: 8 | test: 9 | name: Test 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out code 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 1 16 | 17 | - name: Setup Node.js environment 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 20 21 | 22 | - name: Install dependencies 23 | run: npm ci 24 | 25 | - name: Run test 26 | run: npm run test 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | test-results/ 3 | playwright-report/ 4 | blob-report/ 5 | playwright/.cache/ 6 | 7 | dist/ 8 | .DS_Store -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | .github 3 | .changeset 4 | example/ 5 | docs/ 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # appwright 2 | 3 | ## 0.1.46 4 | 5 | ### Patch Changes 6 | 7 | - f53d282: feat: add udid parameter to EmulatorConfig 8 | 9 | ## 0.1.45 10 | 11 | ### Patch Changes 12 | 13 | - 146116a: fix: added comments 14 | 15 | ## 0.1.44 16 | 17 | ### Patch Changes 18 | 19 | - 4074400: fix: video download issue on lambdatest 20 | 21 | ## 0.1.43 22 | 23 | ### Patch Changes 24 | 25 | - 651cce1: fix: file write error when file is not present 26 | 27 | ## 0.1.42 28 | 29 | ### Patch Changes 30 | 31 | - ed2762a: fix: provider video download failure should not stop tests 32 | 33 | ## 0.1.41 34 | 35 | ### Patch Changes 36 | 37 | - 216e9f5: fix: multiple appium session creation 38 | 39 | ## 0.1.40 40 | 41 | ### Patch Changes 42 | 43 | - fbcbe5a: fix: added capability to enable camera injection using config 44 | 45 | ## 0.1.39 46 | 47 | ### Patch Changes 48 | 49 | - a3e369d: chore: added docs for vision 50 | 51 | ## 0.1.38 52 | 53 | ### Patch Changes 54 | 55 | - a5eacf0: feat: added api key in vision APIs 56 | 57 | ## 0.1.37 58 | 59 | ### Patch Changes 60 | 61 | - 01bb396: fix: move worker video download to reporter 62 | - f7ca94a: fix: updated llm package version to fix the langfuse errors 63 | - 22d3ce8: fix: worker info store needs recursive mkdir 64 | 65 | ## 0.1.36 66 | 67 | ### Patch Changes 68 | 69 | - 3430e0e: feat: added telemetry for vision methods 70 | 71 | ## 0.1.35 72 | 73 | ### Patch Changes 74 | 75 | - ed4a96b: chore: update llm package 76 | 77 | ## 0.1.34 78 | 79 | ### Patch Changes 80 | 81 | - 77b8cd1: fix: ffmpeg error logging 82 | 83 | ## 0.1.33 84 | 85 | ### Patch Changes 86 | 87 | - 4c3c3ec: chore: improve logs for video trimming job 88 | - 61fd8d2: fix: added option for caching in vision tap calls 89 | - fc9879c: feat: capture persistentDevice worker info in a file on disk 90 | - 3dbf6ba: docs: fix persistent device parallelism section 91 | 92 | ## 0.1.32 93 | 94 | ### Patch Changes 95 | 96 | - 070625f: fix: handle ffmpeg error on trimming video 97 | - 56b131e: feat: decorate device methods for reporting 98 | 99 | ## 0.1.31 100 | 101 | ### Patch Changes 102 | 103 | - b65f3db: fix: increased idle timeout in bs to 180 seconds 104 | 105 | ## 0.1.30 106 | 107 | ### Patch Changes 108 | 109 | - 1b89ea4: fix: updated check for http url 110 | 111 | ## 0.1.29 112 | 113 | ### Patch Changes 114 | 115 | - 896541e: support bs:// and lt:// app urls for providers 116 | - b2de667: feat: log device.pause and skip it on CI environments 117 | 118 | ## 0.1.28 119 | 120 | ### Patch Changes 121 | 122 | - 5bbd6e2: fix: increase maxDepth to 62 123 | 124 | ## 0.1.27 125 | 126 | ### Patch Changes 127 | 128 | - 8fa6fca: fix: scale tap coordinates only by width for lambdatest getWindowRect() behavior 129 | - 202ad6d: feat: added method to fill input fields using device 130 | 131 | ## 0.1.26 132 | 133 | ### Patch Changes 134 | 135 | - c86131d: fix: added boxed step in scroll 136 | 137 | ## 0.1.25 138 | 139 | ### Patch Changes 140 | 141 | - ed5f069: chore: upgrade llm/vision to 0.9.12 142 | 143 | ## 0.1.24 144 | 145 | ### Patch Changes 146 | 147 | - 89afbf5: fix: added try catch in close session 148 | 149 | ## 0.1.23 150 | 151 | ### Patch Changes 152 | 153 | - 3930289: feat: use drag method for ios scroll 154 | 155 | ## 0.1.22 156 | 157 | ### Patch Changes 158 | 159 | - bccffd2: feat: add device.pause() and device.waitForTimeout() methods 160 | - e1e3750: feat: vision.tap returns tap coordinates, vision.query accepts screenshot as option 161 | 162 | ## 0.1.21 163 | 164 | ### Patch Changes 165 | 166 | - 029a708: fix: move assets location 167 | 168 | ## 0.1.20 169 | 170 | ### Patch Changes 171 | 172 | - c91b815: fix: vision tap precision 173 | 174 | ## 0.1.19 175 | 176 | ### Patch Changes 177 | 178 | - f43c977: chore: add log for worker teardown 179 | 180 | ## 0.1.18 181 | 182 | ### Patch Changes 183 | 184 | - 168425b: feat: add locator.waitFor to check for attached or visible state 185 | 186 | ## 0.1.17 187 | 188 | ### Patch Changes 189 | 190 | - 6aae307: feat: find ffmpeg path or install it 191 | - a620d3c: fix: use promise.allSettled in reporter 192 | 193 | ## 0.1.16 194 | 195 | ### Patch Changes 196 | 197 | - 13e5451: feat: move video downloader into a reporter 198 | - 40540b1: feat: download and attach video for persistentDevice 199 | - 3ed1b65: docs: persistent device 200 | - 551b1dc: chore: minor docs and logging changes 201 | - 289f2b4: feat: trim video for persistentDevice report with local ffmpeg 202 | 203 | ## 0.1.15 204 | 205 | ### Patch Changes 206 | 207 | - 2be3c09: test: locator tests for isVisible method 208 | - 32b5e24: chore: rename error log messages and ActionOptions 209 | - 0c0c0df: chore: add custom errors for timeout and retryable 210 | 211 | ## 0.1.14 212 | 213 | ### Patch Changes 214 | 215 | - 3de990b: fix: save videos not working 216 | 217 | ## 0.1.13 218 | 219 | ### Patch Changes 220 | 221 | - 49dc470: feat: add persistentDevice as worker-level fixture 222 | - 49dc470: feat: add methods for terminateApp and activateApp 223 | 224 | ## 0.1.12 225 | 226 | ### Patch Changes 227 | 228 | - 44f9a4a: feat: added capability to select model in vision methods 229 | - b8ff829: feat: optimize regex locator by identify simple groups in the pattern 230 | - 5626697: feat: add appBundleId to config and make it mandatory for lambdatest 231 | - 21a4b2c: chore: setup tests with vitest 232 | 233 | ## 0.1.11 234 | 235 | ### Patch Changes 236 | 237 | - c86db58: feat: attach annotated images from vision calls to the report 238 | 239 | ## 0.1.10 240 | 241 | ### Patch Changes 242 | 243 | - bbba9c5: fix: removed LambdaTest network capability 244 | 245 | ## 0.1.9 246 | 247 | ### Patch Changes 248 | 249 | - 5d05bdc: feat: added scroll capabilities in appwright 250 | 251 | ## 0.1.8 252 | 253 | ### Patch Changes 254 | 255 | - 1b83e1c: feat: support for landscape device orientation 256 | 257 | ## 0.1.7 258 | 259 | ### Patch Changes 260 | 261 | - 961019c: fix: wait to download all videos and attach to report 262 | 263 | ## 0.1.6 264 | 265 | ### Patch Changes 266 | 267 | - 80a4c6a: fix: remove await while downloading video from provider in saveVideo fixture 268 | 269 | ## 0.1.5 270 | 271 | ### Patch Changes 272 | 273 | - ef1a2b8: fix: increased idle timeout for LambdaTest 274 | 275 | ## 0.1.4 276 | 277 | ### Patch Changes 278 | 279 | - 65e2ca7: feat: added lambdatest support in appwright 280 | 281 | ## 0.1.3 282 | 283 | ### Patch Changes 284 | 285 | - 197c3e2: chore: added-docs-in-npmignore 286 | 287 | ## 0.1.2 288 | 289 | ### Patch Changes 290 | 291 | - 940ea32: fix: updated playwright version and removed exponential delays between retries 292 | 293 | ## 0.1.1 294 | 295 | ### Patch Changes 296 | 297 | - 415f940: fix: removed check for installed emulator when running on real device 298 | 299 | ## 0.1.0 300 | 301 | ### Minor Changes 302 | 303 | - 1e663d4: chore: updated docs and references 304 | 305 | ## 0.0.41 306 | 307 | ### Patch Changes 308 | 309 | - 6bc5d18: fix: handle already running appium server 310 | - 93386ad: fix: Move list and html reporter out of example and add them as default in appwright 311 | 312 | ## 0.0.40 313 | 314 | ### Patch Changes 315 | 316 | - e9ca490: fix: removed console logs from bin 317 | 318 | ## 0.0.39 319 | 320 | ### Patch Changes 321 | 322 | - 1e97b20: fix: references doc link 323 | 324 | ## 0.0.38 325 | 326 | ### Patch Changes 327 | 328 | - 59d9e3d: chore: updated readme and docs 329 | 330 | ## 0.0.37 331 | 332 | ### Patch Changes 333 | 334 | - afd436d: chore: remove example from npm package 335 | 336 | ## 0.0.36 337 | 338 | ### Patch Changes 339 | 340 | - 0a3c34b: chore: updated `mockCameraView` method name 341 | 342 | ## 0.0.35 343 | 344 | ### Patch Changes 345 | 346 | - b763d60: chore: eslint error in example 347 | - 30712fd: feat: local device config now supports optional udid parameter 348 | - 7b95f16: feat: added capability to perform browserStack camera injection using appwright 349 | - 848108e: feat: multi activity support 350 | 351 | ## 0.0.34 352 | 353 | ### Patch Changes 354 | 355 | - 14e72b7: chore: added npm script to extract app file 356 | 357 | ## 0.0.33 358 | 359 | ### Patch Changes 360 | 361 | - 0a35685: fix: correct project not getting selected with `--project` flag 362 | 363 | ## 0.0.32 364 | 365 | ### Patch Changes 366 | 367 | - ba88b2f: Buildpath validation 368 | - 4f49719: chore: update error strings and add doc links 369 | 370 | ## 0.0.31 371 | 372 | ### Patch Changes 373 | 374 | - f7bd426: feat: add support for test.skip if llm keys are not available 375 | 376 | ## 0.0.30 377 | 378 | ### Patch Changes 379 | 380 | - f34bb56: fix: run global setup only for provided project 381 | 382 | ## 0.0.29 383 | 384 | ### Patch Changes 385 | 386 | - a512d15: feat: add reinstallation of drivers 387 | 388 | ## 0.0.28 389 | 390 | ### Patch Changes 391 | 392 | - c2efc55: fix: error running tests due to missing appium drivers 393 | 394 | ## 0.0.27 395 | 396 | ### Patch Changes 397 | 398 | - db747d5: fix: add driver dependencies to appium 399 | 400 | ## 0.0.26 401 | 402 | ### Patch Changes 403 | 404 | - 7b8d39a: feat: error message for udid for ios 405 | 406 | ## 0.0.25 407 | 408 | ### Patch Changes 409 | 410 | - 62c6759: fix: app bundle id for browserstack provider 411 | 412 | ## 0.0.24 413 | 414 | ### Patch Changes 415 | 416 | - 4951c96: fix: install appium driver if not installed already 417 | 418 | ## 0.0.23 419 | 420 | ### Patch Changes 421 | 422 | - dcee15e: chore: update doc strings 423 | 424 | ## 0.0.22 425 | 426 | ### Patch Changes 427 | 428 | - 8563e13: chore: remove verbose logs 429 | - df08327: feat: added support to run appwright on local device 430 | 431 | ## 0.0.21 432 | 433 | ### Patch Changes 434 | 435 | - 99a557c: fix: validation for config.globalSetup 436 | 437 | ## 0.0.20 438 | 439 | ### Patch Changes 440 | 441 | - b210e79: fix: build upload validation in browserstack provider 442 | - 9266137: chore: warning for globalSetup config behavior 443 | 444 | ## 0.0.19 445 | 446 | ### Patch Changes 447 | 448 | - b8e6947: feat: support buildPath and build uploads with globalSetup 449 | - 10c7397: fix: pick correct browserstack build url 450 | 451 | ## 0.0.18 452 | 453 | ### Patch Changes 454 | 455 | - 6458d17: chore: refactored device provider methods 456 | - 1b26228: fix: webdriver client getting passed as undefined to device 457 | 458 | ## 0.0.17 459 | 460 | ### Patch Changes 461 | 462 | - ee98722: chore: refactored appwright APIs 463 | 464 | ## 0.0.16 465 | 466 | ### Patch Changes 467 | 468 | - 8c04904: fix: remove custom reporter 469 | 470 | ## 0.0.15 471 | 472 | ### Patch Changes 473 | 474 | - b9f5c3d: chore: move boxedStep to util and add locator docstrings 475 | - 912b092: chore: rename internal classes for readability 476 | - 2d1c600: feat: rename tap method 477 | - 8febdb4: fix: added `boxedStep` decorator to actions 478 | - 3abaabe: fix: corrected import for driver 479 | 480 | ## 0.0.14 481 | 482 | ### Patch Changes 483 | 484 | - ebfb243: feat: added capability to search element using regex 485 | 486 | ## 0.0.13 487 | 488 | ### Patch Changes 489 | 490 | - 4cb2069: feat: expose npx appwright bin 491 | 492 | ## 0.0.12 493 | 494 | ### Patch Changes 495 | 496 | - 984a468: feat: added method to send keyboard key events 497 | 498 | ## 0.0.11 499 | 500 | ### Patch Changes 501 | 502 | - 38fd3a3: fix: retry counts in waitUntil 503 | 504 | ## 0.0.10 505 | 506 | ### Patch Changes 507 | 508 | - f22bdfb: feat: added llm vision in appwright 509 | 510 | ## 0.0.9 511 | 512 | ### Patch Changes 513 | 514 | - e6989c9: fix: pick expect timeout from appwright config 515 | 516 | ## 0.0.8 517 | 518 | ### Patch Changes 519 | 520 | - c6910dc: feat: added method getByXpath to get elements by xpath 521 | 522 | ## 0.0.7 523 | 524 | ### Patch Changes 525 | 526 | - 5ab5fc8: chore: removed tests and configs from appwright 527 | 528 | ## 0.0.6 529 | 530 | ### Patch Changes 531 | 532 | - 4dc686c: feat: added `getByText` and `getById` methods on driver 533 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2024 Forge AI Private Limited 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Appwright 2 | 3 | ![NPM Version](https://img.shields.io/npm/v/appwright?color=4AC61C) 4 | 5 | Appwright is a test framework for e2e testing of mobile apps. Appwright builds on top of [Appium](https://appium.io/docs/en/latest/), and can 6 | run tests on local devices, emulators, and remote device farms — for both iOS and Android. 7 | 8 | Appwright is one integrated package that combines an automation driver, test runner and test 9 | reporter. To achieve this, Appwright uses the [Playwright](https://github.com/microsoft/playwright) test runner internally, which is 10 | purpose-built for the e2e testing workflow. 11 | 12 | Appwright exposes an ergonomic API to automate user actions. These actions auto-wait and auto-retry 13 | for UI elements to be ready and interactable, which makes your tests easier to read and maintain. 14 | 15 | ```ts 16 | import { test, expect } from "appwright"; 17 | 18 | test("User can login", async ({ device }) => { 19 | await device.getByText("Username").fill("admin"); 20 | await device.getByText("Password").fill("password"); 21 | await device.getByText("Login").tap(); 22 | }); 23 | ``` 24 | 25 | Links to help you get started. 26 | 27 | - [Example project](https://github.com/empirical-run/appwright/tree/main/example) 28 | - [Launch blog post](https://www.empirical.run/blog/appwright) 29 | - [Documentation](#docs) 30 | 31 | ## Usage 32 | 33 | ### Minimum requirements 34 | 35 | - Node 18.20.4 or higher 36 | 37 | ### Install 38 | 39 | ```sh 40 | npm i --save-dev appwright 41 | touch appwright.config.ts 42 | ``` 43 | 44 | ### Configure 45 | 46 | ```ts 47 | // In appwright.config.ts 48 | import { defineConfig, Platform } from "appwright"; 49 | export default defineConfig({ 50 | projects: [ 51 | { 52 | name: "android", 53 | use: { 54 | platform: Platform.ANDROID, 55 | device: { 56 | provider: "emulator", // or 'local-device' or 'browserstack' 57 | }, 58 | buildPath: "app-release.apk", 59 | }, 60 | }, 61 | { 62 | name: "ios", 63 | use: { 64 | platform: Platform.IOS, 65 | device: { 66 | provider: "emulator", // or 'local-device' or 'browserstack' 67 | }, 68 | buildPath: "app-release.app", // Path to your .app file 69 | }, 70 | }, 71 | ], 72 | }); 73 | ``` 74 | 75 | ### Configuration Options 76 | 77 | - `platform`: The platform you want to test on, such as 'android' or 'ios'. 78 | 79 | - `provider`: The device provider where you want to run your tests. 80 | You can choose between `browserstack`, `lambdatest`, `emulator`, or `local-device`. 81 | 82 | - `buildPath`: The path to your build file. For Android, it should be an APK file. 83 | For iOS, if you are running tests on real device, it should be an `.ipa` file. For running tests on an emulator, it should be a `.app` file. 84 | 85 | ### Run tests 86 | 87 | To run tests, you need to specify the project name with `--project` flag. 88 | 89 | ```sh 90 | npx appwright test --project android 91 | npx appwright test --project ios 92 | ``` 93 | 94 | #### Run tests on BrowserStack 95 | 96 | Appwright supports BrowserStack out of the box. To run tests on BrowserStack, configure 97 | the provider in your config. 98 | 99 | ```ts 100 | { 101 | name: "android", 102 | use: { 103 | platform: Platform.ANDROID, 104 | device: { 105 | provider: "browserstack", 106 | // Specify device to run the tests on 107 | // See supported devices: https://www.browserstack.com/list-of-browsers-and-platforms/app_automate 108 | name: "Google Pixel 8", 109 | osVersion: "14.0", 110 | }, 111 | buildPath: "app-release.apk", 112 | }, 113 | }, 114 | ``` 115 | 116 | #### Run tests on LambdaTest 117 | 118 | Appwright supports LambdaTest out of the box. To run tests on LambdaTest, configure 119 | the provider in your config. 120 | 121 | ```ts 122 | { 123 | name: "android", 124 | use: { 125 | platform: Platform.ANDROID, 126 | device: { 127 | provider: "lambdatest", 128 | // Specify device to run the tests on 129 | // See supported devices: https://www.lambdatest.com/list-of-real-devices 130 | name: "Pixel 8", 131 | osVersion: "14", 132 | }, 133 | buildPath: "app-release.apk", 134 | }, 135 | }, 136 | ``` 137 | 138 | ## Run the sample project 139 | 140 | To run the sample project: 141 | 142 | - Navigate to the `example` directory. 143 | 144 | ```sh 145 | cd example 146 | ``` 147 | 148 | - Install dependencies. 149 | 150 | ```sh 151 | npm install 152 | ``` 153 | 154 | - Run the tests 155 | 156 | Run the following command to execute tests on an Android emulator: 157 | 158 | ```sh 159 | npx appwright test --project android 160 | ``` 161 | 162 | To run the tests on iOS simulator: 163 | 164 | - Unzip the `wikipedia.zip` file 165 | 166 | ```sh 167 | npm run extract:app 168 | ``` 169 | - Run the following command: 170 | 171 | ```sh 172 | npx appwright test --project ios 173 | ``` 174 | 175 | ## Docs 176 | 177 | - [Basics](docs/basics.md) 178 | - [Configuration](docs/config.md) 179 | - [Locators](docs/locators.md) 180 | - [Assertions](docs/assertions.md) 181 | - [API reference](docs/api-reference.md) 182 | -------------------------------------------------------------------------------- /docs/api-reference.md: -------------------------------------------------------------------------------- 1 | # API reference 2 | 3 | ## Device 4 | 5 | The device is the core component that allows you to interact with the mobile app. 6 | It provides methods for interacting with the app, such as tapping, typing, and verifying the state of elements etc. 7 | 8 | [API reference](https://empirical-run.github.io/appwright/classes/Device.html) for `Device` 9 | 10 | ## Locator 11 | 12 | Locators are essential for selecting elements in the app. Appwright provides a set of built-in locators that you can use to select elements by text, ID, RegExp or XPath. 13 | 14 | [API reference](https://empirical-run.github.io/appwright/interfaces/AppwrightLocator.html) for `Locator` 15 | -------------------------------------------------------------------------------- /docs/assertions.md: -------------------------------------------------------------------------------- 1 | # Assertions 2 | 3 | Appwright provides a set of built-in assertions that you can use to verify the state of your app. 4 | 5 | ## expect 6 | 7 | The `expect` function is used to make assertions on the state of your app. 8 | 9 | ```ts 10 | const clipboardText = await device.getClipboardText(); 11 | expect(clipboardText).toBe("Hello, world!"); 12 | ``` 13 | 14 | ## toBeVisible 15 | 16 | The `toBeVisible` assertion checks if an element is visible on the screen. 17 | 18 | ```ts 19 | await expect(device.getByText('Login')).toBeVisible(); 20 | ``` 21 | -------------------------------------------------------------------------------- /docs/basics.md: -------------------------------------------------------------------------------- 1 | # Basics 2 | 3 | ## Write a Test 4 | 5 | In Appwright, writing a test is simple and similar to how tests are written in Playwright, but with enhanced mobile capabilities. 6 | 7 | ## Configure Projects 8 | 9 | In Appwright, you can define multiple test configurations for different platforms (Android, iOS, etc.) within your `appwright.config.ts`. This configuration tells Appwright how to run your tests on different devices and environments. 10 | 11 | ```ts 12 | // In appwright.config.ts 13 | import { defineConfig, Platform } from "appwright"; 14 | export default defineConfig({ 15 | projects: [ 16 | { 17 | name: "android", 18 | use: { 19 | platform: Platform.ANDROID, 20 | device: { 21 | provider: "emulator", // or 'local-device' or 'browserstack' 22 | }, 23 | buildPath: "app-release.apk", 24 | }, 25 | }, 26 | { 27 | name: "ios", 28 | use: { 29 | platform: Platform.IOS, 30 | device: { 31 | provider: "emulator", // or 'local-device' or 'browserstack' 32 | }, 33 | buildPath: "app-release.app", // Path to your .app file 34 | }, 35 | }, 36 | ], 37 | }); 38 | ``` 39 | 40 | ## Built-in Fixtures 41 | 42 | Appwright provides built-in fixtures like `device`, which offers methods to handle mobile interactions. 43 | 44 | Here’s an example of how to write a basic test using the `device` fixture: 45 | 46 | ```ts 47 | import { test, expect } from 'appwright'; 48 | 49 | test('should display the login screen and tap on Login button', async ({ device }) => { 50 | 51 | // Assert that the login button is visible 52 | await expect(device.getByText('Login')).toBeVisible(); 53 | 54 | // Tap on the login button 55 | await device.getByText('Login').tap(); 56 | }); 57 | ``` 58 | 59 | ## Run the Test 60 | 61 | To run the test, you can use the `npx appwright test` command. 62 | 63 | ### Run the test on Android 64 | 65 | ```sh 66 | npx appwright test --project android 67 | ``` 68 | 69 | ### Run the test on iOS 70 | 71 | ```sh 72 | npx appwright test --project ios 73 | ``` 74 | 75 | Above commands will trigger runs on android and iOS emulators based on the above configuration. 76 | 77 | Once the test is completed, the report is launched automatically in the browser. -------------------------------------------------------------------------------- /docs/config.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Appwright provides a set of configuration options that you can use to customize 4 | the test environment and thus the behavior of the tests. 5 | 6 | ## Device Providers 7 | 8 | Device providers make Appium compatible mobile devices available to Appwright. These 9 | providers are supported: 10 | 11 | - `local-device` 12 | - `emulator` 13 | - `browserstack` 14 | - `lambdatest` 15 | 16 | ### BrowserStack 17 | 18 | BrowserStack [App Automate](https://www.browserstack.com/app-automate) can be used to provide 19 | remote devices to Appwright. 20 | 21 | These environment variables are required for the BrowserStack 22 | 23 | - BROWSERSTACK_USERNAME 24 | - BROWSERSTACK_ACCESS_KEY 25 | 26 | BrowserStack also requires `name` and `osVersion` of the device to be set in the projects in appwright config file. 27 | 28 | ### LambdaTest 29 | 30 | LambdaTest [Real Device Cloud](https://www.lambdatest.com/support/docs/app-testing-on-real-devices/) can be used to provide 31 | remote devices to Appwright. 32 | 33 | These environment variables are required for the LambdaTest 34 | 35 | - LAMBDATEST_USERNAME 36 | - LAMBDATEST_ACCESS_KEY 37 | 38 | LambdaTest also requires `name` and `osVersion` of the device to be set in the projects in appwright config file. 39 | 40 | ### Android Emulator 41 | 42 | To run tests on the Android emulator, ensure the following installations are available. If not, follow these steps: 43 | 44 | 1. **Install Android Studio**: If not installed, download and install it from [here](https://developer.android.com/studio). 45 | 2. **Set Android SDK location**: Open Android Studio, copy the Android SDK location, and set the `ANDROID_HOME` environment variable to the same path. 46 | 3. **Check Java Installation**: Verify if Java is installed by running `java -version`. If it's not installed: 47 | - Install Java using Homebrew: `brew install java`. 48 | - After installation, run the symlink command provided at the end of the installation process. 49 | 50 | 51 | To check for available emulators, run the following command: 52 | 53 | ```sh 54 | $ANDROID_HOME/emulator/emulator --list-avds 55 | ``` 56 | 57 | ### iOS Simulator 58 | 59 | To run tests on the iOS Simulator, ensure the following installations are available. If not, follow these steps: 60 | 61 | 1. **Install Xcode**: If not installed, download and install it from [here](https://developer.apple.com/xcode/). 62 | 2. **Download iOS Simulator**: While installing Xcode, you will be prompted to select the platform to develop for. Ensure that iOS is selected. 63 | 64 | To check for available iOS simulators, run the following command: 65 | 66 | ```sh 67 | xcrun simctl list 68 | ``` 69 | -------------------------------------------------------------------------------- /docs/locators.md: -------------------------------------------------------------------------------- 1 | # Locators 2 | 3 | Locators in Appwright are used to select and interact with elements within your mobile app. 4 | 5 | ## How to select an Element 6 | 7 | In Appwright, you can select an element on the screen using the `device` object. The `device` object provides various methods to locate elements by text, ID, or XPath. Here's how you can select elements: 8 | 9 | ### Get an element by Text 10 | 11 | You can use the `getByText` method to select elements by their visible text on the screen. 12 | 13 | ```ts 14 | const element = await device.getByText('Submit'); 15 | ``` 16 | 17 | Above method defaults to a substring match, and this can be overridden by setting the `exact` option to `true`. 18 | 19 | ```ts 20 | const element = await device.getByText('Submit', { exact: true }); 21 | ``` 22 | 23 | We can also use the `getByText` method to select elements using `Regex` patterns. 24 | 25 | ```ts 26 | const counter = device.getByText(/^Counter: \d+/); 27 | ``` 28 | 29 | ### Get an element by ID 30 | 31 | You can use the `getById` method to select elements by their ID on the screen. 32 | 33 | ```ts 34 | const element = await device.getById('signup_button'); 35 | ``` 36 | 37 | Above method defaults to a substring match, and this can be overridden by setting the `exact` option to `true`. 38 | 39 | ```ts 40 | const element = await device.getById('signup_button', { exact: true }); 41 | ``` 42 | 43 | ### Get an element by XPath 44 | 45 | You can use the `getByXpath` method to select elements by their XPath on the screen. 46 | 47 | ```ts 48 | const element = await device.getByXpath(`//android.widget.Button[@text="Confirm"]`); 49 | ``` 50 | 51 | ## How to Take Actions on the Element 52 | 53 | ### Tapping an element 54 | 55 | To tap an element, you can use the `tap` method. 56 | 57 | ```ts 58 | await device.getByText('Submit').tap(); 59 | ``` 60 | 61 | ### Enter text in a text field 62 | 63 | To enter text into an element, you can use the `fill` method. 64 | 65 | ```ts 66 | await device.getByText('Search').fill('Wikipedia'); 67 | ``` 68 | 69 | ### Sending key strokes to an element 70 | 71 | To send key strokes to an element, you can use the `sendKeyStrokes` method. 72 | 73 | ```ts 74 | await device.getByText('Search').sendKeyStrokes('Wikipedia'); 75 | ``` 76 | 77 | ### Extracting text from an element 78 | 79 | To extract text from an element, you can use the `getText` method. 80 | 81 | ```ts 82 | const text = await device.getByText('Playwright').getText(); 83 | ``` 84 | 85 | ## Check for visibility of an element 86 | 87 | To check if an element is visible on the screen, you can use the `isVisible` method. 88 | 89 | ```ts 90 | const isVisible = await device.getByText('Playwright').isVisible(); 91 | ``` 92 | 93 | ## Scroll screen 94 | 95 | To scroll the screen, you can use the `scroll` method. 96 | 97 | ```ts 98 | await device.getByText("Playwright").scroll(ScrollDirection.DOWN); 99 | ``` 100 | -------------------------------------------------------------------------------- /docs/persistent-device.md: -------------------------------------------------------------------------------- 1 | # Persistent device 2 | 3 | Appwright has a `device` fixture which works at the test-level. Every test gets a new Device 4 | and that ensures the environment for the test is pristine. If your tests require user logins, 5 | every test will need to do some login steps before the test starts. This adds to the test time. 6 | 7 | To speed this up, you can use `persistentDevice` fixture which works at the worker-level. Each 8 | worker runs 1 or more tests, and these tests can share the same device. This way, your tests 9 | can login once for the worker, and reuse that state for tests that run in the worker. 10 | 11 | Your suite can have a combination of both fixtures: 12 | - Use `device` for onboarding tests (to replicate fresh installs) 13 | - Use `persistentDevice` for post-login tests 14 | - This is optional: you can still use `device` if you want 15 | 16 | ## Concepts 17 | 18 | - Both fixtures `device` and `persistentDevice` return a `Device` object, so the test code 19 | is reusable. 20 | - Each `Device` maps to a WebDriver session internally. 21 | - `device` is created and teared down for every test 22 | - `persistentDevice` is created and teared down for every worker 23 | 24 | ## Usage 25 | 26 | ### Parallelism config 27 | 28 | By default, Appwright tests in a file are executed in sequential order. Appwright inherits 29 | the default value for the `fullyParallel` config parameter from the Playwright test runner 30 | where it is set to `false`. This means test files are run in parallel, but tests in a file 31 | run sequentially. 32 | 33 | You can set `fullyParallel: true` for your tests. This will create 1 or more workers, 34 | each with a `persistentDevice`. 35 | 36 | ### Structuring tests 37 | 38 | Assuming you have a bunch of tests already written in your test suite that 39 | use the `device` fixture. We will migrate them over to using `persistentDevice`. 40 | 41 | Steps to choose these tests 42 | - These tests must depend on the same user account 43 | - Semantically, it makes sense to locate these tests in one test file 44 | 45 | Steps to implement to group them 46 | - Move these tests to one file 47 | - Define a test-level worker for this group (e.g. `userDeviceForTxnTests`). This 48 | unique name gives you predictability that only one `persistentDevice` is used by this 49 | group of tests 50 | - Ensure ordering is what you want, while keeping in mind that: 51 | - Tests will follow this execution order in the worker, but in the case of a failure 52 | a new worker will restart the failing test (if it is to be retried) 53 | - If it is not to be retried, then a new worker will be created to run the next test 54 | 55 | ### Fixtures in your code 56 | 57 | We will use Playwright's [fixtures](https://playwright.dev/docs/test-fixtures) to use 58 | `persistentDevice`. 59 | 60 | - Create a worker fixture that depends on `persistentDevice`, say `userDevice` 61 | - In the `userDevice` fix, do the steps required to login for the first time 62 | - Then create a test fixture, `userDeviceForFoo` which has any reset steps that 63 | need to be run between tests (e.g. go back to home screen of the app) 64 | 65 | ```ts 66 | import { test as base } from "@playwright/test"; 67 | import { Device } from "appwright"; 68 | 69 | type WorkerFixtures = { 70 | userDevice: Device; 71 | } 72 | type TestFixtures = { 73 | userDeviceForFoo: Device; 74 | } 75 | 76 | export const test = base.extend({ 77 | 78 | // Worker fixture that knows how to login 79 | // This will be called for every worker 80 | userDevice: [async ({ persistentDevice }, use, workerInfo) => { 81 | // Do actions on the device 82 | await persistentDevice.getByText("Login").tap(); 83 | // ... 84 | // Once done, hand over this to the tests 85 | await use(persistentDevice); 86 | }, { scope: 'worker' }], 87 | 88 | // Test fixture that knows how to reset the app for a test 89 | // This will be called for every test 90 | userDeviceForFoo: async ({ userDevice }, use, testInfo) => { 91 | // Hand over the test to the test method 92 | await use(userDevice); 93 | // Reset the device 94 | // e.g. Click the back button 95 | await userDevice.getByText("Back").tap(); 96 | // Or restart the app on the device 97 | await userDevice.terminateApp(); 98 | await userDevice.activateApp(); 99 | }, 100 | 101 | }); 102 | ``` 103 | 104 | ### Using in the test 105 | 106 | Use the test fixture in your test. You can also wrap it into a page object model 107 | and pass that to the test. 108 | 109 | ```ts 110 | import { test } from './fixtures'; 111 | 112 | test("do first thing", async ({ userDeviceForFoo }) => { 113 | // This will run first 114 | // ... 115 | // And then do the reset steps in the userDeviceForFoo fixture 116 | }); 117 | 118 | test("do second thing", async ({ userDeviceForFoo }) => { 119 | // This will run next 120 | // ... 121 | // And then do the reset steps in the userDeviceForFoo fixture 122 | }); 123 | ``` 124 | 125 | Things to remember while writing the tests: 126 | 127 | - When a test fails inside a worker, a new worker will be created and the test 128 | will be restarted 129 | - This means that the nth test of a file can be the first test of a worker (e.g. when it 130 | it is retried after a failure) 131 | - All tests must be written in a way that they can: 132 | - Run after the previous test 133 | - Run as the first test in a worker 134 | 135 | ### Test reporting 136 | 137 | WIP: `persistentDevice` does not support video recordings. 138 | 139 | ## More info 140 | 141 | Learn more about [worker process](https://playwright.dev/docs/test-parallel) in the 142 | Playwright test runner. 143 | -------------------------------------------------------------------------------- /docs/vision.md: -------------------------------------------------------------------------------- 1 | # Vision methods 2 | 3 | Appwright provides a set of built-in methods to tap or extract information from the screen. These methods use LLM Capabilities to perform actions on the screen. 4 | 5 | ## Extract information from the screen 6 | 7 | The `query` method allows you to extract information from the screen based on a prompt. Ensure the `OPENAI_API_KEY` environment variable is set to authenticate the API request. 8 | 9 | ```ts 10 | const text = await device.beta.query("Extract the contact details present in the footer"); 11 | ``` 12 | 13 | By default, the `query` method returns a string. You can also specify a Zod schema to get the response in a specific format. 14 | 15 | ```ts 16 | const isLoginButtonVisible = await device.beta.query( 17 | `Is the login button visible on the screen?`, 18 | { 19 | responseFormat: z.boolean(), 20 | }, 21 | ); 22 | ``` 23 | 24 | ### Using custom screenshot 25 | 26 | By default, the query method retrieves information from the current screen. Alternatively, you can specify a screenshot to perform operations on that particular image. 27 | 28 | ```ts 29 | const text = await device.beta.query( 30 | "Extract contact details from the footer of this screenshot.", 31 | { 32 | screenshot: , 33 | }, 34 | ); 35 | ``` 36 | 37 | ### Using a different model 38 | 39 | By default, the `query` method uses the `gpt-4o-mini` model. You can also specify a different model. 40 | 41 | ```ts 42 | const text = await device.beta.query( 43 | `Extract contact details present in the footer`, 44 | { 45 | model: "gpt-4o", 46 | }, 47 | ); 48 | ``` 49 | 50 | ## Tap on the screen 51 | 52 | The `tap` method allows you to tap on the screen based on a prompt. Ensure the `EMPIRICAL_API_KEY` environment variable is set to authenticate the API request. 53 | 54 | ```ts 55 | await device.beta.tap("point at the 'Login' button."); 56 | ``` 57 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | wikipedia.app -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Appwright example 2 | 3 | This is a sample project to demonstrate how to use Appwright. This uses mobile apps from Wikipedia 4 | 5 | - [Wikipedia Android app](https://github.com/wikimedia/apps-android-wikipedia) ([Apache 2.0 license](https://github.com/wikimedia/apps-android-wikipedia?tab=Apache-2.0-1-ov-file#readme)) 6 | - [Wikipedia iOS app](https://github.com/wikimedia/wikipedia-ios) ([MIT license](https://github.com/wikimedia/wikipedia-ios?tab=MIT-1-ov-file#readme)) 7 | 8 | ## Usage 9 | 10 | ### Install dependencies 11 | 12 | ```sh 13 | npm install 14 | ``` 15 | 16 | ### Run the tests 17 | 18 | To run the tests on Android emulator: 19 | 20 | ```sh 21 | npx appwright test --project android 22 | ``` 23 | 24 | To run the tests on iOS simulator: 25 | 26 | - Unzip the `wikipedia.zip` file 27 | 28 | ```sh 29 | npm run extract:app 30 | ``` 31 | - Run the following command: 32 | 33 | ```sh 34 | npx appwright test --project ios 35 | ``` 36 | -------------------------------------------------------------------------------- /example/appwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, Platform } from "appwright"; 2 | import path from "path"; 3 | 4 | export default defineConfig({ 5 | projects: [ 6 | { 7 | name: "ios", 8 | use: { 9 | platform: Platform.IOS, 10 | device: { 11 | provider: "emulator", 12 | name: "iPhone 14 Pro", 13 | }, 14 | buildPath: path.join("builds", "Wikipedia.app"), 15 | }, 16 | }, 17 | { 18 | name: "android", 19 | use: { 20 | platform: Platform.ANDROID, 21 | device: { 22 | provider: "emulator", 23 | }, 24 | buildPath: path.join("builds", "wikipedia.apk"), 25 | }, 26 | }, 27 | ], 28 | }); 29 | -------------------------------------------------------------------------------- /example/builds/wikipedia.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/empirical-run/appwright/46ec258420d72aefbd4ab1be0e24ea5770ce1baa/example/builds/wikipedia.apk -------------------------------------------------------------------------------- /example/builds/wikipedia_ios.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/empirical-run/appwright/46ec258420d72aefbd4ab1be0e24ea5770ce1baa/example/builds/wikipedia_ios.zip -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "type": "module", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "extract:app": "node tools/extract" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "description": "", 14 | "devDependencies": { 15 | "appwright": "^0.1.1" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /example/tests/tests.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "appwright"; 2 | 3 | test("Open Playwright on Wikipedia and verify Microsoft is visible", async ({ 4 | device, 5 | }) => { 6 | // Dismiss splash screen 7 | await device.getByText("Skip").tap(); 8 | 9 | // Enter search term 10 | const searchInput = device.getByText("Search Wikipedia", { exact: true }); 11 | await searchInput.tap(); 12 | await searchInput.fill("playwright"); 13 | 14 | // Open search result and assert 15 | await device.getByText("Playwright (software)").tap(); 16 | await expect(device.getByText("Microsoft")).toBeVisible(); 17 | }); 18 | -------------------------------------------------------------------------------- /example/tools/extract.js: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process'; 2 | import { promisify } from 'util'; 3 | import fs from 'fs'; 4 | 5 | const execPromise = promisify(exec); 6 | 7 | const zipFile = './builds/wikipedia_ios.zip'; 8 | const extractedFolder = './builds/wikipedia_ios.app'; 9 | const appFile = './builds/wikipedia_ios.app/Wikipedia.app'; 10 | const destinationApp = './builds/Wikipedia.app'; 11 | 12 | async function extractApp() { 13 | try { 14 | await execPromise(`unzip -o ${zipFile} -d ${extractedFolder}`); 15 | await execPromise(`cp -r ${appFile} ${destinationApp}`); 16 | fs.rmSync(extractedFolder, { recursive: true }); 17 | } catch (error) { 18 | console.error(`extractApp: ${error.message}`); 19 | } 20 | } 21 | 22 | extractApp(); 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "appwright", 3 | "version": "0.1.46", 4 | "publishConfig": { 5 | "registry": "https://registry.npmjs.org/", 6 | "access": "public" 7 | }, 8 | "main": "dist/index.js", 9 | "engines": { 10 | "node": ">=18.20.4" 11 | }, 12 | "bin": { 13 | "appwright": "dist/bin/index.js" 14 | }, 15 | "scripts": { 16 | "lint": "eslint .", 17 | "test": "vitest --config ./src/tests/vitest.config.mts", 18 | "build": "tsc --build", 19 | "changeset": "changeset", 20 | "clean": "tsc --build --clean", 21 | "release": "changeset publish", 22 | "build:doc": "typedoc --out api-references src" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/empirical-run/appwright.git" 27 | }, 28 | "keywords": [ 29 | "e2e", 30 | "automation", 31 | "ios", 32 | "android", 33 | "testing" 34 | ], 35 | "author": "Empirical Team ", 36 | "license": "Apache-2.0", 37 | "description": "E2E mobile app testing done right, with the Playwright test runner", 38 | "dependencies": { 39 | "@empiricalrun/llm": "^0.9.25", 40 | "@ffmpeg-installer/ffmpeg": "^1.1.0", 41 | "@playwright/test": "^1.47.1", 42 | "appium": "^2.6.0", 43 | "appium-uiautomator2-driver": "^3.8.0", 44 | "appium-xcuitest-driver": "^7.27.0", 45 | "async-retry": "^1.3.3", 46 | "fluent-ffmpeg": "^2.1.3", 47 | "form-data": "4.0.0", 48 | "node-fetch": "^3.3.2", 49 | "picocolors": "^1.1.0", 50 | "webdriver": "^8.36.1" 51 | }, 52 | "devDependencies": { 53 | "@changesets/cli": "^2.27.8", 54 | "@empiricalrun/eslint-config": "^0.4.1", 55 | "@empiricalrun/typescript-config": "^0.3.0", 56 | "@types/async-retry": "^1.4.8", 57 | "@types/fluent-ffmpeg": "^2.1.26", 58 | "@types/node": "^22.5.2", 59 | "eslint": "8.57.0", 60 | "typedoc": "0.26.7", 61 | "vitest": "^2.1.3" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/bin/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { spawn } from "child_process"; 3 | import { logger } from "../logger"; 4 | 5 | function cmd( 6 | command: string[], 7 | options: { env?: Record }, 8 | ): Promise { 9 | let errorLogs: string[] = []; 10 | return new Promise((resolveFunc, rejectFunc) => { 11 | let p = spawn(command[0]!, command.slice(1), { 12 | env: { ...process.env, ...options.env }, 13 | }); 14 | p.stdout.on("data", (x) => { 15 | const log = x.toString(); 16 | if (log.includes("Error")) { 17 | errorLogs.push(log); 18 | } 19 | process.stdout.write(log); 20 | }); 21 | p.stderr.on("data", (x) => { 22 | const log = x.toString(); 23 | process.stderr.write(x.toString()); 24 | errorLogs.push(log); 25 | }); 26 | p.on("exit", (code) => { 27 | if (code != 0) { 28 | // assuming last log is the error message before exiting 29 | rejectFunc(errorLogs.slice(-3).join("\n")); 30 | } else { 31 | resolveFunc(code!); 32 | } 33 | }); 34 | }); 35 | } 36 | 37 | async function runPlaywrightCmd(args: string) { 38 | const pwRunCmd = `npx playwright ${args}`; 39 | return cmd(pwRunCmd.split(" "), {}); 40 | } 41 | 42 | (async function main() { 43 | const defaultConfigFile = `appwright.config.ts`; 44 | const pwOptions = process.argv.slice(2); 45 | if (!pwOptions.includes("--config")) { 46 | pwOptions.push(`--config`); 47 | pwOptions.push(defaultConfigFile); 48 | } 49 | try { 50 | await runPlaywrightCmd(pwOptions.join(" ")); 51 | } catch (error: any) { 52 | logger.error(`Error while running playwright test: ${error}`); 53 | process.exit(1); 54 | } 55 | })(); 56 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineConfig as defineConfigPlaywright, 3 | PlaywrightTestConfig, 4 | ReporterDescription, 5 | } from "@playwright/test"; 6 | import { AppwrightConfig } from "./types"; 7 | import path from "path"; 8 | import { logger } from "./logger"; 9 | 10 | const resolveGlobalSetup = () => { 11 | const pathToInstalledAppwright = require.resolve("."); 12 | const directory = path.dirname(pathToInstalledAppwright); 13 | return path.join(directory, "global-setup.js"); 14 | }; 15 | 16 | const resolveVideoReporter = () => { 17 | const pathToInstalledAppwright = require.resolve("."); 18 | const directory = path.dirname(pathToInstalledAppwright); 19 | return path.join(directory, "reporter.js"); 20 | }; 21 | 22 | const defaultConfig: PlaywrightTestConfig = { 23 | globalSetup: resolveGlobalSetup(), 24 | testDir: "./tests", 25 | // This is turned off so that a persistent device fixture can be 26 | // used across tests in a file where they run sequentially 27 | fullyParallel: false, 28 | forbidOnly: false, 29 | retries: process.env.CI ? 2 : 0, 30 | workers: 2, 31 | reporter: [["list"], ["html", { open: "always" }]], 32 | use: { 33 | // TODO: Use this for actions 34 | actionTimeout: 20_000, 35 | expectTimeout: 20_000, 36 | }, 37 | expect: { 38 | // This is not used right now 39 | timeout: 20_000, 40 | }, 41 | timeout: 0, 42 | }; 43 | 44 | export function defineConfig(config: PlaywrightTestConfig) { 45 | const hasGlobalSetup = config.globalSetup !== undefined; 46 | if (hasGlobalSetup) { 47 | logger.warn( 48 | "The `globalSetup` parameter in config will be ignored. See https://github.com/empirical-run/appwright/issues/57", 49 | ); 50 | delete config.globalSetup; 51 | } 52 | let reporterConfig: ReporterDescription[]; 53 | if (config.reporter) { 54 | reporterConfig = config.reporter as ReporterDescription[]; 55 | } else { 56 | reporterConfig = [["list"], ["html", { open: "always" }]]; 57 | } 58 | return defineConfigPlaywright({ 59 | ...defaultConfig, 60 | ...config, 61 | reporter: [[resolveVideoReporter()], ...reporterConfig], 62 | use: { 63 | ...defaultConfig.use, 64 | expectTimeout: config.use?.expectTimeout 65 | ? config.use!.expectTimeout 66 | : defaultConfig.use?.expectTimeout, 67 | }, 68 | }); 69 | } 70 | -------------------------------------------------------------------------------- /src/device/index.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore ts not able to identify the import is just an interface 2 | import type { Client as WebDriverClient } from "webdriver"; 3 | import { Locator } from "../locator"; 4 | import { 5 | AppwrightLocator, 6 | ExtractType, 7 | Platform, 8 | TimeoutOptions, 9 | } from "../types"; 10 | import { AppwrightVision, VisionProvider } from "../vision"; 11 | import { boxedStep, longestDeterministicGroup } from "../utils"; 12 | import { uploadImageToBS } from "../providers/browserstack/utils"; 13 | import { uploadImageToLambdaTest } from "../providers/lambdatest/utils"; 14 | import { z } from "zod"; 15 | import { LLMModel } from "@empiricalrun/llm"; 16 | import { logger } from "../logger"; 17 | 18 | export class Device { 19 | constructor( 20 | private webDriverClient: WebDriverClient, 21 | private bundleId: string | undefined, 22 | private timeoutOpts: TimeoutOptions, 23 | private provider: string, 24 | ) {} 25 | 26 | locator({ 27 | selector, 28 | findStrategy, 29 | textToMatch, 30 | }: { 31 | selector: string; 32 | findStrategy: string; 33 | textToMatch?: string | RegExp; 34 | }): AppwrightLocator { 35 | return new Locator( 36 | this.webDriverClient, 37 | this.timeoutOpts, 38 | selector, 39 | findStrategy, 40 | textToMatch, 41 | ); 42 | } 43 | 44 | private vision(): AppwrightVision { 45 | return new VisionProvider(this, this.webDriverClient); 46 | } 47 | 48 | beta = { 49 | tap: async ( 50 | prompt: string, 51 | options?: { 52 | useCache?: boolean; 53 | telemetry?: { 54 | tags?: string[]; 55 | }; 56 | }, 57 | ): Promise<{ x: number; y: number }> => { 58 | return await this.vision().tap(prompt, options); 59 | }, 60 | 61 | query: async ( 62 | prompt: string, 63 | options?: { 64 | responseFormat?: T; 65 | model?: LLMModel; 66 | screenshot?: string; 67 | telemetry?: { 68 | tags?: string[]; 69 | }; 70 | }, 71 | ): Promise> => { 72 | return await this.vision().query(prompt, options); 73 | }, 74 | }; 75 | 76 | /** 77 | * Closes the automation session. This is called automatically after each test. 78 | * 79 | * **Usage:** 80 | * ```js 81 | * await device.close(); 82 | * ``` 83 | */ 84 | async close() { 85 | // TODO: Add @boxedStep decorator here 86 | // Disabled because it breaks persistentDevice as test.step will throw as test is 87 | // undefined when the function is called 88 | try { 89 | await this.webDriverClient.deleteSession(); 90 | } catch (e) { 91 | logger.error(`close:`, e); 92 | } 93 | } 94 | 95 | /** 96 | * Tap on the screen at the given coordinates, specified as x and y. The top left corner 97 | * of the screen is { x: 0, y: 0 }. 98 | * 99 | * **Usage:** 100 | * ```js 101 | * await device.tap({ x: 100, y: 100 }); 102 | * ``` 103 | * 104 | * @param coordinates to tap on 105 | * @returns 106 | */ 107 | @boxedStep 108 | async tap({ x, y }: { x: number; y: number }) { 109 | if (this.getPlatform() == Platform.ANDROID) { 110 | await this.webDriverClient.executeScript("mobile: clickGesture", [ 111 | { 112 | x: x, 113 | y: y, 114 | duration: 100, 115 | tapCount: 1, 116 | }, 117 | ]); 118 | } else { 119 | await this.webDriverClient.executeScript("mobile: tap", [ 120 | { 121 | x: x, 122 | y: y, 123 | }, 124 | ]); 125 | } 126 | } 127 | 128 | /** 129 | * Locate an element on the screen with text content. This method defaults to a 130 | * substring match, and this be overridden by setting the `exact` option to `true`. 131 | * 132 | * **Usage:** 133 | * ```js 134 | * // with string 135 | * const submitButton = device.getByText("Submit"); 136 | * 137 | * // with RegExp 138 | * const counter = device.getByText(/^Counter: \d+/); 139 | * ``` 140 | * 141 | * @param text string or regular expression to search for 142 | * @param options 143 | * @returns 144 | */ 145 | getByText( 146 | text: string | RegExp, 147 | { exact = false }: { exact?: boolean } = {}, 148 | ): AppwrightLocator { 149 | const isAndroid = this.getPlatform() == Platform.ANDROID; 150 | if (text instanceof RegExp) { 151 | const substringForContains = longestDeterministicGroup(text); 152 | if (!substringForContains) { 153 | return this.locator({ 154 | selector: "//*", 155 | findStrategy: "xpath", 156 | textToMatch: text, 157 | }); 158 | } else { 159 | const selector = isAndroid 160 | ? `textContains("${substringForContains}")` 161 | : `label CONTAINS "${substringForContains}"`; 162 | return this.locator({ 163 | selector: selector, 164 | findStrategy: isAndroid 165 | ? "-android uiautomator" 166 | : "-ios predicate string", 167 | textToMatch: text, 168 | }); 169 | } 170 | } 171 | let path: string; 172 | if (isAndroid) { 173 | path = exact ? `text("${text}")` : `textContains("${text}")`; 174 | } else { 175 | path = exact ? `label == "${text}"` : `label CONTAINS "${text}"`; 176 | } 177 | return this.locator({ 178 | selector: path, 179 | findStrategy: isAndroid 180 | ? "-android uiautomator" 181 | : "-ios predicate string", 182 | textToMatch: text, 183 | }); 184 | } 185 | 186 | /** 187 | * Locate an element on the screen with accessibility identifier. This method defaults to 188 | * a substring match, and this can be overridden by setting the `exact` option to `true`. 189 | * 190 | * **Usage:** 191 | * ```js 192 | * const element = await device.getById("signup_button"); 193 | * ``` 194 | * 195 | * @param text string to search for 196 | * @param options 197 | * @returns 198 | */ 199 | getById( 200 | text: string, 201 | { exact = false }: { exact?: boolean } = {}, 202 | ): AppwrightLocator { 203 | const isAndroid = this.getPlatform() == Platform.ANDROID; 204 | let path: string; 205 | if (isAndroid) { 206 | path = exact ? `resourceId("${text}")` : `resourceIdMatches("${text}")`; 207 | } else { 208 | path = exact ? `name == "${text}"` : `name CONTAINS "${text}"`; 209 | } 210 | return this.locator({ 211 | selector: path, 212 | findStrategy: isAndroid 213 | ? "-android uiautomator" 214 | : "-ios predicate string", 215 | textToMatch: text, 216 | }); 217 | } 218 | 219 | /** 220 | * Locate an element on the screen with xpath. 221 | * 222 | * **Usage:** 223 | * ```js 224 | * const element = await device.getByXpath(`//android.widget.Button[@text="Confirm"]`); 225 | * ``` 226 | * 227 | * @param xpath xpath to locate the element 228 | * @returns 229 | */ 230 | getByXpath(xpath: string): AppwrightLocator { 231 | return this.locator({ selector: xpath, findStrategy: "xpath" }); 232 | } 233 | 234 | /** 235 | * Helper method to detect the mobile OS running on the device. 236 | * 237 | * **Usage:** 238 | * ```js 239 | * const platform = device.getPlatform(); 240 | * ``` 241 | * 242 | * @returns "android" or "ios" 243 | */ 244 | getPlatform(): Platform { 245 | const isAndroid = this.webDriverClient.isAndroid; 246 | return isAndroid ? Platform.ANDROID : Platform.IOS; 247 | } 248 | 249 | @boxedStep 250 | async terminateApp(bundleId?: string) { 251 | if (!this.bundleId && !bundleId) { 252 | throw new Error("bundleId is required to terminate the app."); 253 | } 254 | const keyName = 255 | this.getPlatform() == Platform.ANDROID ? "appId" : "bundleId"; 256 | await this.webDriverClient.executeScript("mobile: terminateApp", [ 257 | { 258 | [keyName]: bundleId || this.bundleId, 259 | }, 260 | ]); 261 | } 262 | 263 | @boxedStep 264 | async activateApp(bundleId?: string) { 265 | if (!this.bundleId && !bundleId) { 266 | throw new Error("bundleId is required to activate the app."); 267 | } 268 | const keyName = 269 | this.getPlatform() == Platform.ANDROID ? "appId" : "bundleId"; 270 | await this.webDriverClient.executeScript("mobile: activateApp", [ 271 | { 272 | [keyName]: bundleId || this.bundleId, 273 | }, 274 | ]); 275 | } 276 | 277 | /** 278 | * Retrieves text content from the clipboard of the mobile device. This is useful 279 | * after a "copy to clipboard" action has been performed. This returns base64 encoded string. 280 | * 281 | * **Usage:** 282 | * ```js 283 | * const clipboardText = await device.getClipboardText(); 284 | * ``` 285 | * 286 | * @returns Returns the text content of the clipboard in base64 encoded string. 287 | */ 288 | @boxedStep 289 | async getClipboardText(): Promise { 290 | if (this.getPlatform() == Platform.ANDROID) { 291 | return await this.webDriverClient.getClipboard(); 292 | } else { 293 | if (this.provider == "emulator") { 294 | // iOS simulator supports clipboard sharing 295 | return await this.webDriverClient.getClipboard(); 296 | } else { 297 | if (!this.bundleId) { 298 | throw new Error( 299 | "bundleId is required to retrieve clipboard data on a real device.", 300 | ); 301 | } 302 | await this.activateApp("com.facebook.WebDriverAgentRunner.xctrunner"); 303 | const clipboardDataBase64 = await this.webDriverClient.getClipboard(); 304 | await this.activateApp(this.bundleId); 305 | return clipboardDataBase64; 306 | } 307 | } 308 | } 309 | 310 | /** 311 | * Sets a mock camera view using the specified image. This injects a mock image into the camera view. 312 | * Currently, this functionality is supported only for BrowserStack. 313 | * 314 | * **Usage:** 315 | * ```js 316 | * await device.setMockCameraView(`screenshot.png`); 317 | * ``` 318 | * 319 | * @param imagePath path to the image file that will be used as the mock camera view. 320 | * @returns 321 | */ 322 | @boxedStep 323 | async setMockCameraView(imagePath: string): Promise { 324 | if (this.provider == "browserstack") { 325 | const imageURL = await uploadImageToBS(imagePath); 326 | await this.webDriverClient.executeScript( 327 | `browserstack_executor: {"action":"cameraImageInjection", "arguments": {"imageUrl" : "${imageURL}"}}`, 328 | [], 329 | ); 330 | } else if (this.provider == "lambdatest") { 331 | const imageURL = await uploadImageToLambdaTest(imagePath); 332 | await this.webDriverClient.executeScript( 333 | `lambda-image-injection=${imageURL}`, 334 | [], 335 | ); 336 | } 337 | } 338 | 339 | @boxedStep 340 | async pause() { 341 | const skipPause = process.env.CI === "true"; 342 | if (skipPause) { 343 | return; 344 | } 345 | logger.log(`device.pause: Use Appium Inspector to attach to the session.`); 346 | let iterations = 0; 347 | // eslint-disable-next-line no-constant-condition 348 | while (true) { 349 | await new Promise((resolve) => setTimeout(resolve, 20_000)); 350 | await this.webDriverClient.takeScreenshot(); 351 | iterations += 1; 352 | if (iterations % 3 === 0) { 353 | logger.log(`device.pause: ${iterations * 20} secs elapsed.`); 354 | } 355 | } 356 | } 357 | 358 | @boxedStep 359 | async waitForTimeout(timeout: number) { 360 | await new Promise((resolve) => setTimeout(resolve, timeout)); 361 | } 362 | 363 | /** 364 | * Get a screenshot of the current screen as a base64 encoded string. 365 | */ 366 | @boxedStep 367 | async screenshot(): Promise { 368 | return await this.webDriverClient.takeScreenshot(); 369 | } 370 | 371 | /** 372 | * [iOS Only] 373 | * Scroll the screen from 0.2 to 0.8 of the screen height. 374 | * This can be used for controlled scroll, for auto scroll checkout `scroll` method from locator. 375 | * 376 | * **Usage:** 377 | * ```js 378 | * await device.scroll(); 379 | * ``` 380 | * 381 | */ 382 | @boxedStep 383 | async scroll(): Promise { 384 | const driverSize = await this.webDriverClient.getWindowRect(); 385 | // Scrolls from 0.8 to 0.2 of the screen height 386 | const from = { x: driverSize.width / 2, y: driverSize.height * 0.8 }; 387 | const to = { x: driverSize.width / 2, y: driverSize.height * 0.2 }; 388 | await this.webDriverClient.executeScript("mobile: dragFromToForDuration", [ 389 | { duration: 2, fromX: from.x, fromY: from.y, toX: to.x, toY: to.y }, 390 | ]); 391 | } 392 | 393 | /** 394 | * Send keys to already focused input field. 395 | * To fill input fields using the selectors use `sendKeyStrokes` method from locator 396 | */ 397 | @boxedStep 398 | async sendKeyStrokes(value: string): Promise { 399 | const actions = value 400 | .split("") 401 | .map((char) => [ 402 | { type: "keyDown", value: char }, 403 | { type: "keyUp", value: char }, 404 | ]) 405 | .flat(); 406 | 407 | await this.webDriverClient.performActions([ 408 | { 409 | type: "key", 410 | id: "keyboard", 411 | actions: actions, 412 | }, 413 | ]); 414 | 415 | await this.webDriverClient.releaseActions(); 416 | } 417 | } 418 | -------------------------------------------------------------------------------- /src/fixture/index.ts: -------------------------------------------------------------------------------- 1 | import { test as base, FullProject } from "@playwright/test"; 2 | 3 | import { 4 | AppwrightLocator, 5 | DeviceProvider, 6 | ActionOptions, 7 | AppwrightConfig, 8 | } from "../types"; 9 | import { Device } from "../device"; 10 | import { createDeviceProvider } from "../providers"; 11 | import { WorkerInfoStore } from "./workerInfo"; 12 | import { stopAppiumServer } from "../providers/appium"; 13 | 14 | type TestLevelFixtures = { 15 | /** 16 | * Device provider to be used for the test. 17 | * This creates and manages the device lifecycle for the test 18 | */ 19 | deviceProvider: DeviceProvider; 20 | 21 | /** 22 | * The device instance that will be used for running the test. 23 | * This provides the functionality to interact with the device 24 | * during the test. 25 | */ 26 | device: Device; 27 | }; 28 | 29 | type WorkerLevelFixtures = { 30 | persistentDevice: Device; 31 | }; 32 | 33 | export const test = base.extend({ 34 | deviceProvider: async ({}, use, testInfo) => { 35 | const deviceProvider = createDeviceProvider(testInfo.project); 36 | await use(deviceProvider); 37 | }, 38 | device: async ({ deviceProvider }, use, testInfo) => { 39 | const device = await deviceProvider.getDevice(); 40 | const deviceProviderName = ( 41 | testInfo.project as FullProject 42 | ).use.device?.provider; 43 | testInfo.annotations.push({ 44 | type: "providerName", 45 | description: deviceProviderName, 46 | }); 47 | testInfo.annotations.push({ 48 | type: "sessionId", 49 | description: deviceProvider.sessionId, 50 | }); 51 | await deviceProvider.syncTestDetails?.({ name: testInfo.title }); 52 | await use(device); 53 | await device.close(); 54 | if ( 55 | deviceProviderName === "emulator" || 56 | deviceProviderName === "local-device" 57 | ) { 58 | await stopAppiumServer(); 59 | } 60 | await deviceProvider.syncTestDetails?.({ 61 | name: testInfo.title, 62 | status: testInfo.status, 63 | reason: testInfo.error?.message, 64 | }); 65 | }, 66 | persistentDevice: [ 67 | async ({}, use, workerInfo) => { 68 | const { project, workerIndex } = workerInfo; 69 | const beforeSession = new Date(); 70 | const deviceProvider = createDeviceProvider(project); 71 | const device = await deviceProvider.getDevice(); 72 | const sessionId = deviceProvider.sessionId; 73 | if (!sessionId) { 74 | throw new Error("Worker must have a sessionId."); 75 | } 76 | const providerName = (project as FullProject).use.device 77 | ?.provider; 78 | const afterSession = new Date(); 79 | const workerInfoStore = new WorkerInfoStore(); 80 | await workerInfoStore.saveWorkerStartTime( 81 | workerIndex, 82 | sessionId, 83 | providerName!, 84 | beforeSession, 85 | afterSession, 86 | ); 87 | await use(device); 88 | await workerInfoStore.saveWorkerEndTime(workerIndex, new Date()); 89 | await device.close(); 90 | }, 91 | { scope: "worker" }, 92 | ], 93 | }); 94 | 95 | /** 96 | * Function to extend Playwright’s expect assertion capabilities. 97 | * This adds a new method `toBeVisible` which checks if an element is visible on the screen. 98 | * 99 | * @param locator The AppwrightLocator that locates the element on the device screen. 100 | * @param options 101 | * @returns 102 | */ 103 | export const expect = test.expect.extend({ 104 | toBeVisible: async (locator: AppwrightLocator, options?: ActionOptions) => { 105 | const isVisible = await locator.isVisible(options); 106 | return { 107 | message: () => (isVisible ? "" : `Element was not found on the screen`), 108 | pass: isVisible, 109 | name: "toBeVisible", 110 | expected: true, 111 | actual: isVisible, 112 | }; 113 | }, 114 | }); 115 | -------------------------------------------------------------------------------- /src/fixture/workerInfo.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import { basePath } from "../utils"; 4 | 5 | type TestInWorkerInfo = { 6 | title: string; 7 | startTime: string; 8 | }; 9 | 10 | export type WorkerInfo = { 11 | idx: number; 12 | sessionId?: string | undefined; 13 | providerName?: string | undefined; 14 | startTime?: 15 | | { 16 | // Dates stored as ISO datetime strings 17 | beforeAppiumSession: string; 18 | afterAppiumSession: string; 19 | } 20 | | undefined; 21 | endTime?: string | undefined; 22 | tests: TestInWorkerInfo[]; 23 | }; 24 | 25 | export class WorkerInfoStore { 26 | private basePath: string; 27 | 28 | constructor() { 29 | this.basePath = basePath(); 30 | } 31 | 32 | async saveWorkerToDisk(idx: number, contents: WorkerInfo) { 33 | if (!fs.existsSync(this.basePath)) { 34 | fs.mkdirSync(this.basePath, { recursive: true }); 35 | } 36 | // TODO: can we make this file path unique for a session? 37 | // will avoidd ios/android running into issues when running concurrently on local 38 | fs.writeFileSync( 39 | path.join(this.basePath, `worker-info-${idx}.json`), 40 | JSON.stringify(contents, null, 2), 41 | ); 42 | } 43 | 44 | async getWorkerFromDisk(idx: number): Promise { 45 | const filePath = path.join(this.basePath, `worker-info-${idx}.json`); 46 | if (!fs.existsSync(filePath)) { 47 | return undefined; 48 | } 49 | return JSON.parse(fs.readFileSync(filePath, "utf-8")) as WorkerInfo; 50 | } 51 | 52 | async getWorkerStartTime(idx: number) { 53 | const info = await this.getWorkerFromDisk(idx); 54 | if (!info || !info.startTime) { 55 | throw new Error(`Worker start time info is not available.`); 56 | } 57 | return new Date(info.startTime.afterAppiumSession); 58 | } 59 | 60 | async saveWorkerStartTime( 61 | idx: number, 62 | sessionId: string, 63 | providerName: string, 64 | beforeAppiumSession: Date, 65 | afterAppiumSession: Date, 66 | ) { 67 | let info = await this.getWorkerFromDisk(idx); 68 | const delta = { 69 | providerName, 70 | sessionId, 71 | startTime: { 72 | beforeAppiumSession: beforeAppiumSession.toISOString(), 73 | afterAppiumSession: afterAppiumSession.toISOString(), 74 | }, 75 | }; 76 | if (!info) { 77 | info = { 78 | ...delta, 79 | idx, 80 | tests: [], 81 | }; 82 | } else { 83 | info = { 84 | ...info, 85 | ...delta, 86 | }; 87 | } 88 | return this.saveWorkerToDisk(idx, info); 89 | } 90 | 91 | async saveWorkerEndTime(idx: number, endTime: Date) { 92 | let info = await this.getWorkerFromDisk(idx); 93 | if (!info) { 94 | throw new Error(`Worker info not found for idx: ${idx}`); 95 | } 96 | info.endTime = endTime.toISOString(); 97 | return this.saveWorkerToDisk(idx, info); 98 | } 99 | 100 | async saveTestStartTime(idx: number, testTitle: string, startTime: Date) { 101 | let info = await this.getWorkerFromDisk(idx); 102 | if (!info) { 103 | info = { 104 | idx, 105 | tests: [{ title: testTitle, startTime: startTime.toISOString() }], 106 | }; 107 | } else { 108 | info.tests.push({ 109 | title: testTitle, 110 | startTime: startTime.toISOString(), 111 | }); 112 | } 113 | return this.saveWorkerToDisk(idx, info); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/global-setup.ts: -------------------------------------------------------------------------------- 1 | import { type FullConfig } from "@playwright/test"; 2 | import { AppwrightConfig } from "./types"; 3 | import { createDeviceProvider } from "./providers"; 4 | 5 | async function globalSetup(config: FullConfig) { 6 | const args = process.argv; 7 | const projects: string[] = []; 8 | args.forEach((arg, index) => { 9 | if (arg === "--project") { 10 | const project = args[index + 1]; 11 | if (project) { 12 | projects.push(project); 13 | } else { 14 | throw new Error("Project name is required with --project flag"); 15 | } 16 | } 17 | }); 18 | 19 | if (projects.length == 0) { 20 | // Capability to run all projects is not supported currently 21 | // This will be added after support for using same appium server for multiple projects is added 22 | throw new Error( 23 | "Capability to run all projects is not supported. Please specify the project name with --project flag.", 24 | ); 25 | } 26 | 27 | for (let i = 0; i < config.projects.length; i++) { 28 | if (projects.includes(config.projects[i]!.name)) { 29 | const provider = createDeviceProvider(config.projects[i]!); 30 | await provider.globalSetup?.(); 31 | } 32 | } 33 | } 34 | 35 | export default globalSetup; 36 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { test, expect } from "./fixture"; 2 | export { defineConfig } from "./config"; 3 | export { Device } from "./device"; 4 | export * from "./types"; 5 | -------------------------------------------------------------------------------- /src/locator/index.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore ts not able to identify the import is just an interface 2 | import { Client as WebDriverClient } from "webdriver"; 3 | import retry from "async-retry"; 4 | import { 5 | ElementReference, 6 | ScrollDirection, 7 | TimeoutOptions, 8 | ActionOptions, 9 | WebDriverErrors, 10 | } from "../types"; 11 | import { boxedStep } from "../utils"; 12 | import { RetryableError, TimeoutError } from "../types/errors"; 13 | 14 | export class Locator { 15 | constructor( 16 | private webDriverClient: WebDriverClient, 17 | private timeoutOpts: TimeoutOptions, 18 | // Used for find elements request that is sent to Appium server 19 | private selector: string, 20 | private findStrategy: string, 21 | // Used to filter elements received from Appium server 22 | private textToMatch?: string | RegExp, 23 | ) {} 24 | 25 | @boxedStep 26 | async fill(value: string, options?: ActionOptions): Promise { 27 | const isElementDisplayed = await this.isVisible(options); 28 | if (isElementDisplayed) { 29 | const element = await this.getElement(); 30 | if (element) { 31 | await this.webDriverClient.elementSendKeys( 32 | element["element-6066-11e4-a52e-4f735466cecf"], 33 | value, 34 | ); 35 | } else { 36 | throw new Error( 37 | `Failed to fill: Element "${this.selector}" is not found`, 38 | ); 39 | } 40 | } else { 41 | throw new Error(`Failed to fill: Element "${this.selector}" not visible`); 42 | } 43 | } 44 | 45 | @boxedStep 46 | async sendKeyStrokes(value: string, options?: ActionOptions): Promise { 47 | const isElementDisplayed = await this.isVisible(options); 48 | if (isElementDisplayed) { 49 | const element = await this.getElement(); 50 | if (element) { 51 | await this.webDriverClient.elementClick( 52 | element["element-6066-11e4-a52e-4f735466cecf"], 53 | ); 54 | const actions = value 55 | .split("") 56 | .map((char) => [ 57 | { type: "keyDown", value: char }, 58 | { type: "keyUp", value: char }, 59 | ]) 60 | .flat(); 61 | 62 | await this.webDriverClient.performActions([ 63 | { 64 | type: "key", 65 | id: "keyboard", 66 | actions: actions, 67 | }, 68 | ]); 69 | 70 | await this.webDriverClient.releaseActions(); 71 | } else { 72 | throw new Error( 73 | `Failed to sendKeyStrokes: Element "${this.selector}" is not found`, 74 | ); 75 | } 76 | } else { 77 | throw new Error( 78 | `Failed to sendKeyStrokes: Element "${this.selector}" not visible`, 79 | ); 80 | } 81 | } 82 | 83 | async isVisible(options?: ActionOptions): Promise { 84 | try { 85 | await this.waitFor("visible", options); 86 | return true; 87 | } catch (err) { 88 | if (err instanceof TimeoutError) { 89 | return false; 90 | } 91 | throw err; 92 | } 93 | } 94 | 95 | async waitFor( 96 | state: "attached" | "visible", 97 | options?: ActionOptions, 98 | ): Promise { 99 | const timeoutFromConfig = this.timeoutOpts.expectTimeout; 100 | const timeout = options?.timeout || timeoutFromConfig; 101 | const result = await this.waitUntil(async () => { 102 | const element = await this.getElement(); 103 | if (element && element["element-6066-11e4-a52e-4f735466cecf"]) { 104 | if (state === "attached") { 105 | return true; 106 | } else if (state === "visible") { 107 | try { 108 | const isDisplayed = await this.webDriverClient.isElementDisplayed( 109 | element["element-6066-11e4-a52e-4f735466cecf"], 110 | ); 111 | return isDisplayed; 112 | } catch (error) { 113 | //@ts-ignore 114 | const errName = error.name; 115 | if ( 116 | errName && 117 | errName.includes(WebDriverErrors.StaleElementReferenceError) 118 | ) { 119 | throw new RetryableError(`Stale element detected: ${error}`); 120 | } 121 | throw error; 122 | } 123 | } 124 | } 125 | return false; 126 | }, timeout); 127 | return result; 128 | } 129 | 130 | private async waitUntil( 131 | condition: () => ReturnValue | Promise, 132 | timeout: number, 133 | ): Promise> { 134 | const fn = condition.bind(this.webDriverClient); 135 | try { 136 | return await retry( 137 | async () => { 138 | const result = await fn(); 139 | if (result === false) { 140 | throw new RetryableError(`condition returned false`); 141 | } 142 | return result as Exclude; 143 | }, 144 | { 145 | maxRetryTime: timeout, 146 | retries: Math.ceil(timeout / 1000), 147 | factor: 1, 148 | }, 149 | ); 150 | } catch (err: unknown) { 151 | if (err instanceof RetryableError) { 152 | // Last attempt failed, no longer retryable 153 | throw new TimeoutError( 154 | `waitUntil condition timed out after ${timeout}ms`, 155 | ); 156 | } else { 157 | throw err; 158 | } 159 | } 160 | } 161 | 162 | @boxedStep 163 | async tap(options?: ActionOptions) { 164 | const isElementDisplayed = await this.isVisible(options); 165 | if (isElementDisplayed) { 166 | const element = await this.getElement(); 167 | if (element) { 168 | await this.webDriverClient.elementClick( 169 | element!["element-6066-11e4-a52e-4f735466cecf"], 170 | ); 171 | } else { 172 | throw new Error(`Failed to tap: Element "${this.selector}" not found`); 173 | } 174 | } else { 175 | throw new Error(`Failed to tap: Element "${this.selector}" not visible`); 176 | } 177 | } 178 | 179 | @boxedStep 180 | async getText(options?: ActionOptions): Promise { 181 | const isElementDisplayed = await this.isVisible(options); 182 | if (isElementDisplayed) { 183 | const element = await this.getElement(); 184 | if (element) { 185 | return await this.webDriverClient.getElementText( 186 | element!["element-6066-11e4-a52e-4f735466cecf"], 187 | ); 188 | } else { 189 | throw new Error( 190 | `Failed to getText: Element "${this.selector}" is not found`, 191 | ); 192 | } 193 | } else { 194 | throw new Error( 195 | `Failed to getText: Element "${this.selector}" not visible`, 196 | ); 197 | } 198 | } 199 | 200 | @boxedStep 201 | async scroll(direction: ScrollDirection) { 202 | const element = await this.getElement(); 203 | if (!element) { 204 | throw new Error(`Failed to scroll: Element "${this.selector}" not found`); 205 | } 206 | if (this.webDriverClient.isAndroid) { 207 | await this.webDriverClient.executeScript("mobile: scrollGesture", [ 208 | { 209 | elementId: element["element-6066-11e4-a52e-4f735466cecf"], 210 | direction: direction, 211 | percent: 1, 212 | }, 213 | ]); 214 | } else { 215 | await this.webDriverClient.executeScript("mobile: scroll", [ 216 | { 217 | elementId: element["element-6066-11e4-a52e-4f735466cecf"], 218 | direction: direction, 219 | }, 220 | ]); 221 | } 222 | } 223 | 224 | /** 225 | * Retrieves the element reference based on the `selector`. 226 | * 227 | * @returns 228 | */ 229 | async getElement(): Promise { 230 | /** 231 | * Determine whether `path` is a regex or string, and find elements accordingly. 232 | * 233 | * If `path` is a regex: 234 | * - Iterate through all the elements on the page 235 | * - Extract text content of each element 236 | * - Return the first matching element 237 | * 238 | * If `path` is a string: 239 | * - Use `findStrategy` (either XPath, Android UIAutomator, or iOS predicate string) to find elements 240 | * - Apply regex to clean extra characters from the matched element’s text 241 | * - Return the first element that matches 242 | */ 243 | let elements: ElementReference[] = await this.webDriverClient.findElements( 244 | this.findStrategy, 245 | this.selector, 246 | ); 247 | // If there is only one element, return it 248 | if (elements.length === 1) { 249 | return elements[0]!; 250 | } 251 | // If there are multiple elements, we reverse the order since the probability 252 | // of finding the element is higher at higher depth 253 | const reversedElements = elements.reverse(); 254 | for (const element of reversedElements) { 255 | let elementText = await this.webDriverClient.getElementText( 256 | element["element-6066-11e4-a52e-4f735466cecf"], 257 | ); 258 | if (this.textToMatch) { 259 | if ( 260 | this.textToMatch instanceof RegExp && 261 | this.textToMatch.test(elementText) 262 | ) { 263 | return element; 264 | } 265 | if ( 266 | typeof this.textToMatch === "string" && 267 | elementText.includes(this.textToMatch!) 268 | ) { 269 | return element; 270 | } 271 | } else { 272 | // This is returned for cases where xpath is findStrategy and we want 273 | // to return the last element found in the list 274 | return element; 275 | } 276 | } 277 | return null; 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import { cyan, red, yellow } from "picocolors"; 2 | 3 | class CustomLogger { 4 | log(...args: any[]) { 5 | console.log(cyan("[INFO]"), ...args); 6 | } 7 | 8 | warn(...args: any[]) { 9 | console.log(yellow("[WARN]"), ...args); 10 | } 11 | 12 | error(...args: any[]) { 13 | console.log(red("[ERROR]"), ...args); 14 | } 15 | 16 | logEmptyLine() { 17 | console.log("\n\n"); 18 | } 19 | } 20 | 21 | export const logger = new CustomLogger(); 22 | -------------------------------------------------------------------------------- /src/providers/appium.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcess, spawn, exec } from "child_process"; 2 | import path from "path"; 3 | import { Platform } from "../types"; 4 | import { logger } from "../logger"; 5 | import fs from "fs/promises"; 6 | import { promisify } from "util"; 7 | import { getLatestBuildToolsVersions } from "../utils"; 8 | 9 | const execPromise = promisify(exec); 10 | 11 | export async function installDriver(driverName: string): Promise { 12 | // uninstall the driver first to avoid conflicts 13 | await new Promise((resolve) => { 14 | const installProcess = spawn( 15 | "npx", 16 | ["appium", "driver", "uninstall", driverName], 17 | { 18 | stdio: "pipe", 19 | }, 20 | ); 21 | installProcess.on("exit", (code) => { 22 | resolve(code); 23 | }); 24 | }); 25 | // install the driver 26 | await new Promise((resolve) => { 27 | const installProcess = spawn( 28 | "npx", 29 | ["appium", "driver", "install", driverName], 30 | { 31 | stdio: "pipe", 32 | }, 33 | ); 34 | installProcess.on("exit", (code) => { 35 | resolve(code); 36 | }); 37 | }); 38 | } 39 | 40 | export async function startAppiumServer( 41 | provider: string, 42 | ): Promise { 43 | let emulatorStartRequested = false; 44 | return new Promise((resolve, reject) => { 45 | const appiumProcess = spawn("npx", ["appium"], { 46 | stdio: "pipe", 47 | }); 48 | appiumProcess.stderr.on("data", async (data: Buffer) => { 49 | console.log(data.toString()); 50 | }); 51 | appiumProcess.stdout.on("data", async (data: Buffer) => { 52 | const output = data.toString(); 53 | console.log(output); 54 | 55 | if (output.includes("Error: listen EADDRINUSE")) { 56 | // TODO: Kill the appium server if it is already running 57 | logger.error(`Appium: ${data}`); 58 | throw new Error( 59 | `Appium server is already running. Please stop the server before running tests.`, 60 | ); 61 | } 62 | 63 | if (output.includes("Could not find online devices")) { 64 | if (!emulatorStartRequested && provider == "emulator") { 65 | emulatorStartRequested = true; 66 | await startAndroidEmulator(); 67 | } 68 | } 69 | 70 | if (output.includes("Appium REST http interface listener started")) { 71 | logger.log("Appium server is up and running."); 72 | resolve(appiumProcess); 73 | } 74 | }); 75 | 76 | appiumProcess.on("error", (error) => { 77 | logger.error(`Appium: ${error}`); 78 | reject(error); 79 | }); 80 | 81 | process.on("exit", () => { 82 | logger.log("Main process exiting. Killing Appium server..."); 83 | appiumProcess.kill(); 84 | }); 85 | 86 | appiumProcess.on("close", (code: number) => { 87 | logger.log(`Appium server exited with code ${code}`); 88 | }); 89 | }); 90 | } 91 | 92 | export function stopAppiumServer() { 93 | return new Promise((resolve, reject) => { 94 | exec(`pkill -f appium`, (error, stdout) => { 95 | if (error) { 96 | logger.error(`Error stopping Appium server: ${error.message}`); 97 | reject(error); 98 | } 99 | logger.log("Appium server stopped successfully."); 100 | resolve(stdout); 101 | }); 102 | }); 103 | } 104 | 105 | export function isEmulatorInstalled(platform: Platform): Promise { 106 | return new Promise((resolve) => { 107 | if (platform == Platform.ANDROID) { 108 | const androidHome = process.env.ANDROID_HOME; 109 | 110 | const emulatorPath = path.join(androidHome!, "emulator", "emulator"); 111 | exec(`${emulatorPath} -list-avds`, (error, stdout, stderr) => { 112 | if (error) { 113 | throw new Error( 114 | `Error fetching emulator list.\nPlease install emulator from Android SDK Tools. 115 | Follow this guide to install emulators: https://community.neptune-software.com/topics/tips--tricks/blogs/how-to-install--android-emulator-without--android--st`, 116 | ); 117 | } 118 | if (stderr) { 119 | logger.error(`Emulator: ${stderr}`); 120 | } 121 | 122 | const lines = stdout.trim().split("\n"); 123 | 124 | const deviceNames = lines.filter( 125 | (line) => 126 | line.trim() && !line.startsWith("INFO") && !line.includes("/tmp/"), 127 | ); 128 | 129 | if (deviceNames.length > 0) { 130 | resolve(true); 131 | } else { 132 | throw new Error( 133 | `No installed emulators found. 134 | Follow this guide to install emulators: https://community.neptune-software.com/topics/tips--tricks/blogs/how-to-install--android-emulator-without--android--st`, 135 | ); 136 | } 137 | }); 138 | } 139 | }); 140 | } 141 | 142 | export async function startAndroidEmulator(): Promise { 143 | return new Promise((resolve, reject) => { 144 | const androidHome = process.env.ANDROID_HOME; 145 | 146 | const emulatorPath = path.join(androidHome!, "emulator", "emulator"); 147 | 148 | exec(`${emulatorPath} -list-avds`, (error, stdout, stderr) => { 149 | if (error) { 150 | throw new Error( 151 | `Error fetching emulator list.\nPlease install emulator from Android SDK Tools.\nFollow this guide to install emulators: https://community.neptune-software.com/topics/tips--tricks/blogs/how-to-install--android-emulator-without--android--st`, 152 | ); 153 | } 154 | if (stderr) { 155 | logger.error(`Emulator: ${stderr}`); 156 | } 157 | 158 | const lines = stdout.trim().split("\n"); 159 | 160 | // Filter out lines that do not contain device names 161 | const deviceNames = lines.filter( 162 | (line) => 163 | line.trim() && !line.startsWith("INFO") && !line.includes("/tmp/"), 164 | ); 165 | 166 | if (deviceNames.length === 0) { 167 | throw new Error( 168 | `No installed emulators found.\nFollow this guide to install emulators: https://community.neptune-software.com/topics/tips--tricks/blogs/how-to-install--android-emulator-without--android--st`, 169 | ); 170 | } else { 171 | logger.log(`Available Emulators: ${deviceNames}`); 172 | } 173 | 174 | const emulatorToStart = deviceNames[0]; 175 | 176 | const emulatorProcess = spawn(emulatorPath, ["-avd", emulatorToStart!], { 177 | stdio: "pipe", 178 | }); 179 | 180 | emulatorProcess.stdout?.on("data", (data) => { 181 | logger.log(`Emulator: ${data}`); 182 | 183 | if (data.includes("Successfully loaded snapshot 'default_boot'")) { 184 | logger.log("Emulator started successfully."); 185 | resolve(); 186 | } 187 | }); 188 | 189 | emulatorProcess.on("error", (err) => { 190 | logger.error(`Emulator: ${err.message}`); 191 | reject(`Failed to start emulator: ${err.message}`); 192 | }); 193 | 194 | emulatorProcess.on("close", (code) => { 195 | if (code !== 0) { 196 | reject(`Emulator process exited with code: ${code}`); 197 | } 198 | }); 199 | 200 | // Ensure the emulator process is killed when the main process exits 201 | process.on("exit", () => { 202 | logger.log("Main process exiting. Killing the emulator process..."); 203 | emulatorProcess.kill(); 204 | }); 205 | }); 206 | }); 207 | } 208 | 209 | export function getAppBundleId(path: string): Promise { 210 | return new Promise((resolve, reject) => { 211 | const command = `osascript -e 'id of app "${path}"'`; 212 | exec(command, (error, stdout, stderr) => { 213 | if (error) { 214 | logger.error("osascript:", error.message); 215 | return reject(error); 216 | } 217 | if (stderr) { 218 | logger.error(`osascript: ${stderr}`); 219 | return reject(new Error(stderr)); 220 | } 221 | const bundleId = stdout.trim(); 222 | if (bundleId) { 223 | resolve(bundleId); 224 | } else { 225 | reject(new Error("Bundle ID not found")); 226 | } 227 | }); 228 | }); 229 | } 230 | 231 | export async function getConnectedIOSDeviceUDID(): Promise { 232 | try { 233 | const { stdout } = await execPromise(`xcrun xctrace list devices`); 234 | 235 | const iphoneDevices = stdout 236 | .split("\n") 237 | .filter((line) => line.includes("iPhone")); 238 | 239 | const realDevices = iphoneDevices.filter( 240 | (line) => !line.includes("Simulator"), 241 | ); 242 | 243 | if (!realDevices.length) { 244 | throw new Error( 245 | `No connected iPhone detected. Please ensure your device is connected and try again.`, 246 | ); 247 | } 248 | 249 | const deviceLine = realDevices[0]; 250 | //the output from above looks like this: User’s iPhone (18.0) (00003110-002A304e3A53C41E) 251 | //where `00003110-000A304e3A53C41E` is the UDID of the device 252 | const matches = deviceLine!.match(/\(([\da-fA-F-]+)\)$/); 253 | 254 | if (matches && matches[1]) { 255 | return matches[1]; 256 | } else { 257 | throw new Error( 258 | `Please check your iPhone device connection. 259 | To check for connected devices run "xcrun xctrace list devices | grep iPhone | grep -v Simulator"`, 260 | ); 261 | } 262 | } catch (error) { 263 | //@ts-ignore 264 | throw new Error(`getConnectedIOSDeviceUDID: ${error.message}`); 265 | } 266 | } 267 | 268 | export async function getActiveAndroidDevices(): Promise { 269 | try { 270 | const { stdout } = await execPromise("adb devices"); 271 | 272 | const lines = stdout.trim().split("\n"); 273 | 274 | const deviceLines = lines.filter((line) => line.includes("\tdevice")); 275 | 276 | return deviceLines.length; 277 | } catch (error) { 278 | throw new Error( 279 | //@ts-ignore 280 | `getActiveAndroidDevices: ${error.message}`, 281 | ); 282 | } 283 | } 284 | async function getLatestBuildToolsVersion(): Promise { 285 | const androidHome = process.env.ANDROID_HOME; 286 | const buildToolsPath = path.join(androidHome!, "build-tools"); 287 | try { 288 | const files = await fs.readdir(buildToolsPath); 289 | 290 | const versions = files.filter((file) => 291 | /^\d+\.\d+\.\d+(-rc\d+)?$/.test(file), 292 | ); 293 | 294 | if (versions.length === 0) { 295 | throw new Error( 296 | `No valid build-tools found in ${buildToolsPath}. Please download from Android Studio: https://developer.android.com/studio/intro/update#required`, 297 | ); 298 | } 299 | 300 | return getLatestBuildToolsVersions(versions); 301 | } catch (err) { 302 | logger.error(`getLatestBuildToolsVersion: ${err}`); 303 | throw new Error( 304 | `Error reading ${buildToolsPath}. Ensure it exists or download from Android Studio: https://developer.android.com/studio/intro/update#required`, 305 | ); 306 | } 307 | } 308 | 309 | export async function getApkDetails(buildPath: string): Promise<{ 310 | packageName: string | undefined; 311 | launchableActivity: string | undefined; 312 | }> { 313 | const androidHome = process.env.ANDROID_HOME; 314 | const buildToolsVersion = await getLatestBuildToolsVersion(); 315 | 316 | if (!buildToolsVersion) { 317 | throw new Error( 318 | `No valid build-tools found in ${buildToolsVersion}. Please download from Android Studio: https://developer.android.com/studio/intro/update#required`, 319 | ); 320 | } 321 | 322 | const aaptPath = path.join( 323 | androidHome!, 324 | "build-tools", 325 | buildToolsVersion!, 326 | "aapt", 327 | ); 328 | const command = `${aaptPath} dump badging ${buildPath}`; 329 | 330 | try { 331 | const { stdout, stderr } = await execPromise(command); 332 | 333 | if (stderr) { 334 | logger.error(`getApkDetails: ${stderr}`); 335 | throw new Error(`Error executing aapt: ${stderr}`); 336 | } 337 | 338 | const packageMatch = stdout.match(/package: name='(\S+)'/); 339 | const activityMatch = stdout.match(/launchable-activity: name='(\S+)'/); 340 | 341 | if (!packageMatch || !activityMatch) { 342 | throw new Error( 343 | `Unable to retrieve package or launchable activity from the APK. Please verify that the provided file is a valid APK.`, 344 | ); 345 | } 346 | 347 | const packageName = packageMatch[1]; 348 | const launchableActivity = activityMatch[1]; 349 | 350 | return { packageName, launchableActivity }; 351 | } catch (error: any) { 352 | throw new Error(`getApkDetails: ${error.message}`); 353 | } 354 | } 355 | -------------------------------------------------------------------------------- /src/providers/browserstack/index.ts: -------------------------------------------------------------------------------- 1 | import retry from "async-retry"; 2 | import fs from "fs"; 3 | import FormData from "form-data"; 4 | import path from "path"; 5 | import { 6 | AppwrightConfig, 7 | DeviceProvider, 8 | BrowserStackConfig, 9 | } from "../../types"; 10 | import { FullProject } from "@playwright/test"; 11 | import { Device } from "../../device"; 12 | import { logger } from "../../logger"; 13 | 14 | type BrowserStackSessionDetails = { 15 | name: string; 16 | duration: number; 17 | os: string; 18 | os_version: string; 19 | device: string; 20 | status: string; 21 | reason: string; 22 | build_name: string; 23 | project_name: string; 24 | logs: string; 25 | public_url: string; 26 | appium_logs_url: string; 27 | video_url: string; 28 | device_logs_url: string; 29 | app_details: { 30 | app_url: string; 31 | app_name: string; 32 | app_version: string; 33 | app_custom_id: string; 34 | uploaded_at: string; 35 | }; 36 | }; 37 | 38 | const API_BASE_URL = "https://api-cloud.browserstack.com/app-automate"; 39 | 40 | const envVarKeyForBuild = (projectName: string) => 41 | `BROWSERSTACK_APP_URL_${projectName.toUpperCase()}`; 42 | 43 | function getAuthHeader() { 44 | const userName = process.env.BROWSERSTACK_USERNAME; 45 | const accessKey = process.env.BROWSERSTACK_ACCESS_KEY; 46 | const key = Buffer.from(`${userName}:${accessKey}`).toString("base64"); 47 | return `Basic ${key}`; 48 | } 49 | 50 | async function getSessionDetails(sessionId: string) { 51 | const response = await fetch(`${API_BASE_URL}/sessions/${sessionId}.json`, { 52 | method: "GET", 53 | headers: { 54 | Authorization: getAuthHeader(), 55 | }, 56 | }); 57 | if (!response.ok) { 58 | throw new Error(`Error fetching session details: ${response.statusText}`); 59 | } 60 | const data = await response.json(); 61 | return data; 62 | } 63 | 64 | export class BrowserStackDeviceProvider implements DeviceProvider { 65 | private sessionDetails?: BrowserStackSessionDetails; 66 | sessionId?: string; 67 | private project: FullProject; 68 | 69 | constructor( 70 | project: FullProject, 71 | appBundleId: string | undefined, 72 | ) { 73 | this.project = project; 74 | if (appBundleId) { 75 | logger.log( 76 | `Bundle id is specified (${appBundleId}) but ignored for BrowserStack provider.`, 77 | ); 78 | } 79 | } 80 | 81 | async globalSetup() { 82 | if (!this.project.use.buildPath) { 83 | throw new Error( 84 | `Build path not found. Please set the build path in the config file.`, 85 | ); 86 | } 87 | if ( 88 | !( 89 | process.env.BROWSERSTACK_USERNAME && process.env.BROWSERSTACK_ACCESS_KEY 90 | ) 91 | ) { 92 | throw new Error( 93 | "BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY are required environment variables for this device provider.", 94 | ); 95 | } 96 | const buildPath = this.project.use.buildPath!; 97 | const isHttpUrl = buildPath.startsWith("http"); 98 | const isBrowserStackUrl = buildPath.startsWith("bs://"); 99 | let appUrl: string | undefined = undefined; 100 | if (isBrowserStackUrl) { 101 | appUrl = buildPath; 102 | } else { 103 | // Upload the file to BrowserStack and get the appUrl 104 | let body; 105 | let headers = { 106 | Authorization: getAuthHeader(), 107 | }; 108 | if (isHttpUrl) { 109 | body = new URLSearchParams({ 110 | url: buildPath, 111 | }); 112 | } else { 113 | if (!fs.existsSync(buildPath)) { 114 | throw new Error(`Build file not found: ${buildPath}`); 115 | } 116 | const form = new FormData(); 117 | form.append("file", fs.createReadStream(buildPath)); 118 | headers = { ...headers, ...form.getHeaders() }; 119 | body = form; 120 | } 121 | const fetch = (await import("node-fetch")).default; 122 | logger.log(`Uploading: ${buildPath}`); 123 | const response = await fetch(`${API_BASE_URL}/upload`, { 124 | method: "POST", 125 | headers, 126 | body, 127 | }); 128 | const data = await response.json(); 129 | appUrl = (data as any).app_url; 130 | if (!appUrl) { 131 | logger.error("Uploading the build failed:", data); 132 | } 133 | } 134 | process.env[envVarKeyForBuild(this.project.name)] = appUrl; 135 | } 136 | 137 | async getDevice(): Promise { 138 | this.validateConfig(); 139 | const config = this.createConfig(); 140 | return await this.createDriver(config); 141 | } 142 | 143 | private validateConfig() { 144 | const device = this.project.use.device as BrowserStackConfig; 145 | if (!device.name || !device.osVersion) { 146 | throw new Error( 147 | "Device name and osVersion are required for running tests on BrowserStack", 148 | ); 149 | } 150 | } 151 | 152 | private async createDriver(config: any): Promise { 153 | const WebDriver = (await import("webdriver")).default; 154 | const webDriverClient = await WebDriver.newSession(config); 155 | this.sessionId = webDriverClient.sessionId; 156 | const bundleId = await this.getAppBundleIdFromSession(); 157 | const testOptions = { 158 | expectTimeout: this.project.use.expectTimeout!, 159 | }; 160 | return new Device( 161 | webDriverClient, 162 | bundleId, 163 | testOptions, 164 | this.project.use.device?.provider!, 165 | ); 166 | } 167 | 168 | private async getSessionDetails() { 169 | const data = await getSessionDetails(this.sessionId!); 170 | this.sessionDetails = data.automation_session; 171 | } 172 | 173 | private async getAppBundleIdFromSession(): Promise { 174 | await this.getSessionDetails(); 175 | return this.sessionDetails?.app_details.app_name ?? ""; 176 | } 177 | 178 | static async downloadVideo( 179 | sessionId: string, 180 | outputDir: string, 181 | fileName: string, 182 | ): Promise<{ path: string; contentType: string } | null> { 183 | const sessionData = await getSessionDetails(sessionId); 184 | const sessionDetails = sessionData?.automation_session; 185 | const videoURL = sessionDetails?.video_url; 186 | const pathToTestVideo = path.join(outputDir, `${fileName}.mp4`); 187 | const tempPathForWriting = `${pathToTestVideo}.part`; 188 | const dir = path.dirname(pathToTestVideo); 189 | fs.mkdirSync(dir, { recursive: true }); 190 | /** 191 | * The BrowserStack video URL initially returns a 200 status, 192 | * but the video file may still be empty. To avoid downloading 193 | * an incomplete file, we introduce a delay of 10_000 ms before attempting the download. 194 | * After the wait, BrowserStack may return a 403 error if the video is not 195 | * yet available. We handle this by retrying the download until we either 196 | * receive a 200 response (indicating the video is ready) or reach a maximum 197 | * of 10 retries, whichever comes first. 198 | */ 199 | await new Promise((resolve) => setTimeout(resolve, 10_000)); 200 | const fileStream = fs.createWriteStream(tempPathForWriting); 201 | //To catch the browserstack error in case all retries fails 202 | try { 203 | if (videoURL) { 204 | await retry( 205 | async () => { 206 | const response = await fetch(videoURL, { 207 | method: "GET", 208 | }); 209 | if (response.status !== 200) { 210 | // Retry if not 200 211 | throw new Error( 212 | `Video not found: ${response.status} (URL: ${videoURL})`, 213 | ); 214 | } 215 | const reader = response.body?.getReader(); 216 | if (!reader) { 217 | throw new Error("Failed to get reader from response body."); 218 | } 219 | const streamToFile = async () => { 220 | // eslint-disable-next-line no-constant-condition 221 | while (true) { 222 | const { done, value } = await reader.read(); 223 | if (done) break; 224 | fileStream.write(value); 225 | } 226 | }; 227 | await streamToFile(); 228 | fileStream.close(); 229 | }, 230 | { 231 | retries: 10, 232 | minTimeout: 3_000, 233 | onRetry: (err, i) => { 234 | if (i > 5) { 235 | logger.warn(`Retry attempt ${i} failed: ${err.message}`); 236 | } 237 | }, 238 | }, 239 | ); 240 | return new Promise((resolve, reject) => { 241 | // Ensure file stream is closed even in case of an error 242 | fileStream.on("finish", () => { 243 | try { 244 | fs.renameSync(tempPathForWriting, pathToTestVideo); 245 | logger.log( 246 | `Download finished and file closed: ${pathToTestVideo}`, 247 | ); 248 | resolve({ path: pathToTestVideo, contentType: "video/mp4" }); 249 | } catch (err) { 250 | logger.error(`Failed to rename file: `, err); 251 | reject(err); 252 | } 253 | }); 254 | 255 | fileStream.on("error", (err) => { 256 | logger.error(`Failed to write file: ${err.message}`); 257 | reject(err); 258 | }); 259 | }); 260 | } else { 261 | return null; 262 | } 263 | } catch (e) { 264 | logger.log(`Error Downloading video: `, e); 265 | return null; 266 | } 267 | } 268 | 269 | async syncTestDetails(details: { 270 | status?: string; 271 | reason?: string; 272 | name?: string; 273 | }) { 274 | const response = await fetch( 275 | `${API_BASE_URL}/sessions/${this.sessionId}.json`, 276 | { 277 | method: "PUT", 278 | headers: { 279 | Authorization: getAuthHeader(), 280 | "Content-Type": "application/json", 281 | }, 282 | body: details.status 283 | ? JSON.stringify({ 284 | status: details.status, 285 | reason: details.reason, 286 | }) 287 | : JSON.stringify({ 288 | name: details.name, 289 | }), 290 | }, 291 | ); 292 | if (!response.ok) { 293 | throw new Error(`Error setting session details: ${response.statusText}`); 294 | } 295 | 296 | const responseData = await response.json(); 297 | return responseData; 298 | } 299 | 300 | private createConfig() { 301 | const platformName = this.project.use.platform; 302 | const projectName = path.basename(process.cwd()); 303 | const envVarKey = envVarKeyForBuild(this.project.name); 304 | if (!process.env[envVarKey]) { 305 | throw new Error( 306 | `process.env.${envVarKey} is not set. Did the file upload work?`, 307 | ); 308 | } 309 | return { 310 | port: 443, 311 | path: "/wd/hub", 312 | protocol: "https", 313 | logLevel: "warn", 314 | user: process.env.BROWSERSTACK_USERNAME, 315 | key: process.env.BROWSERSTACK_ACCESS_KEY, 316 | hostname: "hub.browserstack.com", 317 | capabilities: { 318 | "bstack:options": { 319 | debug: true, 320 | interactiveDebugging: true, 321 | networkLogs: true, 322 | appiumVersion: "2.6.0", 323 | enableCameraImageInjection: ( 324 | this.project.use.device as BrowserStackConfig 325 | )?.enableCameraImageInjection, 326 | idleTimeout: 180, 327 | deviceName: this.project.use.device?.name, 328 | osVersion: (this.project.use.device as BrowserStackConfig).osVersion, 329 | platformName: platformName, 330 | deviceOrientation: this.project.use.device?.orientation, 331 | buildName: `${projectName} ${platformName}`, 332 | sessionName: `${projectName} ${platformName} test`, 333 | buildIdentifier: 334 | process.env.GITHUB_ACTIONS === "true" 335 | ? `CI ${process.env.GITHUB_RUN_ID}` 336 | : process.env.USER, 337 | }, 338 | "appium:autoGrantPermissions": true, 339 | "appium:app": process.env[envVarKey], 340 | "appium:autoAcceptAlerts": true, 341 | "appium:fullReset": true, 342 | "appium:settings[snapshotMaxDepth]": 62, 343 | }, 344 | }; 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /src/providers/browserstack/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import FormData from "form-data"; 3 | 4 | function getAuthHeader() { 5 | const userName = process.env.BROWSERSTACK_USERNAME; 6 | const accessKey = process.env.BROWSERSTACK_ACCESS_KEY; 7 | const key = Buffer.from(`${userName}:${accessKey}`).toString("base64"); 8 | return `Basic ${key}`; 9 | } 10 | 11 | interface MediaUploadResponse { 12 | media_url: string; 13 | custom_id: string; 14 | shareable_id: string; 15 | } 16 | 17 | export async function uploadImageToBS(imagePath: string): Promise { 18 | const formData = new FormData(); 19 | if (!fs.existsSync(imagePath)) { 20 | throw new Error( 21 | `No image file found at the specified path: ${imagePath}. Please provide a valid image file. 22 | Supported formats include JPG, JPEG, and PNG. Ensure the file exists and the path is correct.`, 23 | ); 24 | } 25 | formData.append("file", fs.createReadStream(imagePath)); 26 | formData.append("custom_id", "SampleMedia"); 27 | const fetch = (await import("node-fetch")).default; 28 | const response = await fetch( 29 | "https://api-cloud.browserstack.com/app-automate/upload-media", 30 | { 31 | method: "POST", 32 | headers: { 33 | Authorization: getAuthHeader(), 34 | }, 35 | body: formData, 36 | }, 37 | ); 38 | 39 | const data = (await response.json()) as MediaUploadResponse; 40 | const imageURL = data.media_url.trim(); 41 | return imageURL; 42 | } 43 | -------------------------------------------------------------------------------- /src/providers/emulator/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AppwrightConfig, 3 | DeviceProvider, 4 | EmulatorConfig, 5 | Platform, 6 | TimeoutOptions, 7 | } from "../../types"; 8 | import { Device } from "../../device"; 9 | import { 10 | getApkDetails, 11 | installDriver, 12 | isEmulatorInstalled, 13 | startAppiumServer, 14 | } from "../appium"; 15 | import { FullProject } from "@playwright/test"; 16 | import { validateBuildPath } from "../../utils"; 17 | import { logger } from "../../logger"; 18 | 19 | export class EmulatorProvider implements DeviceProvider { 20 | sessionId?: string; 21 | 22 | constructor( 23 | private project: FullProject, 24 | appBundleId: string | undefined, 25 | ) { 26 | if (appBundleId) { 27 | logger.log( 28 | `Bundle id is specified (${appBundleId}) but ignored for Emulator provider.`, 29 | ); 30 | } 31 | } 32 | 33 | async getDevice(): Promise { 34 | return await this.createDriver(); 35 | } 36 | 37 | async globalSetup() { 38 | validateBuildPath( 39 | this.project.use.buildPath, 40 | this.project.use.platform == Platform.ANDROID ? ".apk" : ".app", 41 | ); 42 | if (this.project.use.platform == Platform.ANDROID) { 43 | const androidHome = process.env.ANDROID_HOME; 44 | const androidSimulatorConfigDocLink = 45 | "https://github.com/empirical-run/appwright/blob/main/docs/config.md#android-emulator"; 46 | if (!androidHome) { 47 | throw new Error( 48 | `The ANDROID_HOME environment variable is not set. 49 | This variable is required to locate your Android SDK. 50 | Please set it to the correct path of your Android SDK installation. 51 | Follow the steps mentioned in ${androidSimulatorConfigDocLink} to run test on Android emulator.`, 52 | ); 53 | } 54 | 55 | const javaHome = process.env.JAVA_HOME; 56 | if (!javaHome) { 57 | throw new Error( 58 | `The JAVA_HOME environment variable is not set. 59 | Follow the steps mentioned in ${androidSimulatorConfigDocLink} to run test on Android emulator.`, 60 | ); 61 | } 62 | 63 | await isEmulatorInstalled(this.project.use.platform); 64 | } 65 | } 66 | 67 | private async createDriver(): Promise { 68 | await installDriver( 69 | this.project.use.platform == Platform.ANDROID 70 | ? "uiautomator2" 71 | : "xcuitest", 72 | ); 73 | await startAppiumServer(this.project.use.device?.provider!); 74 | const WebDriver = (await import("webdriver")).default; 75 | const webDriverClient = await WebDriver.newSession( 76 | await this.createConfig(), 77 | ); 78 | this.sessionId = webDriverClient.sessionId; 79 | const expectTimeout = this.project.use.expectTimeout!; 80 | const testOptions: TimeoutOptions = { 81 | expectTimeout, 82 | }; 83 | return new Device( 84 | webDriverClient, 85 | undefined, 86 | testOptions, 87 | this.project.use.device?.provider!, 88 | ); 89 | } 90 | 91 | private async createConfig() { 92 | const platformName = this.project.use.platform; 93 | const udid = (this.project.use.device as EmulatorConfig).udid; 94 | let appPackageName: string | undefined; 95 | let appLaunchableActivity: string | undefined; 96 | 97 | if (platformName == Platform.ANDROID) { 98 | const { packageName, launchableActivity } = await getApkDetails( 99 | this.project.use.buildPath!, 100 | ); 101 | appPackageName = packageName!; 102 | appLaunchableActivity = launchableActivity!; 103 | } 104 | return { 105 | port: 4723, 106 | capabilities: { 107 | "appium:deviceName": this.project.use.device?.name, 108 | "appium:udid": udid, 109 | "appium:automationName": 110 | platformName == Platform.ANDROID ? "uiautomator2" : "xcuitest", 111 | "appium:platformVersion": (this.project.use.device as EmulatorConfig) 112 | .osVersion, 113 | "appium:appActivity": appLaunchableActivity, 114 | "appium:appPackage": appPackageName, 115 | platformName: platformName, 116 | "appium:autoGrantPermissions": true, 117 | "appium:app": this.project.use.buildPath, 118 | "appium:autoAcceptAlerts": true, 119 | "appium:fullReset": true, 120 | "appium:deviceOrientation": this.project.use.device?.orientation, 121 | "appium:settings[snapshotMaxDepth]": 62, 122 | "appium:wdaLaunchTimeout": 300_000, 123 | }, 124 | }; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/providers/index.ts: -------------------------------------------------------------------------------- 1 | import { BrowserStackDeviceProvider } from "./browserstack"; 2 | import { AppwrightConfig, DeviceProvider } from "../types"; 3 | import { LocalDeviceProvider } from "./local"; 4 | import { EmulatorProvider } from "./emulator"; 5 | import { FullProject } from "@playwright/test"; 6 | import { LambdaTestDeviceProvider } from "./lambdatest"; 7 | 8 | export function getProviderClass(provider: string): any { 9 | switch (provider) { 10 | case "browserstack": 11 | return BrowserStackDeviceProvider; 12 | case "lambdatest": 13 | return LambdaTestDeviceProvider; 14 | case "emulator": 15 | return EmulatorProvider; 16 | case "local-device": 17 | return LocalDeviceProvider; 18 | default: 19 | throw new Error(`Unknown device provider: ${provider}`); 20 | } 21 | } 22 | 23 | export function createDeviceProvider( 24 | project: FullProject, 25 | ): DeviceProvider { 26 | const provider = project.use.device?.provider; 27 | const appBundleId = project.use.appBundleId; 28 | if (!provider) { 29 | throw new Error("Device provider is not specified in the configuration."); 30 | } 31 | const ProviderClass = getProviderClass(provider); 32 | return new ProviderClass(project, appBundleId); 33 | } 34 | -------------------------------------------------------------------------------- /src/providers/lambdatest/index.ts: -------------------------------------------------------------------------------- 1 | import retry from "async-retry"; 2 | import fs from "fs"; 3 | import FormData from "form-data"; 4 | import path from "path"; 5 | import { AppwrightConfig, DeviceProvider, LambdaTestConfig } from "../../types"; 6 | import { FullProject } from "@playwright/test"; 7 | import { Device } from "../../device"; 8 | import { logger } from "../../logger"; 9 | import { getAuthHeader } from "./utils"; 10 | 11 | type LambdatestSessionDetails = { 12 | name: string; 13 | duration: number; 14 | platform: string; 15 | os_version: string; 16 | device: string; 17 | status_ind: string; 18 | build_name: string; 19 | remark: string; 20 | create_timestamp: string; 21 | start_timestamp: string; 22 | end_timestamp: string; 23 | console_logs_url: string; 24 | network_logs_url: string; 25 | command_logs_url: string; 26 | appium_logs_url: string; 27 | screenshot_url: string; 28 | video_url: string; 29 | }; 30 | 31 | const browserStackToLambdaTest: { 32 | deviceName: Record; 33 | osVersion: Record; 34 | } = { 35 | deviceName: { 36 | "Google Pixel 8": "Pixel 8", 37 | }, 38 | osVersion: { 39 | "14.0": "14", 40 | }, 41 | }; 42 | 43 | const API_BASE_URL = 44 | "https://mobile-api.lambdatest.com/mobile-automation/api/v1"; 45 | 46 | const envVarKeyForBuild = (projectName: string) => 47 | `LAMBDATEST_APP_URL_${projectName.toUpperCase()}`; 48 | 49 | async function getSessionDetails(sessionId: string) { 50 | const response = await fetch(`${API_BASE_URL}/sessions/${sessionId}`, { 51 | method: "GET", 52 | headers: { 53 | Authorization: getAuthHeader(), 54 | }, 55 | }); 56 | if (!response.ok) { 57 | throw new Error(`Error fetching session details: ${response.statusText}`); 58 | } 59 | const data = await response.json(); 60 | return data; 61 | } 62 | 63 | export class LambdaTestDeviceProvider implements DeviceProvider { 64 | private sessionDetails?: LambdatestSessionDetails; 65 | sessionId?: string; 66 | private projectName = path.basename(process.cwd()); 67 | 68 | constructor( 69 | private project: FullProject, 70 | private appBundleId: string | undefined, 71 | ) { 72 | if (!appBundleId) { 73 | throw new Error( 74 | "App Bundle ID is required for running tests on LambdaTest. Set the `appBundleId` for your projects that run on this provider.", 75 | ); 76 | } 77 | } 78 | 79 | async globalSetup() { 80 | if (!this.project.use.buildPath) { 81 | throw new Error( 82 | `Build path not found. Please set the build path in the config file.`, 83 | ); 84 | } 85 | if ( 86 | !(process.env.LAMBDATEST_USERNAME && process.env.LAMBDATEST_ACCESS_KEY) 87 | ) { 88 | throw new Error( 89 | "LAMBDATEST_USERNAME and LAMBDATEST_ACCESS_KEY are required environment variables for this device provider. Please set the LAMBDATEST_USERNAME and LAMBDATEST_ACCESS_KEY environment variables.", 90 | ); 91 | } 92 | const buildPath = this.project.use.buildPath!; 93 | const isHttpUrl = buildPath.startsWith("http"); 94 | const isLambdaTestUrl = buildPath.startsWith("lt://"); 95 | let appUrl: string | undefined = undefined; 96 | if (isLambdaTestUrl) { 97 | appUrl = buildPath; 98 | } else { 99 | let body; 100 | let headers = { 101 | Authorization: getAuthHeader(), 102 | }; 103 | if (isHttpUrl) { 104 | body = new URLSearchParams({ 105 | url: buildPath, 106 | visibility: "team", 107 | storage: "url", 108 | name: this.projectName, 109 | }); 110 | } else { 111 | if (!fs.existsSync(buildPath)) { 112 | throw new Error(`Build file not found: ${buildPath}`); 113 | } 114 | const form = new FormData(); 115 | form.append("visibility", "team"); 116 | form.append("storage", "file"); 117 | form.append("appFile", fs.createReadStream(buildPath)); 118 | form.append("name", this.projectName); 119 | headers = { ...headers, ...form.getHeaders() }; 120 | body = form; 121 | } 122 | logger.log(`Uploading: ${buildPath}`); 123 | const fetch = (await import("node-fetch")).default; 124 | const response = await fetch( 125 | `https://manual-api.lambdatest.com/app/upload/realDevice`, 126 | { 127 | method: "POST", 128 | headers, 129 | body, 130 | }, 131 | ); 132 | const data = await response.json(); 133 | appUrl = (data as any).app_url; 134 | if (!appUrl) { 135 | logger.error("Uploading the build failed:", data); 136 | } 137 | } 138 | process.env[envVarKeyForBuild(this.project.name)] = appUrl; 139 | } 140 | 141 | async getDevice(): Promise { 142 | this.validateConfig(); 143 | const config = this.createConfig(); 144 | return await this.createDriver(config); 145 | } 146 | 147 | private validateConfig() { 148 | const device = this.project.use.device as LambdaTestConfig; 149 | if (!device.name || !device.osVersion) { 150 | throw new Error( 151 | "Device name and osVersion are required for running tests on LambdaTest. Please set the device name and osVersion in the `appwright.config.ts` file.", 152 | ); 153 | } 154 | } 155 | 156 | private async createDriver(config: any): Promise { 157 | const WebDriver = (await import("webdriver")).default; 158 | const webDriverClient = await WebDriver.newSession(config); 159 | this.sessionId = webDriverClient.sessionId; 160 | const testOptions = { 161 | expectTimeout: this.project.use.expectTimeout!, 162 | }; 163 | return new Device( 164 | webDriverClient, 165 | this.appBundleId, 166 | testOptions, 167 | this.project.use.device?.provider!, 168 | ); 169 | } 170 | 171 | static async downloadVideo( 172 | sessionId: string, 173 | outputDir: string, 174 | fileName: string, 175 | ): Promise<{ path: string; contentType: string } | null> { 176 | const sessionData = await getSessionDetails(sessionId); 177 | const sessionDetails = sessionData?.data; 178 | const videoURL = sessionDetails?.video_url; 179 | const pathToTestVideo = path.join(outputDir, `${fileName}.mp4`); 180 | const tempPathForWriting = `${pathToTestVideo}.part`; 181 | const dir = path.dirname(pathToTestVideo); 182 | fs.mkdirSync(dir, { recursive: true }); 183 | const fileStream = fs.createWriteStream(tempPathForWriting); 184 | //To catch the lambdatest error in case all retries fails 185 | try { 186 | if (videoURL) { 187 | await retry( 188 | async () => { 189 | const response = await fetch(videoURL, { 190 | method: "GET", 191 | }); 192 | if (response.status !== 200) { 193 | // Retry if not 200 194 | throw new Error( 195 | `Video not found: ${response.status} (URL: ${videoURL})`, 196 | ); 197 | } 198 | const reader = response.body?.getReader(); 199 | if (!reader) { 200 | throw new Error("Failed to get reader from response body."); 201 | } 202 | const streamToFile = async () => { 203 | // eslint-disable-next-line no-constant-condition 204 | while (true) { 205 | const { done, value } = await reader.read(); 206 | if (done) break; 207 | fileStream.write(value); 208 | } 209 | }; 210 | await streamToFile(); 211 | fileStream.close(); 212 | }, 213 | { 214 | retries: 10, 215 | minTimeout: 3_000, 216 | onRetry: (err, i) => { 217 | if (i > 5) { 218 | logger.warn(`Retry attempt ${i} failed: ${err.message}`); 219 | } 220 | }, 221 | }, 222 | ); 223 | return new Promise((resolve, reject) => { 224 | // Ensure file stream is closed even in case of an error 225 | fileStream.on("finish", () => { 226 | try { 227 | fs.renameSync(tempPathForWriting, pathToTestVideo); 228 | logger.log( 229 | `Download finished and file closed: ${pathToTestVideo}`, 230 | ); 231 | resolve({ path: pathToTestVideo, contentType: "video/mp4" }); 232 | } catch (err) { 233 | logger.error(`Failed to rename file: `, err); 234 | reject(err); 235 | } 236 | }); 237 | 238 | fileStream.on("error", (err) => { 239 | logger.error(`Failed to write file: ${err.message}`); 240 | reject(err); 241 | }); 242 | }); 243 | } else { 244 | return null; 245 | } 246 | } catch (e) { 247 | logger.log(`Error Downloading video: `, e); 248 | return null; 249 | } 250 | } 251 | 252 | async syncTestDetails(details: { 253 | status?: string; 254 | reason?: string; 255 | name?: string; 256 | }) { 257 | const response = await fetch(`${API_BASE_URL}/sessions/${this.sessionId}`, { 258 | method: "PATCH", 259 | headers: { 260 | Authorization: getAuthHeader(), 261 | "Content-Type": "application/json", 262 | }, 263 | body: details.status 264 | ? JSON.stringify({ 265 | name: details.name, 266 | status_ind: details.status, 267 | custom_data: details.reason, 268 | }) 269 | : JSON.stringify({ 270 | name: details.name, 271 | }), 272 | }); 273 | if (!response.ok) { 274 | //TODO: Check whether add retry here or leave it as is because while setting the name of test 275 | //sometimes the session is not getting created till then thus this fails. 276 | // throw new Error(`Error setting session details: ${response.statusText}`); 277 | } 278 | const responseData = await response.json(); 279 | return responseData; 280 | } 281 | 282 | private deviceInfoForSession() { 283 | let deviceName = this.project.use.device?.name; 284 | let osVersion = (this.project.use.device as LambdaTestConfig).osVersion; 285 | if ( 286 | deviceName && 287 | Object.keys(browserStackToLambdaTest.deviceName).includes(deviceName) 288 | ) { 289 | // we map BrowserStack names to LambdaTest for better usability 290 | deviceName = browserStackToLambdaTest.deviceName[deviceName]; 291 | } 292 | if ( 293 | osVersion && 294 | Object.keys(browserStackToLambdaTest.osVersion).includes(osVersion) 295 | ) { 296 | osVersion = browserStackToLambdaTest.osVersion[osVersion]!; 297 | } 298 | return { 299 | deviceName, 300 | platformVersion: osVersion, 301 | deviceOrientation: this.project.use.device?.orientation, 302 | }; 303 | } 304 | 305 | private createConfig() { 306 | const platformName = this.project.use.platform; 307 | const envVarKey = envVarKeyForBuild(this.project.name); 308 | if (!process.env[envVarKey]) { 309 | throw new Error( 310 | `process.env.${envVarKey} is not set. Did the file upload work?`, 311 | ); 312 | } 313 | return { 314 | port: 443, 315 | protocol: "https", 316 | path: "/wd/hub", 317 | logLevel: "warn", 318 | user: process.env.LAMBDATEST_USERNAME, 319 | key: process.env.LAMBDATEST_ACCESS_KEY, 320 | hostname: "mobile-hub.lambdatest.com", 321 | capabilities: { 322 | ...this.deviceInfoForSession(), 323 | appiumVersion: "2.3.0", 324 | platformName: platformName, 325 | queueTimeout: 600, 326 | idleTimeout: 600, 327 | app: process.env[envVarKey], 328 | devicelog: true, 329 | video: true, 330 | build: `${this.projectName} ${platformName} ${ 331 | process.env.GITHUB_ACTIONS === "true" 332 | ? `CI ${process.env.GITHUB_RUN_ID}` 333 | : process.env.USER 334 | }`, 335 | project: this.projectName, 336 | autoGrantPermissions: true, 337 | autoAcceptAlerts: true, 338 | isRealMobile: true, 339 | enableImageInjection: (this.project.use.device as LambdaTestConfig) 340 | ?.enableCameraImageInjection, 341 | "settings[snapshotMaxDepth]": 62, 342 | }, 343 | }; 344 | } 345 | } 346 | -------------------------------------------------------------------------------- /src/providers/lambdatest/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import FormData from "form-data"; 3 | 4 | export function getAuthHeader() { 5 | const userName = process.env.LAMBDATEST_USERNAME; 6 | const accessKey = process.env.LAMBDATEST_ACCESS_KEY; 7 | const key = Buffer.from(`${userName}:${accessKey}`).toString("base64"); 8 | return `Basic ${key}`; 9 | } 10 | 11 | interface MediaUploadResponse { 12 | media_url: string; 13 | } 14 | 15 | export async function uploadImageToLambdaTest( 16 | imagePath: string, 17 | ): Promise { 18 | const formData = new FormData(); 19 | if (!fs.existsSync(imagePath)) { 20 | throw new Error( 21 | `No image file found at the specified path: ${imagePath}. Please provide a valid image file. 22 | Supported formats include JPG, JPEG, and PNG. Ensure the file exists and the path is correct.`, 23 | ); 24 | } 25 | formData.append("media_file", fs.createReadStream(imagePath)); 26 | formData.append("type", "image"); 27 | formData.append("custom_id", "SampleMedia"); 28 | const fetch = (await import("node-fetch")).default; 29 | const response = await fetch( 30 | "https://mobile-mgm.lambdatest.com/mfs/v1.0/media/upload", 31 | { 32 | method: "POST", 33 | headers: { 34 | Authorization: getAuthHeader(), 35 | }, 36 | body: formData, 37 | }, 38 | ); 39 | 40 | const data = (await response.json()) as MediaUploadResponse; 41 | const imageURL = data.media_url.trim(); 42 | return imageURL; 43 | } 44 | -------------------------------------------------------------------------------- /src/providers/local/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AppwrightConfig, 3 | DeviceProvider, 4 | LocalDeviceConfig, 5 | Platform, 6 | TimeoutOptions, 7 | } from "../../types"; 8 | import { Device } from "../../device"; 9 | import { FullProject } from "@playwright/test"; 10 | import { 11 | getActiveAndroidDevices, 12 | getApkDetails, 13 | getAppBundleId, 14 | getConnectedIOSDeviceUDID, 15 | installDriver, 16 | startAppiumServer, 17 | } from "../appium"; 18 | import { validateBuildPath } from "../../utils"; 19 | import { logger } from "../../logger"; 20 | 21 | export class LocalDeviceProvider implements DeviceProvider { 22 | sessionId?: string; 23 | 24 | constructor( 25 | private project: FullProject, 26 | appBundleId: string | undefined, 27 | ) { 28 | if (appBundleId) { 29 | logger.log( 30 | `Bundle id is specified (${appBundleId}) but ignored for local device provider.`, 31 | ); 32 | } 33 | } 34 | 35 | async getDevice(): Promise { 36 | return await this.createDriver(); 37 | } 38 | 39 | async globalSetup() { 40 | validateBuildPath( 41 | this.project.use.buildPath, 42 | this.project.use.platform == Platform.ANDROID ? ".apk" : ".ipa", 43 | ); 44 | if (this.project.use.platform == Platform.ANDROID) { 45 | const androidHome = process.env.ANDROID_HOME; 46 | 47 | if (!androidHome) { 48 | return Promise.reject( 49 | "The ANDROID_HOME environment variable is not set. This variable is required to locate your Android SDK. Please set it to the correct path of your Android SDK installation. For detailed instructions on how to set up the Android SDK path, visit: https://developer.android.com/tools", 50 | ); 51 | } 52 | } 53 | } 54 | 55 | private async createDriver(): Promise { 56 | await installDriver( 57 | this.project.use.platform == Platform.ANDROID 58 | ? "uiautomator2" 59 | : "xcuitest", 60 | ); 61 | await startAppiumServer(this.project.use.device?.provider!); 62 | const WebDriver = (await import("webdriver")).default; 63 | const webDriverClient = await WebDriver.newSession( 64 | await this.createConfig(), 65 | ); 66 | this.sessionId = webDriverClient.sessionId; 67 | const bundleId = await getAppBundleId(this.project.use.buildPath!); 68 | const expectTimeout = this.project.use.expectTimeout!; 69 | const testOptions: TimeoutOptions = { 70 | expectTimeout, 71 | }; 72 | return new Device( 73 | webDriverClient, 74 | bundleId, 75 | testOptions, 76 | this.project.use.device?.provider!, 77 | ); 78 | } 79 | 80 | private async createConfig() { 81 | const platformName = this.project.use.platform; 82 | let appPackageName: string | undefined; 83 | let appLaunchableActivity: string | undefined; 84 | 85 | if (platformName == Platform.ANDROID) { 86 | const { packageName, launchableActivity } = await getApkDetails( 87 | this.project.use.buildPath!, 88 | ); 89 | appPackageName = packageName!; 90 | appLaunchableActivity = launchableActivity!; 91 | } 92 | let udid = (this.project.use.device as LocalDeviceConfig).udid; 93 | if (!udid) { 94 | if (platformName == Platform.IOS) { 95 | udid = await getConnectedIOSDeviceUDID(); 96 | } else { 97 | const activeAndroidDevices = await getActiveAndroidDevices(); 98 | if (activeAndroidDevices > 1) { 99 | logger.warn( 100 | `Multiple active devices detected. Selecting one for the test. 101 | To specify a device, use the udid property. Run "adb devices" to get the UDID for active devices.`, 102 | ); 103 | } 104 | } 105 | } 106 | return { 107 | port: 4723, 108 | capabilities: { 109 | "appium:deviceName": this.project.use.device?.name, 110 | "appium:udid": udid, 111 | "appium:automationName": 112 | platformName == Platform.ANDROID ? "uiautomator2" : "xcuitest", 113 | platformName: platformName, 114 | "appium:autoGrantPermissions": true, 115 | "appium:app": this.project.use.buildPath, 116 | "appium:appActivity": appLaunchableActivity, 117 | "appium:appPackage": appPackageName, 118 | "appium:autoAcceptAlerts": true, 119 | "appium:fullReset": true, 120 | "appium:deviceOrientation": this.project.use.device?.orientation, 121 | "appium:settings[snapshotMaxDepth]": 62, 122 | }, 123 | }; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/reporter.ts: -------------------------------------------------------------------------------- 1 | import type { Reporter, TestCase, TestResult } from "@playwright/test/reporter"; 2 | import { getProviderClass } from "./providers"; 3 | import fs from "fs"; 4 | import path from "path"; 5 | import ffmpeg from "fluent-ffmpeg"; 6 | import ffmpegInstaller from "@ffmpeg-installer/ffmpeg"; 7 | import { logger } from "./logger"; 8 | import { basePath } from "./utils"; 9 | import { WorkerInfo, WorkerInfoStore } from "./fixture/workerInfo"; 10 | 11 | class VideoDownloader implements Reporter { 12 | private downloadPromises: Promise[] = []; 13 | 14 | onBegin() { 15 | if (fs.existsSync(basePath())) { 16 | fs.rmSync(basePath(), { 17 | recursive: true, 18 | }); 19 | } 20 | } 21 | 22 | onTestBegin(test: TestCase, result: TestResult) { 23 | logger.log(`Starting test: ${test.title} on worker ${result.workerIndex}`); 24 | const workerInfoStore = new WorkerInfoStore(); 25 | void workerInfoStore.saveTestStartTime( 26 | result.workerIndex, 27 | test.title, 28 | new Date(), 29 | ); 30 | } 31 | 32 | onTestEnd(test: TestCase, result: TestResult) { 33 | logger.log(`Ending test: ${test.title} on worker ${result.workerIndex}`); 34 | const sessionIdAnnotation = test.annotations.find( 35 | ({ type }) => type === "sessionId", 36 | ); 37 | const providerNameAnnotation = test.annotations.find( 38 | ({ type }) => type === "providerName", 39 | ); 40 | // Check if test ran on `device` or on `persistentDevice` 41 | const isTestUsingDevice = sessionIdAnnotation && providerNameAnnotation; 42 | if (isTestUsingDevice) { 43 | // This is a test that ran with the `device` fixture 44 | const sessionId = sessionIdAnnotation.description; 45 | const providerName = providerNameAnnotation.description; 46 | const provider = getProviderClass(providerName!); 47 | this.downloadAndAttachDeviceVideo(test, result, provider, sessionId!); 48 | const otherAnnotations = test.annotations.filter( 49 | ({ type }) => type !== "sessionId" && type !== "providerName", 50 | ); 51 | test.annotations = otherAnnotations; 52 | } else { 53 | // This is a test that ran on `persistentDevice` fixture 54 | const { workerIndex, duration } = result; 55 | if (duration <= 0) { 56 | // Skipped tests 57 | return; 58 | } 59 | test.annotations.push({ 60 | type: "workerInfo", 61 | description: `Ran on worker #${workerIndex}.`, 62 | }); 63 | const expectedVideoPath = path.join( 64 | basePath(), 65 | `worker-${workerIndex}-video.mp4`, 66 | ); 67 | // The `onTestEnd` is method is called before the worker ends and 68 | // the worker's `endTime` is saved to disk. We add a 5 secs delay 69 | // to prevent a harmful race condition. 70 | const workerDownload = waitFiveSeconds() 71 | .then(() => getWorkerInfo(workerIndex)) 72 | .then(async (workerInfo) => { 73 | if (!workerInfo) { 74 | throw new Error(`Worker info not found for idx: ${workerIndex}`); 75 | } 76 | const { providerName, sessionId, endTime } = workerInfo; 77 | if (!providerName || !sessionId) { 78 | throw new Error( 79 | `Provider name or session id not found for worker: ${workerIndex}`, 80 | ); 81 | } 82 | if (!this.providerSupportsVideo(providerName)) { 83 | return; // Nothing to do here 84 | } 85 | if (endTime) { 86 | // This is the last test in the worker, so let's download the video 87 | const provider = getProviderClass(providerName); 88 | const downloaded: { 89 | path: string; 90 | contentType: string; 91 | } | null = await provider.downloadVideo( 92 | sessionId, 93 | basePath(), 94 | `worker-${workerIndex}-video`, 95 | ); 96 | if (!downloaded) { 97 | return; 98 | } 99 | return this.trimAndAttachPersistentDeviceVideo( 100 | test, 101 | result, 102 | downloaded.path, 103 | ); 104 | } else { 105 | // This is an intermediate test in the worker, so let's wait for the 106 | // video file to be found on disk. Once it is, we trim and attach it. 107 | await waitFor(() => fs.existsSync(expectedVideoPath)); 108 | return this.trimAndAttachPersistentDeviceVideo( 109 | test, 110 | result, 111 | expectedVideoPath, 112 | ); 113 | } 114 | }) 115 | .catch((e) => { 116 | logger.error("Failed to get worker end time:", e); 117 | }); 118 | this.downloadPromises.push(workerDownload); 119 | } 120 | } 121 | 122 | async onEnd() { 123 | logger.log(`Triggered onEnd`); 124 | await Promise.allSettled(this.downloadPromises); 125 | } 126 | 127 | private async trimAndAttachPersistentDeviceVideo( 128 | test: TestCase, 129 | result: TestResult, 130 | workerVideoPath: string, 131 | ) { 132 | const workerIdx = result.workerIndex; 133 | const workerStart = await getWorkerStartTime(workerIdx); 134 | let pathToAttach = workerVideoPath; 135 | const testStart = result.startTime; 136 | if (testStart.getTime() < workerStart.getTime()) { 137 | // This is the first test for the worker 138 | // The startTime for the first test in the worker tends to be 139 | // before worker (session) start time. This would have been manageable 140 | // if the `duration` included the worker setup time, but the duration only 141 | // covers the test method execution time. 142 | // So in this case, we are not going to trim. 143 | // TODO: We can use the startTime of the second test in the worker 144 | pathToAttach = workerVideoPath; 145 | } else { 146 | const trimSkipPoint = 147 | (testStart.getTime() - workerStart.getTime()) / 1000; 148 | const trimmedFileName = `worker-${workerIdx}-trimmed-${test.id}.mp4`; 149 | try { 150 | pathToAttach = await trimVideo({ 151 | originalVideoPath: workerVideoPath, 152 | startSecs: trimSkipPoint, 153 | durationSecs: result.duration / 1000, 154 | outputPath: trimmedFileName, 155 | }); 156 | } catch (e) { 157 | logger.error("Failed to trim video:", e); 158 | test.annotations.push({ 159 | type: "videoError", 160 | description: `Unable to trim video, attaching full video instead. Test starts at ${trimSkipPoint} secs.`, 161 | }); 162 | } 163 | } 164 | result.attachments.push({ 165 | path: pathToAttach, 166 | contentType: "video/mp4", 167 | name: "video", 168 | }); 169 | } 170 | 171 | private downloadAndAttachDeviceVideo( 172 | test: TestCase, 173 | result: TestResult, 174 | providerClass: any, 175 | sessionId: string, 176 | ) { 177 | const videoFileName = `${test.id}`; 178 | if (!providerClass.downloadVideo) { 179 | return; 180 | } 181 | const downloadPromise = providerClass 182 | .downloadVideo(sessionId, basePath(), videoFileName) 183 | .then((downloadedVideo: { path: string; contentType: string } | null) => { 184 | if (!downloadedVideo) { 185 | return; 186 | } 187 | result.attachments.push({ 188 | ...downloadedVideo, 189 | name: "video", 190 | }); 191 | return downloadedVideo; 192 | }); 193 | this.downloadPromises.push(downloadPromise); 194 | } 195 | 196 | private providerSupportsVideo(providerName: string) { 197 | const provider = getProviderClass(providerName); 198 | return !!provider.downloadVideo; 199 | } 200 | } 201 | 202 | function waitFor( 203 | condition: () => boolean, 204 | timeout: number = 60 * 60 * 1000, // 1 hour in ms 205 | ): Promise { 206 | return new Promise((resolve, reject) => { 207 | let interval: any; 208 | const timeoutId = setTimeout(() => { 209 | clearInterval(interval); 210 | reject(new Error("Timed out waiting for condition")); 211 | }, timeout); 212 | interval = setInterval(() => { 213 | if (condition()) { 214 | clearInterval(interval); 215 | clearTimeout(timeoutId); 216 | resolve(); 217 | } 218 | }, 500); 219 | }); 220 | } 221 | 222 | function trimVideo({ 223 | originalVideoPath, 224 | startSecs, 225 | durationSecs, 226 | outputPath, 227 | }: { 228 | originalVideoPath: string; 229 | startSecs: number; 230 | durationSecs: number; 231 | outputPath: string; 232 | }): Promise { 233 | logger.log( 234 | `Attemping to trim video: ${originalVideoPath} at start: ${startSecs} and duration: ${durationSecs} to ${outputPath}`, 235 | ); 236 | const copyName = `draft-for-${outputPath}`; 237 | const dirPath = path.dirname(originalVideoPath); 238 | const copyFullPath = path.join(dirPath, copyName); 239 | const fullOutputPath = path.join(dirPath, outputPath); 240 | fs.copyFileSync(originalVideoPath, copyFullPath); 241 | return new Promise((resolve, reject) => { 242 | let stdErrs = ""; 243 | ffmpeg(copyFullPath) 244 | .setFfmpegPath(ffmpegInstaller.path) 245 | .setStartTime(startSecs) 246 | .setDuration(durationSecs) 247 | .output(fullOutputPath) 248 | .on("end", () => { 249 | logger.log(`Trimmed video saved at: ${fullOutputPath}`); 250 | fs.unlinkSync(copyFullPath); 251 | resolve(fullOutputPath); 252 | }) 253 | .on("stderr", (stderrLine) => { 254 | stdErrs += stderrLine + "\n"; 255 | }) 256 | .on("error", (err) => { 257 | logger.error("ffmpeg error:", err); 258 | logger.error("ffmpeg stderr:", stdErrs); 259 | reject(err); 260 | }) 261 | .run(); 262 | }); 263 | } 264 | 265 | async function getWorkerStartTime(idx: number): Promise { 266 | const workerInfoStore = new WorkerInfoStore(); 267 | return workerInfoStore.getWorkerStartTime(idx); 268 | } 269 | 270 | async function getWorkerInfo(idx: number): Promise { 271 | const workerInfoStore = new WorkerInfoStore(); 272 | return workerInfoStore.getWorkerFromDisk(idx); 273 | } 274 | 275 | const waitFiveSeconds = () => 276 | new Promise((resolve) => setTimeout(resolve, 5000)); 277 | 278 | export default VideoDownloader; 279 | -------------------------------------------------------------------------------- /src/tests/locator.spec.ts: -------------------------------------------------------------------------------- 1 | import { vi, test, expect } from "vitest"; 2 | //@ts-ignore 3 | import { Client as WebDriverClient } from "webdriver"; 4 | import { Locator } from "../locator"; 5 | import { TimeoutError } from "../types/errors"; 6 | 7 | test("isVisible on unknown element", async () => { 8 | const mockFindElements = vi.fn().mockResolvedValue([]); 9 | //@ts-ignore 10 | const wdClientMock: WebDriverClient = { 11 | findElements: mockFindElements, 12 | }; 13 | const locator = new Locator( 14 | wdClientMock, 15 | { expectTimeout: 1_000 }, 16 | "//unknown-selector", 17 | "xpath", 18 | ); 19 | const isVisible = await locator.isVisible(); 20 | expect(isVisible).toBe(false); 21 | expect(mockFindElements).toHaveBeenCalledTimes(2); 22 | }); 23 | 24 | test("isVisible on element that is found but fails displayed check", async () => { 25 | const mockFindElements = vi.fn().mockResolvedValue([ 26 | { 27 | "element-6066-11e4-a52e-4f735466cecf": "element-id", 28 | }, 29 | ]); 30 | const mockIsElementDisplayed = vi.fn().mockResolvedValue(false); 31 | //@ts-ignore 32 | const wdClientMock: WebDriverClient = { 33 | findElements: mockFindElements, 34 | isElementDisplayed: mockIsElementDisplayed, 35 | }; 36 | const locator = new Locator( 37 | wdClientMock, 38 | { expectTimeout: 1_000 }, 39 | "//known-but-hidden-element", 40 | "xpath", 41 | ); 42 | const isVisible = await locator.isVisible(); 43 | expect(isVisible).toBe(false); 44 | expect(mockFindElements).toHaveBeenCalledTimes(2); 45 | expect(mockIsElementDisplayed).toHaveBeenCalledTimes(2); 46 | expect(mockIsElementDisplayed).toHaveBeenCalledWith("element-id"); 47 | }); 48 | 49 | test("isVisible on element that throws stale element reference", async () => { 50 | const mockFindElements = vi.fn().mockResolvedValue([ 51 | { 52 | "element-6066-11e4-a52e-4f735466cecf": "element-id", 53 | }, 54 | ]); 55 | const mockIsElementDisplayed = vi.fn().mockImplementation(() => { 56 | class WebDriverInteralError extends Error { 57 | constructor(name: string, ...args: any[]) { 58 | super(...args); 59 | this.name = name; 60 | } 61 | } 62 | throw new WebDriverInteralError(`random stale element reference random`); 63 | }); 64 | //@ts-ignore 65 | const wdClientMock: WebDriverClient = { 66 | findElements: mockFindElements, 67 | isElementDisplayed: mockIsElementDisplayed, 68 | }; 69 | const locator = new Locator( 70 | wdClientMock, 71 | { expectTimeout: 1_000 }, 72 | "//known-element-that-keeps-throwing-stale-element-reference", 73 | "xpath", 74 | ); 75 | const isVisible = await locator.isVisible(); 76 | expect(isVisible).toBe(false); 77 | expect(mockFindElements).toHaveBeenCalledTimes(2); 78 | expect(mockIsElementDisplayed).toHaveBeenCalledTimes(2); 79 | expect(mockIsElementDisplayed).toHaveBeenCalledWith("element-id"); 80 | }); 81 | 82 | test("waitFor attached state works for hidden element", async () => { 83 | const mockFindElements = vi.fn().mockResolvedValue([ 84 | { 85 | "element-6066-11e4-a52e-4f735466cecf": "element-id", 86 | }, 87 | ]); 88 | const mockIsElementDisplayed = vi.fn().mockResolvedValue(false); 89 | //@ts-ignore 90 | const wdClientMock: WebDriverClient = { 91 | findElements: mockFindElements, 92 | isElementDisplayed: mockIsElementDisplayed, 93 | }; 94 | const locator = new Locator( 95 | wdClientMock, 96 | { expectTimeout: 1_000 }, 97 | "//attached-hidden-element", 98 | "xpath", 99 | ); 100 | expect(await locator.waitFor("attached")).toBe(true); 101 | expect(mockFindElements).toHaveBeenCalledTimes(1); 102 | expect(mockIsElementDisplayed).toHaveBeenCalledTimes(0); 103 | }); 104 | 105 | test("waitFor attached state throws TimeoutError", async () => { 106 | const mockFindElements = vi.fn().mockResolvedValue([]); 107 | //@ts-ignore 108 | const wdClientMock: WebDriverClient = { 109 | findElements: mockFindElements, 110 | }; 111 | const locator = new Locator( 112 | wdClientMock, 113 | { expectTimeout: 1_000 }, 114 | "//attached-hidden-element", 115 | "xpath", 116 | ); 117 | await expect(locator.waitFor("attached")).rejects.toThrowError(TimeoutError); 118 | expect(mockFindElements).toHaveBeenCalledTimes(2); 119 | }); 120 | -------------------------------------------------------------------------------- /src/tests/regex.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | import { longestDeterministicGroup } from "../utils"; 3 | 4 | test("longest deterministic group with one group", () => { 5 | const pattern = /.*(Copy to clipboard)$/; 6 | const substring = longestDeterministicGroup(pattern); 7 | expect(substring).toBe("Copy to clipboard"); 8 | }); 9 | 10 | test("longest deterministic group with no groups", () => { 11 | const pattern = /.*Copy to clipboard$/; 12 | const substring = longestDeterministicGroup(pattern); 13 | expect(substring).toBe(undefined); 14 | }); 15 | 16 | test("longest deterministic group with group that has special chars", () => { 17 | const pattern = /.*(Copy .* to clipboard)$/; 18 | const substring = longestDeterministicGroup(pattern); 19 | expect(substring).toBe(undefined); 20 | }); 21 | -------------------------------------------------------------------------------- /src/tests/vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | exclude: ["example/**", "node_modules/**", "dist/**"], 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /src/types/errors.ts: -------------------------------------------------------------------------------- 1 | export class RetryableError extends Error { 2 | constructor(message: string) { 3 | super(message); 4 | } 5 | } 6 | 7 | export class TimeoutError extends Error { 8 | constructor(message: string) { 9 | super(message); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | import { Device } from "../device"; 2 | import { z } from "zod"; 3 | 4 | export type ExtractType = T extends z.ZodType ? z.infer : never; 5 | 6 | export type ActionOptions = { 7 | timeout: number; 8 | }; 9 | 10 | export type TimeoutOptions = { 11 | /** 12 | * The maximum amount of time (in milliseconds) to wait for the condition to be met. 13 | */ 14 | expectTimeout: number; 15 | }; 16 | 17 | export interface DeviceProvider { 18 | /** 19 | * Identifier for the Appium session. Can be undefined if the session was not created. 20 | */ 21 | sessionId?: string; 22 | 23 | /** 24 | * Global setup validates the configuration. 25 | */ 26 | globalSetup?(): Promise; 27 | 28 | /** 29 | * Returns a device instance. 30 | */ 31 | getDevice(): Promise; 32 | 33 | /** 34 | * Updates test details and test status. 35 | * 36 | * @param status of the test 37 | * @param reason for the test status 38 | * @param name of the test 39 | */ 40 | syncTestDetails?: (details: { 41 | status?: string; 42 | reason?: string; 43 | name?: string; 44 | }) => Promise; 45 | } 46 | 47 | export type AppwrightConfig = { 48 | platform: Platform; 49 | device: DeviceConfig; 50 | buildPath: string; 51 | appBundleId: string; 52 | // TODO: use expect timeout from playwright config 53 | expectTimeout: number; 54 | }; 55 | 56 | export type DeviceConfig = 57 | | BrowserStackConfig 58 | | LambdaTestConfig 59 | | LocalDeviceConfig 60 | | EmulatorConfig; 61 | 62 | /** 63 | * Configuration for devices running on Browserstack. 64 | */ 65 | export type BrowserStackConfig = { 66 | provider: "browserstack"; 67 | 68 | /** 69 | * The name of the device to be used on Browserstack. 70 | * Checkout the list of devices supported by BrowserStack: https://www.browserstack.com/list-of-browsers-and-platforms/app_automate 71 | * Example: "iPhone 15 Pro Max", "Samsung Galaxy S23 Ultra". 72 | */ 73 | name: string; 74 | 75 | /** 76 | * The operating system version of the device to be used on Browserstack. 77 | * Checkout the list of OS versions supported by BrowserStack: https://www.browserstack.com/list-of-browsers-and-platforms/app_automate 78 | * Example: "14.0", "15.0". 79 | */ 80 | osVersion: string; 81 | 82 | /** 83 | * The orientation of the device on Browserstack. 84 | * Default orientation is "portrait". 85 | */ 86 | orientation?: DeviceOrientation; 87 | 88 | /** 89 | * Whether to enable camera injection on the device. 90 | * Default is false. 91 | */ 92 | enableCameraImageInjection?: boolean; 93 | }; 94 | 95 | export type LambdaTestConfig = { 96 | provider: "lambdatest"; 97 | 98 | /** 99 | * The name of the device to be used on LambdaTest. 100 | * Checkout the list of devices supported by LambdaTest: https://www.lambdatest.com/list-of-real-devices 101 | * Example: "iPhone 15 Pro Max", "Galaxy S23 Ultra". 102 | */ 103 | name: string; 104 | 105 | /** 106 | * The operating system version of the device to be used on LambdaTest. 107 | * Checkout the list of OS versions supported by LambdaTest: https://www.lambdatest.com/list-of-real-devices 108 | * Example: "14.0", "15.0". 109 | */ 110 | osVersion: string; 111 | 112 | /** 113 | * The orientation of the device on LambdaTest. 114 | * Default orientation is "portrait". 115 | */ 116 | orientation?: DeviceOrientation; 117 | 118 | /** 119 | * Whether to enable camera injection on the device. 120 | * Default is false. 121 | */ 122 | enableCameraImageInjection?: boolean; 123 | }; 124 | 125 | /** 126 | * Configuration for locally connected physical devices. 127 | */ 128 | export type LocalDeviceConfig = { 129 | provider: "local-device"; 130 | name?: string; 131 | 132 | /** 133 | * The unique device identifier (UDID) of the connected local device. 134 | */ 135 | udid?: string; 136 | 137 | /** 138 | * The orientation of the device. 139 | * Default orientation is "portrait". 140 | */ 141 | orientation?: DeviceOrientation; 142 | }; 143 | 144 | /** 145 | * Configuration for running tests on an Android or iOS emulator. 146 | */ 147 | export type EmulatorConfig = { 148 | provider: "emulator"; 149 | name?: string; 150 | osVersion?: string; 151 | 152 | /** 153 | * The unique device identifier (UDID) of the emulator. 154 | */ 155 | udid?: string; 156 | 157 | /** 158 | * The orientation of the emulator. 159 | * Default orientation is "portrait". 160 | */ 161 | orientation?: DeviceOrientation; 162 | }; 163 | 164 | export enum Platform { 165 | ANDROID = "android", 166 | IOS = "ios", 167 | } 168 | 169 | export enum DeviceOrientation { 170 | PORTRAIT = "portrait", 171 | LANDSCAPE = "landscape", 172 | } 173 | 174 | export enum ScrollDirection { 175 | UP = "up", 176 | DOWN = "down", 177 | } 178 | 179 | export interface AppwrightLocator { 180 | /** 181 | * Taps (clicks) on the element. This method waits for the element to be visible before clicking it. 182 | * 183 | * **Usage:** 184 | * ```js 185 | * await device.getByText("Submit").tap(); 186 | * ``` 187 | * 188 | * @param options Use this to override the timeout for this action 189 | */ 190 | tap(options?: ActionOptions): Promise; 191 | 192 | /** 193 | * Fills the input element with the given value. This method waits for the element to be visible before filling it. 194 | * 195 | * **Usage:** 196 | * ```js 197 | * await device.getByText("Search").fill("My query"); 198 | * ``` 199 | * 200 | * @param value The value to fill in the input field 201 | * @param options Use this to override the timeout for this action 202 | */ 203 | fill(value: string, options?: ActionOptions): Promise; 204 | 205 | /** 206 | * Sends key strokes to the element. This method waits for the element to be visible before sending the key strokes. 207 | * 208 | * **Usage:** 209 | * ```js 210 | * await device.getByText("Search").sendKeyStrokes("My query"); 211 | * ``` 212 | * 213 | * @param value The string to send as key strokes. 214 | * @param options Use this to override the timeout for this action 215 | */ 216 | sendKeyStrokes(value: string, options?: ActionOptions): Promise; 217 | 218 | /** 219 | * Wait for the element to be visible or attached, while attempting for the `timeout` duration. 220 | * Throws TimeoutError if element is not found within the timeout. 221 | * 222 | * **Usage:** 223 | * ```js 224 | * await device.getByText("Search").waitFor({ state: "visible" }); 225 | * ``` 226 | * 227 | * @param state The state to wait for 228 | * @param options Use this to override the timeout for this action 229 | */ 230 | waitFor( 231 | state: "attached" | "visible", 232 | options?: ActionOptions, 233 | ): Promise; 234 | 235 | /** 236 | * Waits for the element to be visible, while attempting for the `timeout` duration. 237 | * Returns boolean based on the visibility of the element. 238 | * 239 | * **Usage:** 240 | * ```js 241 | * const isVisible = await device.getByText("Search").isVisible(); 242 | * ``` 243 | * 244 | * @param options Use this to override the timeout for this action 245 | */ 246 | isVisible(options?: ActionOptions): Promise; 247 | 248 | /** 249 | * Returns the text content of the element. This method waits for the element to be visible before getting the text. 250 | * 251 | * **Usage:** 252 | * ```js 253 | * const textContent = await device.getByText("Search").getText(); 254 | * ``` 255 | * 256 | * @param options Use this to override the timeout for this action 257 | */ 258 | getText(options?: ActionOptions): Promise; 259 | 260 | scroll(direction: ScrollDirection): Promise; 261 | } 262 | 263 | export enum WebDriverErrors { 264 | StaleElementReferenceError = "stale element reference", 265 | } 266 | 267 | export type ElementReference = Record; 268 | export type ElementReferenceId = "element-6066-11e4-a52e-4f735466cecf"; 269 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import test from "@playwright/test"; 2 | import fs from "fs"; 3 | import path from "path"; 4 | 5 | export function boxedStep( 6 | target: Function, 7 | context: ClassMethodDecoratorContext, 8 | ) { 9 | return function replacementMethod( 10 | this: { 11 | selector: string | RegExp; 12 | }, 13 | ...args: any 14 | ) { 15 | const path = this.selector ? `("${this.selector}")` : ""; 16 | const argsString = args.length 17 | ? "(" + 18 | Array.from(args) 19 | .map((a) => JSON.stringify(a)) 20 | .join(" , ") + 21 | ")" 22 | : ""; 23 | const name = `${context.name as string}${path}${argsString}`; 24 | return test.step( 25 | name, 26 | async () => { 27 | return await target.call(this, ...args); 28 | }, 29 | { box: true }, 30 | ); 31 | }; 32 | } 33 | 34 | export function validateBuildPath( 35 | buildPath: string | undefined, 36 | expectedExtension: string, 37 | ) { 38 | if (!buildPath) { 39 | throw new Error( 40 | `Build path not found. Please set the build path in appwright.config.ts`, 41 | ); 42 | } 43 | 44 | if (!buildPath.endsWith(expectedExtension)) { 45 | throw new Error( 46 | `File path is not supported for the given combination of platform and provider. Please provide build with ${expectedExtension} file extension in the appwright.config.ts`, 47 | ); 48 | } 49 | 50 | if (!fs.existsSync(buildPath)) { 51 | throw new Error( 52 | `File not found at given path: ${buildPath} 53 | Please provide the correct path of the build.`, 54 | ); 55 | } 56 | } 57 | 58 | export function getLatestBuildToolsVersions( 59 | versions: string[], 60 | ): string | undefined { 61 | return versions.sort((a, b) => (a > b ? -1 : 1))[0]; 62 | } 63 | 64 | export function longestDeterministicGroup(pattern: RegExp): string | undefined { 65 | const patternToString = pattern.toString(); 66 | const matches = [...patternToString.matchAll(/\(([^)]+)\)/g)].map( 67 | (match) => match[1], 68 | ); 69 | if (!matches || !matches.length) { 70 | return undefined; 71 | } 72 | const noSpecialChars: string[] = matches.filter((match): match is string => { 73 | if (!match) { 74 | return false; 75 | } 76 | const regexSpecialCharsPattern = /[.*+?^${}()|[\]\\]/; 77 | return !regexSpecialCharsPattern.test(match); 78 | }); 79 | const longestString = noSpecialChars.reduce( 80 | (max, str) => (str.length > max.length ? str : max), 81 | "", 82 | ); 83 | if (longestString == "") { 84 | return undefined; 85 | } 86 | return longestString; 87 | } 88 | 89 | export function basePath() { 90 | return path.join(process.cwd(), "playwright-report", "data", "videos-store"); 91 | } 92 | -------------------------------------------------------------------------------- /src/vision/index.ts: -------------------------------------------------------------------------------- 1 | import { query } from "@empiricalrun/llm/vision"; 2 | import { getCoordinatesFor } from "@empiricalrun/llm/vision/point"; 3 | import fs from "fs"; 4 | // @ts-ignore ts not able to identify the import is just an interface 5 | import { Client as WebDriverClient } from "webdriver"; 6 | import { Device } from "../device"; 7 | import test from "@playwright/test"; 8 | import { boxedStep } from "../utils"; 9 | import { z } from "zod"; 10 | import { LLMModel } from "@empiricalrun/llm"; 11 | import { ExtractType } from "../types"; 12 | import { logger } from "../logger"; 13 | 14 | export interface AppwrightVision { 15 | /** 16 | * Extracts text from the screenshot based on the specified prompt. 17 | * Ensure the `OPENAI_API_KEY` environment variable is set to authenticate the API request. 18 | * 19 | * **Usage:** 20 | * ```js 21 | * await device.beta.query("Extract contact details present in the footer from the screenshot"); 22 | * ``` 23 | * 24 | * @param prompt that defines the specific area or context from which text should be extracted. 25 | * @returns 26 | */ 27 | query( 28 | prompt: string, 29 | options?: { 30 | responseFormat?: T; 31 | model?: LLMModel; 32 | screenshot?: string; 33 | telemetry?: { 34 | tags?: string[]; 35 | }; 36 | }, 37 | ): Promise>; 38 | 39 | /** 40 | * Performs a tap action on the screen based on the provided prompt. 41 | * Ensure the `EMPIRICAL_API_KEY` environment variable is set to authenticate the API request. 42 | * 43 | * **Usage:** 44 | * ```js 45 | * await device.beta.tap("Tap on the search button"); 46 | * ``` 47 | * 48 | * @param prompt that defines where on the screen the tap action should occur 49 | */ 50 | tap( 51 | prompt: string, 52 | options?: { 53 | useCache?: boolean; 54 | telemetry?: { 55 | tags?: string[]; 56 | }; 57 | }, 58 | ): Promise<{ x: number; y: number }>; 59 | } 60 | 61 | export class VisionProvider { 62 | constructor( 63 | private device: Device, 64 | private webDriverClient: WebDriverClient, 65 | ) {} 66 | 67 | @boxedStep 68 | async query( 69 | prompt: string, 70 | options?: { 71 | responseFormat?: T; 72 | model?: LLMModel; 73 | screenshot?: string; 74 | }, 75 | ): Promise> { 76 | test.skip( 77 | !process.env.OPENAI_API_KEY, 78 | "LLM vision based extract text is not enabled. Set the OPENAI_API_KEY environment variable to enable it", 79 | ); 80 | let base64Screenshot = options?.screenshot; 81 | if (!base64Screenshot) { 82 | base64Screenshot = await this.webDriverClient.takeScreenshot(); 83 | } 84 | return await query(base64Screenshot, prompt, options); 85 | } 86 | 87 | @boxedStep 88 | async tap( 89 | prompt: string, 90 | options?: { useCache?: boolean }, 91 | ): Promise<{ x: number; y: number }> { 92 | test.skip( 93 | !process.env.EMPIRICAL_API_KEY, 94 | "LLM vision based tap is not enabled. Set the EMPIRICAL_API_KEY environment variable to enable it", 95 | ); 96 | const base64Image = await this.webDriverClient.takeScreenshot(); 97 | const coordinates = await getCoordinatesFor(prompt, base64Image, options); 98 | if (coordinates.annotatedImage) { 99 | const random = Math.floor(1000 + Math.random() * 9000); 100 | const file = test.info().outputPath(`${random}.png`); 101 | await fs.promises.writeFile( 102 | file, 103 | Buffer.from(coordinates.annotatedImage!, "base64"), 104 | ); 105 | await test.info().attach(`${random}`, { path: file }); 106 | } 107 | const driverSize = await this.webDriverClient.getWindowRect(); 108 | const { container: imageSize, x, y } = coordinates; 109 | const scaleFactorWidth = imageSize.width / driverSize.width; 110 | const scaleFactorHeight = imageSize.height / driverSize.height; 111 | if (scaleFactorWidth !== scaleFactorHeight) { 112 | logger.warn( 113 | `Scale factors are different: ${scaleFactorWidth} vs ${scaleFactorHeight}`, 114 | ); 115 | } 116 | const tapTargetX = x / scaleFactorWidth; 117 | // This uses the width scale factor because getWindowRect on LambdaTest returns a smaller 118 | // height value than the screenshot height, which causes disproportionate scaling 119 | // for width and height. 120 | // For example, Pixel 8 screenshot is 1080 (w) x 2400 (h), but LambdaTest returns 121 | // 1080 (w) x 2142 (h) for getWindowRect. 122 | const tapTargetY = y / scaleFactorWidth; 123 | await this.device.tap({ 124 | x: tapTargetX, 125 | y: tapTargetY, 126 | }); 127 | return { x: tapTargetX, y: tapTargetY }; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@empiricalrun/typescript-config/base", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "rootDir": "src" 6 | }, 7 | "include": [ 8 | "src/**/*" 9 | ], 10 | "exclude": [ 11 | "node_modules", 12 | "dist", 13 | "src/**/*.test.ts" 14 | ] 15 | } --------------------------------------------------------------------------------