├── .yarnrc.yml ├── .prettierrc ├── .gitignore ├── e2e ├── jest.config.js ├── simple.test.js ├── utils.js ├── expect.test.js ├── scroll.test.js └── visible.test.js ├── src ├── LoginTesteeAction.ts ├── PuppeteerScreenshotPlugin.ts ├── PuppeteerRecordVideoPlugin.ts ├── matchers.js ├── expect.js └── PuppeteerDriver.ts ├── tsconfig.json ├── LICENSE ├── Dockerfile.example ├── package.json └── README.md /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-3.6.1.cjs 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": true, 4 | "singleQuote": true, 5 | "trailingComma": "all", 6 | "bracketSpacing": true, 7 | "arrowParens": "always" 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # node.js 6 | # 7 | node_modules/ 8 | npm-debug.log 9 | yarn-error.log 10 | build/ 11 | 12 | .yarn/* 13 | !.yarn/patches 14 | !.yarn/plugins 15 | !.yarn/releases 16 | !.yarn/sdks 17 | !.yarn/versions 18 | -------------------------------------------------------------------------------- /e2e/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | maxWorkers: 1, 3 | testTimeout: 120000, 4 | verbose: true, 5 | testRegex: 'e2e/.*test.js', 6 | reporters: ['detox/runners/jest/reporter'], 7 | globalSetup: 'detox/runners/jest/globalSetup', 8 | globalTeardown: 'detox/runners/jest/globalTeardown', 9 | testEnvironment: 'detox/runners/jest/testEnvironment', 10 | }; 11 | -------------------------------------------------------------------------------- /src/LoginTesteeAction.ts: -------------------------------------------------------------------------------- 1 | export default class LoginTestee { 2 | type: 'login'; 3 | params: any; 4 | messageId; 5 | constructor(sessionId: string, role: 'testee' | 'app') { 6 | this.type = 'login'; 7 | this.params = { sessionId, role }; 8 | this.messageId; 9 | } 10 | async handle(response) { 11 | if (response.type !== 'loginSuccess') throw new Error('Unexpected response type'); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "baseUrl": ".", 6 | "paths": {}, 7 | "checkJs": false, 8 | "declaration": false, 9 | "experimentalDecorators": true, 10 | "importHelpers": true, 11 | "jsx": "preserve", 12 | "lib": ["dom", "esnext"], 13 | "module": "commonjs", 14 | "noErrorTruncation": true, 15 | "noEmitHelpers": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "noImplicitAny": false, 18 | "noImplicitReturns": true, 19 | "noImplicitThis": true, 20 | "noUnusedLocals": true, 21 | "outDir": "build", 22 | "skipLibCheck": true, 23 | "sourceMap": true, 24 | "strict": true, 25 | "target": "es6" 26 | }, 27 | "include": ["src/**/*"], 28 | "exclude": ["node_modules"] 29 | } 30 | -------------------------------------------------------------------------------- /src/PuppeteerScreenshotPlugin.ts: -------------------------------------------------------------------------------- 1 | const temporaryPath = require('detox/src/artifacts/utils/temporaryPath'); 2 | const FileArtifact = require('detox/src/artifacts/templates/artifact/FileArtifact'); 3 | const ScreenshotArtifactPlugin = require('detox/src/artifacts/screenshot/ScreenshotArtifactPlugin'); 4 | 5 | class PuppeteerScreenshotPlugin extends ScreenshotArtifactPlugin { 6 | constructor(config) { 7 | super(config); 8 | 9 | this.driver = config.driver; 10 | } 11 | 12 | createTestArtifact() { 13 | const { driver } = this; 14 | 15 | return new FileArtifact({ 16 | name: 'PuppeteerScreenshot', 17 | 18 | async start() { 19 | this.temporaryPath = temporaryPath.for.png(); 20 | await driver.takeScreenshot(this.temporaryPath); 21 | }, 22 | }); 23 | } 24 | } 25 | 26 | export default PuppeteerScreenshotPlugin; 27 | -------------------------------------------------------------------------------- /e2e/simple.test.js: -------------------------------------------------------------------------------- 1 | const utils = require('./utils'); 2 | const detox = require('detox'); 3 | 4 | let server; 5 | 6 | describe('simple', () => { 7 | beforeAll(async () => { 8 | server = await utils.startServer(); 9 | }); 10 | 11 | it('can execute the driver', async () => { 12 | await device.launchApp({ url: `http://localhost:${server.address().port}` }); 13 | 14 | await expect(element(by.id('mytestid'))).toBeVisible(); 15 | await expect(element(by.id('mytestid2'))).toNotExist(); 16 | }); 17 | 18 | it('can execute the driver 2', async () => { 19 | await device.launchApp({ url: `http://localhost:${server.address().port}` }); 20 | 21 | await expect(element(by.id('mytestid'))).toBeVisible(); 22 | await expect(element(by.id('mytestid2'))).toNotExist(); 23 | }); 24 | 25 | afterAll(async () => { 26 | await server.destroy(); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Oui Therapeutics, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/PuppeteerRecordVideoPlugin.ts: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const log = require('detox/src/utils/logger').child({ __filename }); 3 | const VideoArtifactPlugin = require('detox/src/artifacts/video/VideoArtifactPlugin'); 4 | const Artifact = require('detox/src/artifacts/templates/artifact/Artifact'); 5 | const FileArtifact = require('detox/src/artifacts/templates/artifact/FileArtifact'); 6 | 7 | class PuppeteerRecordVideoPlugin extends VideoArtifactPlugin { 8 | constructor(config) { 9 | super(config); 10 | 11 | this.driver = config.driver; 12 | } 13 | 14 | createTestRecording() { 15 | let temporaryFilePath; 16 | 17 | return new Artifact({ 18 | name: 'PuppeteerVideoRecording', 19 | start: async () => { 20 | temporaryFilePath = await this.driver.recordVideo(); 21 | }, 22 | stop: async () => { 23 | await this.driver.stopVideo(); 24 | }, 25 | save: async (artifactPath) => { 26 | await FileArtifact.moveTemporaryFile(log, temporaryFilePath, artifactPath); 27 | }, 28 | discard: async () => { 29 | await fs.unlinkSync(temporaryFilePath); 30 | }, 31 | }); 32 | } 33 | } 34 | 35 | export default PuppeteerRecordVideoPlugin; 36 | -------------------------------------------------------------------------------- /e2e/utils.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | 3 | // https://gajus.medium.com/how-to-terminate-a-http-server-in-node-js-d374f8b8c17f 4 | function enableDestroy(server) { 5 | var connections = {}; 6 | 7 | server.on('connection', function(conn) { 8 | var key = conn.remoteAddress + ':' + conn.remotePort; 9 | connections[key] = conn; 10 | conn.on('close', function() { 11 | delete connections[key]; 12 | }); 13 | }); 14 | 15 | server.destroy = function() { 16 | return new Promise((res) => { 17 | server.close(res); 18 | for (var key in connections) connections[key].destroy(); 19 | }); 20 | }; 21 | } 22 | 23 | async function startServer(content) { 24 | return new Promise((resolve) => { 25 | const server = http 26 | .createServer(function(_req, res) { 27 | res.writeHead(200, { 'Content-Type': 'text/html' }); 28 | res.end(content || `
hello world
`); 29 | }) 30 | .listen(0, () => { 31 | resolve(server); 32 | }); 33 | enableDestroy(server); 34 | }); 35 | } 36 | 37 | function sleep(time) { 38 | return new Promise((res) => setTimeout(res, time)); 39 | } 40 | 41 | module.exports = { 42 | startServer, 43 | sleep, 44 | }; 45 | -------------------------------------------------------------------------------- /Dockerfile.example: -------------------------------------------------------------------------------- 1 | ARG NODE_VERSION 2 | FROM node:$NODE_VERSION-alpine 3 | 4 | # Installs latest Chromium (79) package. 5 | RUN apk update && apk upgrade && \ 6 | echo @edge http://nl.alpinelinux.org/alpine/edge/community >> /etc/apk/repositories && \ 7 | echo @edge http://nl.alpinelinux.org/alpine/edge/main >> /etc/apk/repositories && \ 8 | apk add --no-cache \ 9 | chromium@edge \ 10 | nss@edge \ 11 | freetype@edge \ 12 | freetype-dev@edge \ 13 | harfbuzz@edge \ 14 | ca-certificates@edge \ 15 | ttf-freefont@edge \ 16 | git \ 17 | xvfb 18 | 19 | # Tell Puppeteer to skip installing Chrome. We'll be using the installed package. 20 | ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true 21 | ENV PUPPETEER_EXECUTABLE_PATH /usr/bin/chromium-browser 22 | ENV JEST_PUPPETEER_CONFIG jest-puppeteer.config.ci.js 23 | ENV CI true 24 | 25 | # Puppeteer v2.0.0 works with Chromium 79. 26 | # - This installation of puppeteer will not be used if the web project this cloud builder is used 27 | # on installs puppeteer itself. I.e the web project's puppeteer dependency will take precedence. 28 | RUN yarn global add puppeteer@2.0.0 detox-cli@10.0.7 29 | 30 | # It's a good idea to use dumb-init to help prevent zombie chrome processes. 31 | ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.0/dumb-init_1.2.0_amd64 /usr/local/bin/dumb-init 32 | RUN chmod +x /usr/local/bin/dumb-init 33 | 34 | ENTRYPOINT ["dumb-init", "--", "yarn"] 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "detox-puppeteer", 3 | "version": "8.0.6", 4 | "main": "build/PuppeteerDriver.js", 5 | "scripts": { 6 | "build": "tsc", 7 | "test": "tsc && detox test --configuration simple", 8 | "test-video": "detox test --configuration simple --record-videos all" 9 | }, 10 | "peerDependencies": { 11 | "detox": "^20.13.0" 12 | }, 13 | "resolutions": { 14 | "mkdirp": "^1.0.0" 15 | }, 16 | "dependencies": { 17 | "lodash": "^4.17.19", 18 | "puppeteer": "^22.0.0", 19 | "puppeteer-screen-recorder": "^3.0.0", 20 | "tslib": "^2.2.0" 21 | }, 22 | "devDependencies": { 23 | "@babel/runtime": "^7.8.4", 24 | "@types/jest": "^29.0.2", 25 | "@types/lodash": "^4.14.149", 26 | "@types/node": "^13.7.0", 27 | "detox": "^20.13.0", 28 | "jest": "^29.0.0", 29 | "prettier": "^1.19.0", 30 | "react-native": "0.73.4", 31 | "typescript": "4.2.4" 32 | }, 33 | "detox": { 34 | "testRunner": { 35 | "args": { 36 | "$0": "jest", 37 | "config": "e2e/jest.config.js" 38 | }, 39 | "forwardEnv": true 40 | }, 41 | "devices": { 42 | "puppeteer": { 43 | "type": "./build/PuppeteerDriver.js", 44 | "defaultViewport": { 45 | "width": 375, 46 | "height": 712 47 | } 48 | } 49 | }, 50 | "apps": { 51 | "simple": { 52 | "type": "chrome", 53 | "binaryPath": "http://localhost:8889/" 54 | } 55 | }, 56 | "session": {}, 57 | "configurations": { 58 | "simple": { 59 | "device": "puppeteer", 60 | "app": "simple" 61 | }, 62 | "android.emu.release": { 63 | "device": "emulator", 64 | "app": "android.release" 65 | }, 66 | "android.att.release": { 67 | "device": "android.attached", 68 | "app": "android.release" 69 | }, 70 | "android.genymotion.release": { 71 | "device": "android.genycloud", 72 | "app": "android.release" 73 | } 74 | } 75 | }, 76 | "packageManager": "yarn@3.6.1" 77 | } 78 | -------------------------------------------------------------------------------- /e2e/expect.test.js: -------------------------------------------------------------------------------- 1 | const { jestExpect } = require('@jest/expect'); 2 | const utils = require('./utils'); 3 | const detox = require('detox'); 4 | 5 | let server; 6 | 7 | beforeAll(async () => { 8 | server = await utils.startServer(` 9 | 12 |
13 |
my top
14 |
my middle
15 |
my bottom
16 |
17 | `); 18 | await device.launchApp({ url: `http://localhost:${server.address().port}` }); 19 | }); 20 | 21 | describe('id', () => { 22 | it('expects toHaveId', async () => { 23 | await expect(element(by.id('top'))).toHaveId('top'); 24 | }); 25 | 26 | it('matches by.id', async () => { 27 | await expect(element(by.id('top'))).toExist(); 28 | }); 29 | }); 30 | 31 | describe('text', () => { 32 | it('expects toHaveText', async () => { 33 | await expect(element(by.id('top'))).toHaveText('my top'); 34 | }); 35 | 36 | it('matches by.text', async () => { 37 | await expect(element(by.text('my top'))).toExist(); 38 | }); 39 | }); 40 | 41 | describe('label', () => { 42 | it('expects label', async () => { 43 | await expect(element(by.id('middle'))).toHaveLabel('accessibility is important'); 44 | }); 45 | 46 | it('matches by label', async () => { 47 | await expect(element(by.label('accessibility is important'))).toExist(); 48 | }); 49 | }); 50 | 51 | describe('getAttributes', () => { 52 | // https://wix.github.io/Detox/docs/api/actions/#getattributes 53 | it('returns standard attributes', async () => { 54 | const attrs = await element(by.id('middle')).getAttributes(); 55 | jestExpect(attrs).toEqual({ 56 | text: 'my middle', 57 | label: 'accessibility is important', 58 | placeholder: null, 59 | enabled: true, 60 | identifier: 'middle', 61 | visible: true, 62 | value: null, 63 | frame: { 64 | x: 8, 65 | y: 26.5, 66 | width: 359, 67 | height: 18.5, 68 | top: 26.5, 69 | right: 367, 70 | bottom: 45, 71 | left: 8, 72 | }, 73 | }); 74 | }); 75 | }); 76 | 77 | afterAll(async () => { 78 | await server.destroy(); 79 | }); 80 | -------------------------------------------------------------------------------- /e2e/scroll.test.js: -------------------------------------------------------------------------------- 1 | const utils = require('./utils'); 2 | const detox = require('detox'); 3 | 4 | let server; 5 | 6 | beforeAll(async () => { 7 | server = await utils.startServer(` 8 | 11 |
12 |
top element
13 |
14 |
middle element
15 |
16 |
bottom element
17 |
18 | `); 19 | }); 20 | 21 | const scrollable = element(by.id('scrollable')); 22 | const top = element(by.id('top')); 23 | const middle = element(by.id('middle')); 24 | const bottom = element(by.id('bottom')); 25 | 26 | describe('scrollTo', () => { 27 | it('scrolls to edge vertically', async () => { 28 | await device.launchApp({ url: `http://localhost:${server.address().port}` }); 29 | 30 | await expect(scrollable).toBeVisible(); 31 | await expect(bottom).toBeNotVisible(); 32 | 33 | await scrollable.scrollTo('bottom'); 34 | 35 | await expect(bottom).toBeVisible(); 36 | }); 37 | }); 38 | 39 | describe('scroll', () => { 40 | it('scrolls by offset provided', async () => { 41 | await device.launchApp({ url: `http://localhost:${server.address().port}` }); 42 | 43 | await expect(scrollable).toBeVisible(); 44 | await expect(bottom).toBeNotVisible(); 45 | 46 | await scrollable.scroll(1000, 'down'); 47 | 48 | await expect(middle).toBeVisible(); 49 | await expect(bottom).toBeNotVisible(); 50 | 51 | await scrollable.scroll(1000, 'down'); 52 | 53 | await expect(bottom).toBeVisible(); 54 | }); 55 | 56 | describe('whileElement', () => { 57 | it('scrolls until the element is visible and stops', async () => { 58 | await device.launchApp({ url: `http://localhost:${server.address().port}` }); 59 | 60 | await expect(scrollable).toBeVisible(); 61 | await expect(bottom).toBeNotVisible(); 62 | await expect(middle).toBeNotVisible(); 63 | 64 | try { 65 | await waitFor(middle) 66 | .toBeVisible() 67 | .whileElement(by.id('scrollable')) 68 | .scroll(1500, 'down'); 69 | } catch (e) { 70 | await utils.sleep(5000000); 71 | } 72 | }, 50000000); 73 | }); 74 | }); 75 | 76 | afterAll(async () => { 77 | await server.destroy(); 78 | }); 79 | -------------------------------------------------------------------------------- /e2e/visible.test.js: -------------------------------------------------------------------------------- 1 | const utils = require('./utils'); 2 | const detox = require('detox'); 3 | 4 | let server; 5 | 6 | beforeAll(async () => { 7 | server = await utils.startServer(` 8 | 11 |
12 |
top element
13 |
14 |
middle element
15 |
16 |
bottom element
17 |
18 | `); 19 | }); 20 | 21 | describe('visible', () => { 22 | it('waits for visible elements', async () => { 23 | const scrollable = element(by.id('scrollable')); 24 | const top = element(by.id('top')); 25 | 26 | await device.launchApp({ url: `http://localhost:${server.address().port}` }); 27 | 28 | await waitFor(scrollable) 29 | .toBeVisible() 30 | .withTimeout(100); 31 | await waitFor(top) 32 | .toBeVisible() 33 | .withTimeout(100); 34 | }); 35 | 36 | it('waits for non visible elements', async () => { 37 | await device.launchApp({ url: `http://localhost:${server.address().port}` }); 38 | 39 | const middle = element(by.id('middle')); 40 | const bottom = element(by.id('bottom')); 41 | 42 | await waitFor(middle) 43 | .toBeNotVisible() 44 | .withTimeout(100); 45 | await waitFor(bottom) 46 | .toBeNotVisible() 47 | .withTimeout(100); 48 | }); 49 | 50 | it('expects visible elements', async () => { 51 | await device.launchApp({ url: `http://localhost:${server.address().port}` }); 52 | 53 | const scrollable = element(by.id('scrollable')); 54 | const top = element(by.id('top')); 55 | 56 | await expect(scrollable).toBeVisible(); 57 | await expect(top).toBeVisible(); 58 | }); 59 | 60 | it('expects non visible elements', async () => { 61 | await device.launchApp({ url: `http://localhost:${server.address().port}` }); 62 | const middle = element(by.id('middle')); 63 | const bottom = element(by.id('bottom')); 64 | 65 | await expect(middle).toBeNotVisible(); 66 | await expect(bottom).toBeNotVisible(); 67 | }); 68 | }); 69 | 70 | describe('exist', () => { 71 | it('matches for existing elements', async () => { 72 | await device.launchApp({ url: `http://localhost:${server.address().port}` }); 73 | 74 | const scrollable = element(by.id('scrollable')); 75 | const top = element(by.id('top')); 76 | const middle = element(by.id('middle')); 77 | const bottom = element(by.id('bottom')); 78 | 79 | await expect(scrollable).toExist(); 80 | await expect(top).toExist(); 81 | await expect(middle).toExist(); 82 | await expect(bottom).toExist(); 83 | }); 84 | 85 | it('matches for non existing elements', async () => { 86 | await device.launchApp({ url: `http://localhost:${server.address().port}` }); 87 | 88 | await expect(element(by.id('RandomJunk959'))).toNotExist(); 89 | }); 90 | }); 91 | 92 | afterAll(async () => { 93 | await server.destroy(); 94 | }); 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | `detox-puppeteer` is a custom driver for [Detox](https://github.com/wix/Detox/) that runs e2e tests for web apps with [Puppeteer](https://github.com/puppeteer/puppeteer/) behind the scenes. `detox-puppeteer` may be a good fit for you if you already use detox for testing your android + ios react-native apps and have a web version as well. 4 | 5 | ## Getting started 6 | 7 | **This plugin requires Detox ≥ `20.0.0`.** 8 | 9 | > For Detox v19, use detox-puppeteer `4.x.x`. 10 | 11 | > For Detox v18 and v17, use detox-puppeteer `v3.x.x`. 12 | 13 | 1. Run `yarn add --dev detox-puppeteer` 14 | 1. [add a new detox configuration](https://github.com/wix/Detox/blob/master/docs/APIRef.Configuration.md) for `detox-puppeteer` 15 | 16 | #### New Detox configuration format 17 | 18 | ``` 19 | ... 20 | "detox": { 21 | "devices": { 22 | "puppeteer-mobile": { 23 | "type": "detox-puppeteer", 24 | "defaultViewport": { 25 | "width": 375, 26 | "height": 712 27 | } 28 | } 29 | }, 30 | "apps": { 31 | "localapp": { 32 | "type": "chrome", 33 | "binaryPath": "http://localhost:8889/" 34 | } 35 | }, 36 | "configurations": { 37 | "web.example": { 38 | "device": "puppeteer-mobile", 39 | "app": "localapp" 40 | }, 41 | }, 42 | ... 43 | ``` 44 | 45 | 46 | #### Old Detox configuration format 47 | 48 | ``` 49 | ... 50 | "detox": { 51 | "configurations": { 52 | "web.example": { 53 | "binaryPath": "http://example.com/", // Note trailing slash 54 | "type": "detox-puppeteer", 55 | "device": { 56 | "defaultViewport": { 57 | "width": 375, 58 | "height": 712 59 | }, 60 | "headless": false, // optional 61 | "devtools": false, // optional 62 | }, 63 | "name": "puppeteer" 64 | }, 65 | }, 66 | ... 67 | ``` 68 | 69 | ### Running on CI 70 | 71 | In your CI service of choice, run a container based off of `Dockerfile.example` provided here. 72 | 73 | If you install your node modules in a build step that doesn't use this container, you can set 74 | `PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true` when running `npm install` or `yarn install` as the 75 | container image comes with chromium already installed. The puppeteer npm package will download 76 | chromium by default unless the ENV variable is set. 77 | 78 | ### Workarounds 79 | 80 | When screen recording is enabled, chromium will display a toolbar that pushes the page content 81 | out of the viewport. To compensate you can add the following css to your app: 82 | 83 | ```css 84 | body.detox-puppeteer > #root { 85 | height: calc(100vh - 50px); 86 | } 87 | ``` 88 | 89 | Sometimes the detox API is insufficient for interacting with the full range of puppeteer 90 | functionality. detox-puppeteer exposes puppeteer's browser object so your tests can opt-in to 91 | custom puppeteer functionality. 92 | 93 | ```ts 94 | export async function getPuppeteerPage() { 95 | const browser: Browser = (detox.device as any).deviceDriver.getBrowser(); 96 | return (await browser.pages())[0]; 97 | } 98 | ``` 99 | 100 | ## Credits 101 | 102 | Thanks to the following people / organizations 103 | 104 | - https://github.com/wix/ and the detox maintaners for detox 105 | - @muralikg for https://github.com/muralikg/puppetcam 106 | - The puppeteer team for https://github.com/puppeteer/puppeteer/ 107 | -------------------------------------------------------------------------------- /src/matchers.js: -------------------------------------------------------------------------------- 1 | const invoke = require('detox/src/invoke'); 2 | 3 | class Matcher { 4 | withAncestor(matcher) { 5 | const _originalMatcherCall = this._call; 6 | if (_originalMatcherCall.method === 'selector' && matcher._call.method === 'selector') { 7 | this._call = { 8 | ..._originalMatcherCall, 9 | args: [`${matcher._call.args[0]}//*${_originalMatcherCall.args[0]}`], 10 | }; 11 | } else { 12 | throw new Error('Complex withAncestor not supported'); 13 | } 14 | return this; 15 | } 16 | withDescendant(matcher) { 17 | const _originalMatcherCall = this._call; 18 | if (_originalMatcherCall.method === 'selector' && matcher._call.method === 'selector') { 19 | this._call = { 20 | ..._originalMatcherCall, 21 | args: [`${_originalMatcherCall.args[0]}[descendant::*${matcher._call.args[0]}]`], 22 | }; 23 | } else { 24 | throw new Error('Complex withDescendent not supported'); 25 | } 26 | return this; 27 | } 28 | and(matcher) { 29 | const _originalMatcherCall = this._call; 30 | // TODO guard around complex combos 31 | if (matcher._call.args[0].startsWith('/')) { 32 | this._call = { 33 | target: { 34 | type: 'matcher', 35 | value: 'matcher', 36 | }, 37 | method: 'selector', 38 | args: [`${matcher._call.args[0]}${_originalMatcherCall.args[0]}`], 39 | }; 40 | } else if (_originalMatcherCall.args[0].startsWith('/')) { 41 | this._call = { 42 | target: { 43 | type: 'matcher', 44 | value: 'matcher', 45 | }, 46 | method: 'selector', 47 | args: [`${_originalMatcherCall.args[0]}${matcher._call.args[0]}`], 48 | }; 49 | } else { 50 | this._call = { 51 | target: { 52 | type: 'matcher', 53 | value: 'matcher', 54 | }, 55 | method: 'selector', 56 | args: [`${_originalMatcherCall.args[0]}${matcher._call.args[0]}`], 57 | }; 58 | } 59 | return this; 60 | } 61 | not() { 62 | throw new Error('not yet implemented'); 63 | // const _originalMatcherCall = this._call; 64 | // this._call = invoke.callDirectly(GreyMatchersDetox.detoxMatcherForNot(_originalMatcherCall)); 65 | // return this; 66 | } 67 | _avoidProblematicReactNativeElements() { 68 | throw new Error('not yet implemented'); 69 | // const _originalMatcherCall = this._call; 70 | // this._call = invoke.callDirectly( 71 | // GreyMatchersDetox.detoxMatcherAvoidingProblematicReactNativeElements(_originalMatcherCall), 72 | // ); 73 | // return this; 74 | } 75 | _extendToDescendantScrollViews() { 76 | throw new Error('not yet implemented'); 77 | // const _originalMatcherCall = this._call; 78 | // this._call = invoke.callDirectly( 79 | // GreyMatchersDetox.detoxMatcherForScrollChildOfMatcher(_originalMatcherCall), 80 | // ); 81 | // return this; 82 | } 83 | _extendPickerViewMatching() { 84 | throw new Error('not yet implemented'); 85 | // const _originalMatcherCall = this._call; 86 | // this._call = invoke.callDirectly( 87 | // GreyMatchersDetox.detoxMatcherForPickerViewChildOfMatcher(_originalMatcherCall), 88 | // ); 89 | // return this; 90 | } 91 | } 92 | 93 | function normalizeRegex(value) { 94 | // for some reason instanceof RegExp isn't working in this layer when a value is passed from 95 | // testing code 96 | if (typeof value === 'object') { 97 | const [prefix, pattern, flags] = value.toString().split('/'); 98 | // cleanup special characters 99 | return { value: pattern.replace(/[^\w\s\d]/g, ''), regex: true }; 100 | } 101 | 102 | return { value, regex: false }; 103 | } 104 | 105 | class IndexMatcher extends Matcher { 106 | constructor(value) { 107 | super(); 108 | this._call = { 109 | target: { 110 | type: 'matcher', 111 | value: 'matcher', 112 | }, 113 | method: 'index', 114 | args: [value], 115 | }; 116 | } 117 | } 118 | 119 | class LabelMatcher extends Matcher { 120 | constructor(_value) { 121 | super(); 122 | 123 | const { value, regex } = normalizeRegex(_value); 124 | 125 | this._call = { 126 | target: { 127 | type: 'matcher', 128 | value: 'matcher', 129 | }, 130 | method: 'selector', 131 | args: [regex ? `[contains(@aria-label, '${value}')]` : `[@aria-label='${value}']`], 132 | }; 133 | } 134 | } 135 | 136 | class IdMatcher extends Matcher { 137 | constructor(value) { 138 | super(); 139 | this._call = { 140 | target: { 141 | type: 'matcher', 142 | value: 'matcher', 143 | }, 144 | method: 'selector', 145 | args: [`[@data-testid="${value}"]`], 146 | }; 147 | } 148 | } 149 | 150 | class TypeMatcher extends Matcher { 151 | constructor(value) { 152 | super(); 153 | this._call = { 154 | target: { 155 | type: 'matcher', 156 | value: 'matcher', 157 | }, 158 | method: 'selector', 159 | args: [`//${value}`], 160 | }; 161 | } 162 | } 163 | 164 | // iOS only, just a dummy matcher here 165 | class TraitsMatcher extends Matcher { 166 | constructor(value) { 167 | super(); 168 | this._call = { 169 | target: { 170 | type: 'matcher', 171 | value: 'matcher', 172 | }, 173 | method: 'selector', 174 | args: [`*`], 175 | }; 176 | } 177 | } 178 | 179 | class VisibleMatcher extends Matcher { 180 | constructor() { 181 | super(); 182 | this._call = { 183 | target: { 184 | type: 'matcher', 185 | value: 'matcher', 186 | }, 187 | method: 'option', 188 | args: [{ visible: true }], 189 | }; 190 | } 191 | } 192 | 193 | class NotVisibleMatcher extends Matcher { 194 | constructor() { 195 | super(); 196 | this._call = { 197 | target: { 198 | type: 'matcher', 199 | value: 'matcher', 200 | }, 201 | method: 'option', 202 | args: [{ visible: false }], 203 | }; 204 | } 205 | } 206 | 207 | class ExistsMatcher extends Matcher { 208 | constructor() { 209 | super(); 210 | this._call = { 211 | target: { 212 | type: 'matcher', 213 | value: 'matcher', 214 | }, 215 | method: 'option', 216 | args: [{ exists: true }], 217 | }; 218 | } 219 | } 220 | 221 | class NotExistsMatcher extends Matcher { 222 | constructor() { 223 | super(); 224 | this._call = { 225 | target: { 226 | type: 'matcher', 227 | value: 'matcher', 228 | }, 229 | method: 'option', 230 | args: [{ exists: false }], 231 | }; 232 | } 233 | } 234 | 235 | class TextMatcher extends Matcher { 236 | constructor(_value) { 237 | super(); 238 | const { value, regex } = normalizeRegex(_value); 239 | this._call = { 240 | target: { 241 | type: 'matcher', 242 | value: 'matcher', 243 | }, 244 | method: 'selector', 245 | args: [`[contains(., '${value}') or @value='${value}']`], 246 | }; 247 | } 248 | } 249 | 250 | class ValueMatcher extends Matcher { 251 | constructor(value) { 252 | super(); 253 | this._call = { 254 | target: { 255 | type: 'matcher', 256 | value: 'matcher', 257 | }, 258 | method: 'selector', 259 | args: [`[@value="${value}"]`], 260 | }; 261 | } 262 | } 263 | 264 | class NotValueMatcher extends Matcher { 265 | constructor(value) { 266 | super(); 267 | this._call = { 268 | target: { 269 | type: 'matcher', 270 | value: 'matcher', 271 | }, 272 | method: 'selector', 273 | args: [`[not(@value="${value}")]`], 274 | }; 275 | } 276 | } 277 | 278 | export { 279 | Matcher, 280 | LabelMatcher, 281 | IdMatcher, 282 | TypeMatcher, 283 | TraitsMatcher, 284 | VisibleMatcher, 285 | NotVisibleMatcher, 286 | ExistsMatcher, 287 | NotExistsMatcher, 288 | TextMatcher, 289 | IndexMatcher, 290 | ValueMatcher, 291 | NotValueMatcher, 292 | }; 293 | -------------------------------------------------------------------------------- /src/expect.js: -------------------------------------------------------------------------------- 1 | const invoke = require('detox/src/invoke'); 2 | import * as matchers from './matchers'; 3 | const Matcher = matchers.Matcher; 4 | const LabelMatcher = matchers.LabelMatcher; 5 | const IndexMatcher = matchers.IndexMatcher; 6 | const IdMatcher = matchers.IdMatcher; 7 | const TypeMatcher = matchers.TypeMatcher; 8 | const TraitsMatcher = matchers.TraitsMatcher; 9 | const VisibleMatcher = matchers.VisibleMatcher; 10 | const NotVisibleMatcher = matchers.NotVisibleMatcher; 11 | const ExistsMatcher = matchers.ExistsMatcher; 12 | const NotExistsMatcher = matchers.NotExistsMatcher; 13 | const NotValueMatcher = matchers.NotValueMatcher; 14 | const TextMatcher = matchers.TextMatcher; 15 | const ValueMatcher = matchers.ValueMatcher; 16 | 17 | function callThunk(element) { 18 | return typeof element._call === 'function' ? element._call() : element._call; 19 | } 20 | 21 | class Action {} 22 | 23 | class GetAttributesAction extends Action { 24 | constructor() { 25 | super(); 26 | this._call = { 27 | target: { 28 | type: 'action', 29 | value: 'action', 30 | }, 31 | method: 'getAttributes', 32 | args: [], 33 | }; 34 | } 35 | } 36 | 37 | class TapAction extends Action { 38 | constructor() { 39 | super(); 40 | this._call = { 41 | target: { 42 | type: 'action', 43 | value: 'action', 44 | }, 45 | method: 'tap', 46 | args: [], 47 | }; 48 | } 49 | } 50 | 51 | class TapAtPointAction extends Action { 52 | constructor(value) { 53 | super(); 54 | this._call = { 55 | target: { 56 | type: 'action', 57 | value: 'action', 58 | }, 59 | method: 'tapAtPoint', 60 | args: [value], // NB value is currently unused 61 | }; 62 | } 63 | } 64 | 65 | class LongPressAction extends Action { 66 | constructor(duration) { 67 | super(); 68 | if (typeof duration !== 'number') { 69 | this._call = { 70 | target: { 71 | type: 'action', 72 | value: 'action', 73 | }, 74 | method: 'longPress', 75 | args: [700], // https://github.com/google/EarlGrey/blob/91c27bb8a15e723df974f620f7f576a30a6a7484/EarlGrey/Common/GREYConstants.m#L27 76 | }; 77 | } else { 78 | this._call = { 79 | target: { 80 | type: 'action', 81 | value: 'action', 82 | }, 83 | method: 'longPress', 84 | args: [duration], 85 | }; 86 | } 87 | } 88 | } 89 | 90 | class MultiTapAction extends Action { 91 | constructor(value) { 92 | super(); 93 | this._call = { 94 | target: { 95 | type: 'action', 96 | value: 'action', 97 | }, 98 | method: 'multiTap', 99 | args: [value], 100 | }; 101 | } 102 | } 103 | 104 | class PinchAction extends Action { 105 | constructor(direction, speed, angle) { 106 | super(); 107 | if (typeof direction !== 'string') 108 | throw new Error(`PinchAction ctor 1st argument must be a string, got ${typeof direction}`); 109 | if (typeof speed !== 'string') 110 | throw new Error(`PinchAction ctor 2nd argument must be a string, got ${typeof speed}`); 111 | if (typeof angle !== 'number') 112 | throw new Error(`PinchAction ctor 3nd argument must be a number, got ${typeof angle}`); 113 | if (speed === 'fast') { 114 | this._call = invoke.callDirectly( 115 | GreyActions.actionForPinchFastInDirectionWithAngle(direction, angle), 116 | ); 117 | } else if (speed === 'slow') { 118 | this._call = invoke.callDirectly( 119 | GreyActions.actionForPinchSlowInDirectionWithAngle(direction, angle), 120 | ); 121 | } else { 122 | throw new Error(`PinchAction speed must be a 'fast'/'slow', got ${speed}`); 123 | } 124 | } 125 | } 126 | 127 | class TypeTextAction extends Action { 128 | constructor(value) { 129 | super(); 130 | this._call = { 131 | target: { 132 | type: 'action', 133 | value: 'action', 134 | }, 135 | method: 'typeText', 136 | args: [value], 137 | }; 138 | } 139 | } 140 | 141 | class KeyboardPressAction extends Action { 142 | constructor(value) { 143 | super(); 144 | this._call = { 145 | target: { 146 | type: 'action', 147 | value: 'action', 148 | }, 149 | method: 'keyboardPress', 150 | args: [value], 151 | }; 152 | } 153 | } 154 | 155 | class ReplaceTextAction extends Action { 156 | constructor(value) { 157 | super(); 158 | this._call = { 159 | target: { 160 | type: 'action', 161 | value: 'action', 162 | }, 163 | method: 'replaceText', 164 | args: [value], 165 | }; 166 | } 167 | } 168 | 169 | class ClearTextAction extends Action { 170 | constructor() { 171 | super(); 172 | this._call = { 173 | target: { 174 | type: 'action', 175 | value: 'action', 176 | }, 177 | method: 'clearText', 178 | args: [], 179 | }; 180 | } 181 | } 182 | 183 | class ScrollAmountAction extends Action { 184 | constructor(direction, amount, startScrollX = NaN, startScrollY = NaN) { 185 | super(); 186 | this._call = { 187 | target: { 188 | type: 'action', 189 | value: 'action', 190 | }, 191 | method: 'scroll', 192 | args: [direction, amount], 193 | }; 194 | } 195 | } 196 | 197 | class ScrollEdgeAction extends Action { 198 | constructor(edge) { 199 | super(); 200 | 201 | this._call = { 202 | target: { 203 | type: 'action', 204 | value: 'action', 205 | }, 206 | method: 'scrollTo', 207 | args: [edge], 208 | }; 209 | } 210 | } 211 | 212 | class SwipeAction extends Action { 213 | constructor(direction, speed, percentage, normalizedStartingPointX, normalizedStartingPointY) { 214 | super(); 215 | if (typeof direction !== 'string') 216 | throw new Error(`SwipeAction ctor 1st argument must be a string, got ${typeof direction}`); 217 | if (typeof speed !== 'string') 218 | throw new Error(`SwipeAction ctor 2nd argument must be a string, got ${typeof speed}`); 219 | 220 | this._call = { 221 | target: { 222 | type: 'action', 223 | value: 'action', 224 | }, 225 | method: 'swipe', 226 | args: [direction, speed, percentage, normalizedStartingPointX, normalizedStartingPointY], 227 | }; 228 | } 229 | } 230 | 231 | class ScrollColumnToValue extends Action { 232 | constructor(column, value) { 233 | super(); 234 | this._call = invoke.callDirectly(GreyActions.actionForSetPickerColumnToValue(column, value)); 235 | } 236 | } 237 | 238 | class SetDatePickerDate extends Action { 239 | constructor(dateString, dateFormat) { 240 | super(); 241 | this._call = invoke.callDirectly( 242 | GreyActionsDetox.detoxSetDatePickerDateWithFormat(dateString, dateFormat), 243 | ); 244 | } 245 | } 246 | 247 | class Interaction { 248 | constructor(invocationManager) { 249 | this._invocationManager = invocationManager; 250 | } 251 | 252 | async execute() { 253 | //if (!this._call) throw new Error(`Interaction.execute cannot find a valid _call, got ${typeof this._call}`); 254 | return await this._invocationManager.execute(this._call); 255 | } 256 | } 257 | 258 | class ActionInteraction extends Interaction { 259 | constructor(invocationManager, element, action) { 260 | super(invocationManager); 261 | 262 | // this._call = GreyInteraction.performAction(invoke.callDirectly(callThunk(element)), callThunk(action)); 263 | this._call = { 264 | target: { 265 | type: 'this', 266 | value: 'this', 267 | }, 268 | method: 'performAction', 269 | args: [invoke.callDirectly(callThunk(element)), callThunk(action)], 270 | }; 271 | } 272 | } 273 | 274 | class MatcherAssertionInteraction extends Interaction { 275 | constructor(invocationManager, element, matcher) { 276 | super(invocationManager); 277 | 278 | this._call = { 279 | target: 'this', 280 | method: 'assertWithMatcher', 281 | args: [invoke.callDirectly(callThunk(element)), callThunk(matcher)], 282 | }; 283 | } 284 | } 285 | 286 | class WaitForInteraction extends Interaction { 287 | constructor(invocationManager, element, matcher) { 288 | super(invocationManager); 289 | //if (!(element instanceof Element)) throw new Error(`WaitForInteraction ctor 1st argument must be a valid Element, got ${typeof element}`); 290 | //if (!(matcher instanceof Matcher)) throw new Error(`WaitForInteraction ctor 2nd argument must be a valid Matcher, got ${typeof matcher}`); 291 | this._element = element; 292 | this._originalMatcher = matcher; 293 | // we need to override the original matcher for the element and add matcher to it as well 294 | this._element._selectElementWithMatcher(this._element._originalMatcher, this._originalMatcher); 295 | } 296 | async withTimeout(timeout) { 297 | if (typeof timeout !== 'number') 298 | throw new Error( 299 | `WaitForInteraction withTimeout argument must be a number, got ${typeof timeout}`, 300 | ); 301 | if (timeout < 0) throw new Error('timeout must be larger than 0'); 302 | 303 | let _conditionCall; 304 | 305 | const call = callThunk(this._element); 306 | call.args.push({ 307 | target: { 308 | type: 'matcher', 309 | value: 'matcher', 310 | }, 311 | method: 'option', 312 | args: [{ timeout }], 313 | }); 314 | this._call = call; 315 | // this._call = GreyCondition.waitWithTimeout(invoke.callDirectly(_conditionCall), timeout / 1000); 316 | await this.execute(); 317 | } 318 | whileElement(searchMatcher) { 319 | return new WaitForActionInteraction( 320 | this._invocationManager, 321 | this._element, 322 | this._originalMatcher, 323 | searchMatcher, 324 | ); 325 | } 326 | } 327 | 328 | class WaitForActionInteraction extends Interaction { 329 | constructor(invocationManager, element, matcher, searchMatcher) { 330 | super(invocationManager); 331 | //if (!(element instanceof Element)) throw new Error(`WaitForActionInteraction ctor 1st argument must be a valid Element, got ${typeof element}`); 332 | //if (!(matcher instanceof Matcher)) throw new Error(`WaitForActionInteraction ctor 2nd argument must be a valid Matcher, got ${typeof matcher}`); 333 | if (!(searchMatcher instanceof Matcher)) 334 | throw new Error( 335 | `WaitForActionInteraction ctor 3rd argument must be a valid Matcher, got ${typeof searchMatcher}`, 336 | ); 337 | this._element = element; 338 | this._originalMatcher = matcher; 339 | this._searchMatcher = searchMatcher; 340 | } 341 | 342 | async _execute(searchAction) { 343 | const _interactionCall = { 344 | target: { 345 | type: 'this', 346 | value: 'this', 347 | }, 348 | method: 'selectElementWhileScrolling', 349 | args: [callThunk(this._element), callThunk(searchAction), callThunk(this._searchMatcher)], 350 | }; 351 | 352 | this._call = { 353 | target: { 354 | type: 'this', 355 | value: 'this', 356 | }, 357 | method: 'assertWithMatcher', 358 | args: [invoke.callDirectly(_interactionCall), callThunk(this._originalMatcher)], 359 | }; 360 | 361 | await this.execute(); 362 | } 363 | 364 | async scroll(amount, direction = 'down', startScrollX, startScrollY) { 365 | // override the user's element selection with an extended matcher that looks for UIScrollView children 366 | // this._searchMatcher = this._searchMatcher._extendToDescendantScrollViews(); 367 | await this._execute(new ScrollAmountAction(direction, amount, startScrollX, startScrollY)); 368 | } 369 | } 370 | 371 | class Element { 372 | constructor(invocationManager, matcher) { 373 | this._invocationManager = invocationManager; 374 | this._originalMatcher = matcher; 375 | this._selectElementWithMatcher(this._originalMatcher); 376 | } 377 | _selectElementWithMatcher(...matchers) { 378 | // if (!(matcher instanceof Matcher)) 379 | // throw new Error(`Element _selectElementWithMatcher argument must be a valid Matcher, got ${typeof matcher}`); 380 | matchers = Array.isArray(matchers) ? matchers : [matchers]; 381 | this._call = invoke.call( 382 | { 383 | type: 'this', 384 | value: 'this', 385 | }, 386 | 'selectElementWithMatcher', 387 | ...matchers.map((m) => m._call), 388 | ); 389 | // if (this._atIndex !== undefined) { 390 | // this.atIndex(this._atIndex); 391 | // } 392 | } 393 | atIndex(index) { 394 | if (typeof index !== 'number') 395 | throw new Error(`Element atIndex argument must be a number, got ${typeof index}`); 396 | const _originalCall = this._call; 397 | this._atIndex = index; 398 | this._selectElementWithMatcher(this._originalMatcher, new IndexMatcher(index)); 399 | return this; 400 | } 401 | async getAttributes() { 402 | return await new ActionInteraction( 403 | this._invocationManager, 404 | this, 405 | new GetAttributesAction(), 406 | ).execute(); 407 | } 408 | async tap() { 409 | return await new ActionInteraction(this._invocationManager, this, new TapAction()).execute(); 410 | } 411 | async tapAtPoint(value) { 412 | return await new ActionInteraction( 413 | this._invocationManager, 414 | this, 415 | new TapAtPointAction(value), 416 | ).execute(); 417 | } 418 | async longPress(duration) { 419 | return await new ActionInteraction( 420 | this._invocationManager, 421 | this, 422 | new LongPressAction(duration), 423 | ).execute(); 424 | } 425 | async multiTap(value) { 426 | return await new ActionInteraction( 427 | this._invocationManager, 428 | this, 429 | new MultiTapAction(value), 430 | ).execute(); 431 | } 432 | async tapBackspaceKey() { 433 | return await new ActionInteraction( 434 | this._invocationManager, 435 | this, 436 | new KeyboardPressAction('Backspace'), 437 | ).execute(); 438 | } 439 | async tapReturnKey() { 440 | return await new ActionInteraction( 441 | this._invocationManager, 442 | this, 443 | new TypeTextAction(String.fromCharCode(13)), 444 | ).execute(); 445 | } 446 | async typeText(value) { 447 | return await new ActionInteraction( 448 | this._invocationManager, 449 | this, 450 | new TypeTextAction(value), 451 | ).execute(); 452 | } 453 | async replaceText(value) { 454 | return await new ActionInteraction( 455 | this._invocationManager, 456 | this, 457 | new ReplaceTextAction(value), 458 | ).execute(); 459 | } 460 | async clearText() { 461 | return await new ActionInteraction( 462 | this._invocationManager, 463 | this, 464 | new ClearTextAction(), 465 | ).execute(); 466 | } 467 | async pinchWithAngle(direction, speed = 'slow', angle = 0) { 468 | return await new ActionInteraction( 469 | this._invocationManager, 470 | this, 471 | new PinchAction(direction, speed, angle), 472 | ).execute(); 473 | } 474 | async scroll(amount, direction = 'down', startScrollX, startScrollY) { 475 | // override the user's element selection with an extended matcher that looks for UIScrollView children 476 | // this._selectElementWithMatcher(this._originalMatcher._extendToDescendantScrollViews()); 477 | return await new ActionInteraction( 478 | this._invocationManager, 479 | this, 480 | new ScrollAmountAction(direction, amount, startScrollX, startScrollY), 481 | ).execute(); 482 | } 483 | async scrollTo(edge) { 484 | // override the user's element selection with an extended matcher that looks for UIScrollView children 485 | // this._selectElementWithMatcher(this._originalMatcher._extendToDescendantScrollViews()); 486 | return await new ActionInteraction( 487 | this._invocationManager, 488 | this, 489 | new ScrollEdgeAction(edge), 490 | ).execute(); 491 | } 492 | async swipe( 493 | direction, 494 | speed = 'fast', 495 | percentage = NaN, 496 | normalizedStartingPointX = NaN, 497 | normalizedStartingPointY = NaN, 498 | ) { 499 | return await new ActionInteraction( 500 | this._invocationManager, 501 | this, 502 | new SwipeAction( 503 | direction, 504 | speed, 505 | percentage, 506 | normalizedStartingPointX, 507 | normalizedStartingPointY, 508 | ), 509 | ).execute(); 510 | } 511 | async setColumnToValue(column, value) { 512 | // override the user's element selection with an extended matcher that supports RN's date picker 513 | this._selectElementWithMatcher(this._originalMatcher._extendPickerViewMatching()); 514 | return await new ActionInteraction( 515 | this._invocationManager, 516 | this, 517 | new ScrollColumnToValue(column, value), 518 | ).execute(); 519 | } 520 | async setDatePickerDate(dateString, dateFormat) { 521 | return await new ActionInteraction( 522 | this._invocationManager, 523 | this, 524 | new SetDatePickerDate(dateString, dateFormat), 525 | ).execute(); 526 | } 527 | } 528 | 529 | class Expect { 530 | constructor(invocationManager) { 531 | this._invocationManager = invocationManager; 532 | } 533 | } 534 | 535 | class ExpectElement extends Expect { 536 | constructor(invocationManager, element) { 537 | super(invocationManager); 538 | this._element = element; 539 | } 540 | async toBeVisible() { 541 | return await new MatcherAssertionInteraction( 542 | this._invocationManager, 543 | this._element, 544 | new VisibleMatcher(), 545 | ).execute(); 546 | } 547 | async toBeNotVisible() { 548 | return await new MatcherAssertionInteraction( 549 | this._invocationManager, 550 | this._element, 551 | new NotVisibleMatcher(), 552 | ).execute(); 553 | } 554 | async toExist() { 555 | return await new MatcherAssertionInteraction( 556 | this._invocationManager, 557 | this._element, 558 | new ExistsMatcher(), 559 | ).execute(); 560 | } 561 | async toNotExist() { 562 | return await new MatcherAssertionInteraction( 563 | this._invocationManager, 564 | this._element, 565 | new NotExistsMatcher(), 566 | ).execute(); 567 | } 568 | async toHaveText(value) { 569 | return await new MatcherAssertionInteraction( 570 | this._invocationManager, 571 | this._element, 572 | new TextMatcher(value), 573 | ).execute(); 574 | } 575 | async toHaveLabel(value) { 576 | return await new MatcherAssertionInteraction( 577 | this._invocationManager, 578 | this._element, 579 | new LabelMatcher(value), 580 | ).execute(); 581 | } 582 | async toHaveId(value) { 583 | return await new MatcherAssertionInteraction( 584 | this._invocationManager, 585 | this._element, 586 | new IdMatcher(value), 587 | ).execute(); 588 | } 589 | async toHaveValue(value) { 590 | return await new MatcherAssertionInteraction( 591 | this._invocationManager, 592 | this._element, 593 | new ValueMatcher(value), 594 | ).execute(); 595 | } 596 | } 597 | 598 | class WaitFor { 599 | constructor(invocationManager) { 600 | this._invocationManager = invocationManager; 601 | } 602 | } 603 | 604 | class WaitForElement extends WaitFor { 605 | constructor(invocationManager, element) { 606 | super(invocationManager); 607 | //if ((!element instanceof Element)) throw new Error(`WaitForElement ctor argument must be a valid Element, got ${typeof element}`); 608 | this._element = element; 609 | } 610 | toBeVisible() { 611 | return new WaitForInteraction(this._invocationManager, this._element, new VisibleMatcher()); 612 | } 613 | toBeNotVisible() { 614 | return new WaitForInteraction(this._invocationManager, this._element, new NotVisibleMatcher()); 615 | } 616 | toExist() { 617 | return new WaitForInteraction(this._invocationManager, this._element, new ExistsMatcher()); 618 | } 619 | toNotExist() { 620 | return new WaitForInteraction(this._invocationManager, this._element, new NotExistsMatcher()); 621 | } 622 | toHaveText(text) { 623 | return new WaitForInteraction(this._invocationManager, this._element, new TextMatcher(text)); 624 | } 625 | toHaveValue(value) { 626 | return new WaitForInteraction(this._invocationManager, this._element, new ValueMatcher(value)); 627 | } 628 | toNotHaveValue(value) { 629 | return new WaitForInteraction( 630 | this._invocationManager, 631 | this._element, 632 | new NotValueMatcher(value), 633 | ); 634 | } 635 | } 636 | 637 | class WebExpect { 638 | constructor({ invocationManager }) { 639 | this._invocationManager = invocationManager; 640 | 641 | this.by = { 642 | accessibilityLabel: (value) => new LabelMatcher(value), 643 | label: (value) => new LabelMatcher(value), 644 | id: (value) => new IdMatcher(value), 645 | type: (value) => new TypeMatcher(value), 646 | traits: (value) => new TraitsMatcher(value), 647 | value: (value) => new ValueMatcher(value), 648 | text: (value) => new TextMatcher(value), 649 | }; 650 | 651 | this.element = this.element.bind(this); 652 | this.expect = this.expect.bind(this); 653 | this.waitFor = this.waitFor.bind(this); 654 | } 655 | 656 | expect(element) { 657 | if (element instanceof Element) return new ExpectElement(this._invocationManager, element); 658 | throw new Error(`expect() argument is invalid, got ${typeof element}`); 659 | } 660 | 661 | element(matcher) { 662 | return new Element(this._invocationManager, matcher); 663 | } 664 | 665 | waitFor(element) { 666 | if (element instanceof Element) return new WaitForElement(this._invocationManager, element); 667 | throw new Error(`waitFor() argument is invalid, got ${typeof element}`); 668 | } 669 | } 670 | 671 | export default WebExpect; 672 | -------------------------------------------------------------------------------- /src/PuppeteerDriver.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import { PuppeteerScreenRecorder } from 'puppeteer-screen-recorder'; 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const os = require('os'); 6 | 7 | const log = require('detox/src/utils/logger').child({ __filename }); 8 | const DeviceDriverBase = require('detox/src/devices/runtime/drivers/DeviceDriverBase'); 9 | const temporaryPath = require('detox/src/artifacts/utils/temporaryPath'); 10 | const Client = require('detox/src/client/Client'); 11 | 12 | import * as puppeteer from 'puppeteer'; 13 | import WebExpect from './expect'; 14 | import PuppeteerScreenshotPlugin from './PuppeteerScreenshotPlugin'; 15 | import PuppeteerRecordVideoPlugin from './PuppeteerRecordVideoPlugin'; 16 | import LoginTestee from './LoginTesteeAction'; 17 | 18 | const TOOLBAR_SIZE = 124; // size of automated chrome + recording screen toolbars + url bar 19 | const NETWORKIDLE = 'networkidle0'; 20 | 21 | // @ts-ignore 22 | function sleep(ms: number) { 23 | return new Promise((res) => { 24 | setTimeout(res, ms); 25 | }); 26 | } 27 | 28 | let rando = Math.random(); 29 | function debug(label: string, ...args: any[]) { 30 | log.debug(`${rando} PuppeteerDriver.${label}`, ...args); 31 | } 32 | function debugTestee(label: string, ...args: any[]) { 33 | log.debug(`${rando} PuppeteerTestee.${label}`, ...args); 34 | } 35 | 36 | let enableSynchronization = true; 37 | let browser: puppeteer.Browser | null; 38 | let page: puppeteer.Page | null; 39 | let urlBlacklist: string[] = []; 40 | let isRecording = false; 41 | let disableTouchIndicators = false; 42 | 43 | // https://gist.github.com/aslushnikov/94108a4094532c7752135c42e12a00eb 44 | async function setupTouchIndicators() { 45 | await page?.evaluate(() => { 46 | if ((window as any).__detox_puppeteer_mouse_pointer) return; 47 | (window as any).__detox_puppeteer_mouse_pointer = true; 48 | document.body.classList.add('detox-puppeteer'); 49 | const box = document.createElement('puppeteer-mouse-pointer'); 50 | const styleElement = document.createElement('style'); 51 | styleElement.innerHTML = ` 52 | puppeteer-mouse-pointer { 53 | pointer-events: none; 54 | position: absolute; 55 | top: 0; 56 | z-index: 10000; 57 | left: 0; 58 | width: 20px; 59 | height: 20px; 60 | background: rgba(0,0,0,.4); 61 | border: 1px solid white; 62 | border-radius: 10px; 63 | margin: -10px 0 0 -10px; 64 | padding: 0; 65 | transition: background .2s, border-radius .2s, border-color .2s; 66 | } 67 | puppeteer-mouse-pointer.button-1 { 68 | transition: none; 69 | background: rgba(0,0,0,0.9); 70 | } 71 | puppeteer-mouse-pointer.button-2 { 72 | transition: none; 73 | border-color: rgba(0,0,255,0.9); 74 | } 75 | puppeteer-mouse-pointer.button-3 { 76 | transition: none; 77 | border-radius: 4px; 78 | } 79 | puppeteer-mouse-pointer.button-4 { 80 | transition: none; 81 | border-color: rgba(255,0,0,0.9); 82 | } 83 | puppeteer-mouse-pointer.button-5 { 84 | transition: none; 85 | border-color: rgba(0,255,0,0.9); 86 | } 87 | `; 88 | document.head.appendChild(styleElement); 89 | document.body.appendChild(box); 90 | document.addEventListener( 91 | 'mousemove', 92 | (event) => { 93 | box.style.left = event.pageX + 'px'; 94 | box.style.top = event.pageY + 'px'; 95 | updateButtons(event.buttons); 96 | }, 97 | true, 98 | ); 99 | document.addEventListener( 100 | 'mousedown', 101 | (event) => { 102 | updateButtons(event.buttons); 103 | box.classList.add('button-' + event.which); 104 | }, 105 | true, 106 | ); 107 | document.addEventListener( 108 | 'mouseup', 109 | (event) => { 110 | updateButtons(event.buttons); 111 | box.classList.remove('button-' + event.which); 112 | }, 113 | true, 114 | ); 115 | function updateButtons(buttons) { 116 | for (let i = 0; i < 5; i++) box.classList.toggle('button-' + i, !!(buttons & (1 << i))); 117 | } 118 | }); 119 | } 120 | 121 | class PuppeteerTestee { 122 | client: typeof Client; 123 | inflightRequests: { [key: string]: boolean }; 124 | inflightRequestsSettledCallback: (() => void) | null; 125 | sessionId: string; 126 | 127 | constructor(deps) { 128 | this.sessionId = deps.sessionConfig.sessionId; 129 | this.client = new Client({ sessionId: this.sessionId, server: deps.sessionConfig.server }); 130 | this.inflightRequests = {}; 131 | this.inflightRequestsSettledCallback = null; 132 | this.onRequest = this.onRequest.bind(this); 133 | this.removeInflightRequest = this.removeInflightRequest.bind(this); 134 | this.clearInflightRequests = this.clearInflightRequests.bind(this); 135 | } 136 | 137 | async selectElementWithMatcher(...args: any[]) { 138 | debugTestee('selectElementWithMatcher', JSON.stringify(args, null, 2)); 139 | const selectorArg = args.find((a) => a.method === 'selector'); 140 | const timeoutArg = args.find( 141 | (a) => a.method === 'option' && typeof a.args[0].timeout === 'number', 142 | ); 143 | const visibleArg = args.find( 144 | (a) => a.method === 'option' && typeof a.args[0].visible === 'boolean', 145 | ); 146 | const existArg = args.find( 147 | (a) => a.method === 'option' && typeof a.args[0].exists === 'boolean', 148 | ); 149 | const indexArg = args.find((a) => a.method === 'index'); 150 | let result: puppeteer.JSHandle | null = null; 151 | 152 | let bodyHTML = await page?.evaluate(() => document.body.innerHTML); 153 | let availableTestIds = await page?.evaluate(() => 154 | Array.prototype.slice 155 | .call(document.querySelectorAll('[data-testid]')) 156 | .map((n) => n.attributes['data-testid'].nodeValue), 157 | ); 158 | 159 | try { 160 | // This is a dummy waitFor because sometimes the JS thread is (apparently) 161 | // blocked and doesn't execute our element finding function a single time 162 | // before being able to run again. 163 | await page!.waitForFunction(() => { 164 | return true; 165 | }); 166 | // @ts-ignore 167 | result = await page!.waitForFunction( 168 | ({ selectorArg, indexArg, visibleArg }) => { 169 | const xpath = selectorArg.args[0]; 170 | const isContainMatcher = xpath.includes('contains('); 171 | // return document.querySelector(selectorArg ? selectorArg.args.join('') : 'body'); 172 | // let candidates = Array.prototype.slice.apply(document.querySelectorAll(selectorArg ? selectorArg.args.join('') : 'body'), [0]); 173 | const iterator = document.evaluate(`//*${xpath}`, document.body); 174 | const elements = []; 175 | // @ts-ignore 176 | let maybeElement, lastMatch; 177 | while ((maybeElement = iterator.iterateNext())) { 178 | lastMatch = maybeElement; 179 | // xpaths matching on text match every parent in addition to the 180 | // element we actually care about so only take the element if it 181 | // is a leaf 182 | if (!isContainMatcher || maybeElement.children.length === 0) { 183 | // @ts-ignore 184 | elements.push(maybeElement); 185 | } 186 | } 187 | // Sometimes we use contains in a compound matcher and skip a valid result 188 | // To recover, we take the last match if we did have a match but nothing was added to elements 189 | // @ts-ignore 190 | if (isContainMatcher && elements.length === 0 && lastMatch) elements.push(lastMatch); 191 | 192 | // https://github.com/puppeteer/puppeteer/blob/49f25e2412fbe3ac43ebc6913a582718066486cc/experimental/puppeteer-firefox/lib/JSHandle.js#L190-L204 193 | function isIntersectingViewport(el) { 194 | return new Promise((resolve) => { 195 | const observer = new IntersectionObserver((entries) => { 196 | resolve(entries[0].intersectionRatio); 197 | observer.disconnect(); 198 | }); 199 | observer.observe(el); 200 | // Firefox doesn't call IntersectionObserver callback unless 201 | // there are rafs. 202 | requestAnimationFrame(() => {}); 203 | // @ts-ignore 204 | }).then((visibleRatio) => visibleRatio > 0); 205 | } 206 | 207 | // do a reverse search to match iOS indexes 208 | const element = elements[indexArg ? elements.length - 1 - indexArg.args[0] : 0]; 209 | if (visibleArg) { 210 | if (visibleArg.args[0].visible === false) { 211 | if (element) { 212 | return isIntersectingViewport(element).then((isVisible) => !isVisible); 213 | } else { 214 | return true; 215 | } 216 | } else if (visibleArg.args[0].visible === true) { 217 | if (element) { 218 | return isIntersectingViewport(element).then((isVisible) => 219 | isVisible ? element : false, 220 | ); 221 | } 222 | } 223 | } 224 | 225 | return element; 226 | }, 227 | { 228 | timeout: timeoutArg 229 | ? timeoutArg.args[0].timeout 230 | : /* sometimes puppeteer unable to evaluate in less than ~800ms so we give some extra cushion */ 1500, 231 | }, 232 | { visibleArg, selectorArg, indexArg }, 233 | ); 234 | 235 | if (!result) { 236 | debugTestee('selectElementWithMatcher no result (pre-check)', { 237 | bodyHTML: bodyHTML, 238 | availableTestIds: availableTestIds, 239 | }); 240 | debugTestee( 241 | 'selectElementWithMatcher no result (post-check)', 242 | await page?.evaluate(() => document.body.innerHTML), 243 | { 244 | availableTestIds: await page?.evaluate(() => 245 | Array.prototype.slice 246 | .call(document.querySelectorAll('[data-testid]')) 247 | .map((n) => n.attributes['data-testid'].nodeValue), 248 | ), 249 | }, 250 | ); 251 | } 252 | } catch (e) { 253 | if (visibleArg) { 254 | const shouldBeVisible = visibleArg.args[0].visible === true; 255 | if (shouldBeVisible) throw new Error(e.toString() + selectorArg.args[0]); 256 | } 257 | if (existArg) { 258 | const shouldNotExist = existArg.args[0].exists === false; 259 | if (shouldNotExist) return true; 260 | } 261 | // console.warn(e); 262 | } 263 | 264 | return result; 265 | } 266 | 267 | async performAction(element: puppeteer.ElementHandle | undefined, action: any) { 268 | debugTestee('performAction', action); 269 | 270 | if (!element) { 271 | debugTestee('performAction DOM', await page?.evaluate(() => document.body.innerHTML), { 272 | availableTestIds: await page?.evaluate(() => 273 | Array.prototype.slice 274 | .call(document.querySelectorAll('[data-testid]')) 275 | .map((n) => n.attributes['data-testid'].nodeValue), 276 | ), 277 | }); 278 | throw new Error('performing action on undefined element'); 279 | } 280 | 281 | async function clickIfUnfocused() { 282 | const isFocused = await element?.evaluate((el) => document.activeElement === el); 283 | if (!isFocused) { 284 | await element?.click(); 285 | await element?.evaluate((node) => { 286 | const contentLength = node.innerHTML.length; 287 | if (node.attributes.getNamedItem('type')?.value !== 'email') { 288 | // @ts-expect-error 289 | node.setSelectionRange(contentLength, contentLength); 290 | } 291 | }); 292 | } 293 | } 294 | 295 | if (action.method === 'replaceText') { 296 | await clickIfUnfocused(); 297 | await element.evaluate((el) => ((el as any).value = '')); 298 | await page!.keyboard.type(action.args[0]); 299 | return true; 300 | } else if (action.method === 'typeText') { 301 | await clickIfUnfocused(); 302 | await page!.keyboard.type(action.args[0]); 303 | return true; 304 | } else if (action.method === 'keyboardPress') { 305 | await clickIfUnfocused(); 306 | await page!.keyboard.press(action.args[0]); 307 | return true; 308 | } else if (action.method === 'clearText') { 309 | const elementValue = await element.evaluate((el) => (el as any).value); 310 | await clickIfUnfocused(); 311 | for (let i = 0; i < elementValue.length; i++) { 312 | await page!.keyboard.press('Backspace'); 313 | } 314 | return true; 315 | } else if (action.method === 'getAttributes') { 316 | // https://wix.github.io/Detox/docs/api/actions/#getattributes 317 | return { 318 | text: await (await element.getProperty('textContent')).jsonValue(), 319 | label: await (await element.getProperty('ariaLabel')).jsonValue(), 320 | placeholder: await (await element.getProperty('ariaPlaceholder')).jsonValue(), 321 | enabled: !(await (await element.getProperty('ariaDisabled')).jsonValue()), 322 | identifier: await element.evaluate((e) => e.getAttribute('data-testid')), 323 | visible: await element.isIntersectingViewport(), 324 | value: await (await element.getProperty('nodeValue')).jsonValue(), 325 | frame: await element.evaluate((e) => e.getBoundingClientRect().toJSON()), 326 | }; 327 | } else if (action.method === 'tap') { 328 | await element.tap(); 329 | return true; 330 | } else if (action.method === 'tapAtPoint') { 331 | const box = (await element.boundingBox())!; 332 | const x = box.x + action.args[0].x; 333 | const y = box.y + action.args[0].y; 334 | await page!.touchscreen.tap(x, y); 335 | return true; 336 | } else if (action.method === 'longPress') { 337 | await element.evaluate( 338 | (el, { duration }) => { 339 | return new Promise((resolve) => { 340 | const boundingBox = el.getBoundingClientRect(); 341 | const pageX = boundingBox.x + boundingBox.width / 2; 342 | const pageY = boundingBox.y + boundingBox.height / 2; 343 | const touch = new Touch({ 344 | identifier: Date.now(), 345 | target: document, 346 | pageX, 347 | pageY, 348 | }); 349 | const start = new TouchEvent('touchstart', { 350 | cancelable: true, 351 | bubbles: true, 352 | touches: [touch], 353 | targetTouches: [], 354 | changedTouches: [touch], 355 | }); 356 | const end = new TouchEvent('touchend', { 357 | cancelable: true, 358 | bubbles: true, 359 | touches: [touch], 360 | targetTouches: [], 361 | changedTouches: [touch], 362 | }); 363 | 364 | el.dispatchEvent(start); 365 | 366 | setTimeout(() => { 367 | el.dispatchEvent(end); 368 | // @ts-ignore 369 | resolve(); 370 | }, duration); 371 | }); 372 | }, 373 | { duration: action.args[0] }, 374 | ); 375 | return true; 376 | } else if (action.method === 'multiTap') { 377 | for (let i = 0; i < action.args[0]; i++) { 378 | await element.tap(); 379 | } 380 | return true; 381 | } else if (action.method === 'scroll') { 382 | const direction = action.args[0]; 383 | const pixels = action.args[1]; 384 | 385 | // TODO handle all options 386 | let top = 0; 387 | let left = 0; 388 | if (direction === 'down') { 389 | top = pixels; 390 | } else if (direction === 'up') { 391 | top = -pixels; 392 | } else if (direction === 'right') { 393 | left = pixels; 394 | } else if (direction === 'left') { 395 | left = -pixels; 396 | } 397 | 398 | await element.evaluate( 399 | (el, scrollOptions) => { 400 | el.scrollBy(scrollOptions); 401 | }, 402 | { top, left }, 403 | ); 404 | return true; 405 | } else if (action.method === 'scrollTo') { 406 | const edge = action.args[0]; 407 | 408 | let top = 0; 409 | let left = 0; 410 | if (edge === 'bottom') { 411 | top = 10000; 412 | } else if (edge === 'top') { 413 | top = -10000; 414 | } else if (edge === 'left') { 415 | left = -10000; 416 | } else if (edge === 'right') { 417 | left = 10000; 418 | } 419 | 420 | await element.evaluate( 421 | (el, scrollOptions) => { 422 | el.scrollBy(scrollOptions); 423 | }, 424 | { top, left }, 425 | ); 426 | return true; 427 | } else if (action.method === 'swipe') { 428 | const direction = action.args[0]; 429 | // const speed = action.args[1]; 430 | const percentageOfScreenToSwipe = action.args[2] ?? 0.5; 431 | const normalizedStartingPointX = action.args[3] ?? 0.5; 432 | const normalizedStartingPointY = action.args[4] ?? 0.5; 433 | 434 | const { width, height } = page!.viewport()!; 435 | let top = 0; 436 | let left = 0; 437 | 438 | if (direction === 'up') { 439 | top = -height * percentageOfScreenToSwipe; 440 | } else if (direction === 'down') { 441 | top = height * percentageOfScreenToSwipe; 442 | } else if (direction === 'left') { 443 | left = -width * percentageOfScreenToSwipe; 444 | } else if (direction === 'right') { 445 | left = width * percentageOfScreenToSwipe; 446 | } 447 | 448 | const scrollable = await element.evaluate((el) => { 449 | return el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth; 450 | }); 451 | if (scrollable) { 452 | await element.evaluate( 453 | (el, scrollOptions) => { 454 | el.scrollBy(scrollOptions); 455 | }, 456 | // we want to scroll in the opposite direction of the swipe. If we swipe down, we expect 457 | // the scroll down, decreasing scrollTop 458 | { top: top * -1, left: left * -1 }, 459 | ); 460 | } else { 461 | let result = (await element.boundingBox())!; 462 | await element.hover(); 463 | await page!.mouse.down(); 464 | await page!.mouse.move( 465 | result.x + result.width * normalizedStartingPointX + left, 466 | result.y + result.height * normalizedStartingPointY + top, 467 | { steps: 100 }, 468 | ); 469 | await page!.mouse.up(); 470 | } 471 | return true; 472 | } 473 | 474 | throw new Error('action not performed: ' + JSON.stringify(action)); 475 | } 476 | 477 | async assertWithMatcher(element, matcher) { 478 | debugTestee('assertWithMatcher', matcher); 479 | const isExists = !!element; 480 | const isVisibleMatcher = matcher.method === 'option' && matcher.args[0].visible === true; 481 | const isNotVisibleMatcher = matcher.method === 'option' && matcher.args[0].visible === false; 482 | const isExistsMatcher = matcher.method === 'option' && matcher.args[0].exists === true; 483 | const isNotExistsMatcher = matcher.method === 'option' && matcher.args[0].exists === false; 484 | debugTestee('assertWithMatcher', { 485 | isExists, 486 | isVisibleMatcher, 487 | isNotVisibleMatcher, 488 | isExistsMatcher, 489 | isNotExistsMatcher, 490 | }); 491 | 492 | let result = true; 493 | if (isVisibleMatcher || isNotVisibleMatcher) { 494 | const isVisible = isExists ? await element.isIntersectingViewport() : false; 495 | if (isVisibleMatcher && !isVisible) { 496 | result = false; 497 | } 498 | if (isNotVisibleMatcher && isVisible) { 499 | result = false; 500 | } 501 | } 502 | if (isExistsMatcher || isNotExistsMatcher) { 503 | if (isExistsMatcher && !isExists) { 504 | result = false; 505 | } 506 | if (isNotExistsMatcher && isExists) { 507 | result = false; 508 | } 509 | } 510 | 511 | if (matcher.method === 'selector') { 512 | result = await element.evaluate((el, selector) => { 513 | const iterator = document.evaluate(`//*${selector}`, el); 514 | return !!iterator.iterateNext(); 515 | }, matcher.args[0]); 516 | } 517 | 518 | debugTestee('/assertWithMatcher', { result }); 519 | 520 | if (!result) throw new Error('assertion failed'); 521 | return result; 522 | } 523 | 524 | async selectElementWhileScrolling( 525 | search, 526 | action: { 527 | target: { 528 | type: 'action'; 529 | value: 'action'; 530 | }; 531 | method: 'scroll'; 532 | args: [string, number]; 533 | }, 534 | actionMatcher, 535 | ) { 536 | const searchWithTimeout = { 537 | ...search, 538 | args: [ 539 | ...search.args, 540 | { 541 | target: { 542 | type: 'matcher', 543 | value: 'matcher', 544 | }, 545 | method: 'option', 546 | args: [ 547 | { 548 | timeout: 10, 549 | }, 550 | ], 551 | }, 552 | ], 553 | }; 554 | const actionElement = await this.selectElementWithMatcher(actionMatcher); 555 | const numSteps = 20; 556 | const deltaScroll = { ...action, args: [action.args[0], action.args[1] / numSteps] }; 557 | 558 | let result; 559 | for (let step = 0; step < numSteps; step = step + 1) { 560 | try { 561 | result = await this.invoke(searchWithTimeout); 562 | break; 563 | } catch (e) { 564 | result = e; 565 | await this.performAction(actionElement! as puppeteer.ElementHandle, deltaScroll); 566 | } 567 | } 568 | 569 | if (result instanceof Error) throw result; 570 | return result; 571 | } 572 | 573 | async invoke(params) { 574 | debugTestee('invoke', JSON.stringify(params, null, 2)); 575 | const promises = params.args.map((arg) => { 576 | // debugTestee('arg', arg); 577 | if (arg.type === 'Invocation') { 578 | return this.invoke(arg.value); 579 | } 580 | return arg; 581 | }); 582 | 583 | const args = await Promise.all(promises); 584 | if (params.target === 'this' || params.target.type === 'this') { 585 | const result = await this[params.method](...args); 586 | // a small delay between each invocation allows for more stable tests 587 | await sleep(30); 588 | debugTestee('result?', params.method, !!result); 589 | return result; 590 | } 591 | 592 | return params; 593 | } 594 | 595 | setupNetworkSynchronization() { 596 | // teardown before adding listeners to ensure we don't double subscribe to events 597 | browser!.off('disconnected', this.clearInflightRequests); 598 | page!.off('close', this.clearInflightRequests); 599 | page!.off('request', this.onRequest); 600 | page!.off('requestfinished', this.removeInflightRequest); 601 | page!.off('requestfailed', this.removeInflightRequest); 602 | 603 | browser!.on('disconnected', this.clearInflightRequests); 604 | page!.on('close', this.clearInflightRequests); 605 | page!.on('request', this.onRequest); 606 | page!.on('requestfinished', this.removeInflightRequest); 607 | page!.on('requestfailed', this.removeInflightRequest); 608 | } 609 | 610 | async clearInflightRequests() { 611 | debugTestee('clearInflightRequests', this.inflightRequests); 612 | Object.keys(this.inflightRequests).forEach((key) => { 613 | this.removeInflightRequest({ uid: key }); 614 | }); 615 | } 616 | 617 | async synchronizeNetwork() { 618 | return new Promise((resolve) => { 619 | debugTestee('inflightRequests', this.inflightRequests); 620 | if (Object.keys(this.inflightRequests).length === 0) { 621 | resolve(); 622 | return; 623 | } 624 | // We use debounce because some new requests may fire immediately after 625 | // the last one outstanding resolves. We prefer to let the requests settle 626 | // before considering the network "synchronized" 627 | this.inflightRequestsSettledCallback = _.debounce(() => { 628 | this.inflightRequestsSettledCallback = null; 629 | this.synchronizeNetwork().then(resolve); 630 | }, 200); 631 | }); 632 | } 633 | 634 | removeInflightRequest(request) { 635 | request.__completed = true; 636 | debugTestee('offRequest', request.uid); 637 | delete this.inflightRequests[request.uid]; 638 | if (Object.keys(this.inflightRequests).length === 0) { 639 | if (this.inflightRequestsSettledCallback) this.inflightRequestsSettledCallback(); 640 | } 641 | } 642 | 643 | onRequest(request) { 644 | if (request.__completed) { 645 | debugTestee('request completed before onRequest invoked', request.url()); 646 | return; 647 | } 648 | request.uid = Math.random(); 649 | const url = request.url(); 650 | const isIgnored = 651 | // data urls dont get a requestfinished callback 652 | url.startsWith('data:') || 653 | urlBlacklist.some((candidate) => { 654 | return url.match(new RegExp(candidate)); 655 | }); 656 | if (!isIgnored) { 657 | debugTestee('onRequest', request.uid, url, request.postData()); 658 | this.inflightRequests[request.uid] = true; 659 | } 660 | } 661 | 662 | async connect() { 663 | const client = await page!.target().createCDPSession(); 664 | await client.send('Animation.enable'); 665 | 666 | /* animation synchronization */ 667 | let animationTimeById: { [key: string]: number } = {}; 668 | client.on('Animation.animationStarted', ({ animation }) => { 669 | // console.log('Animation started id=', animation.id) 670 | // console.log(animation) 671 | animationTimeById[animation.id] = animation.source!.duration; 672 | }); 673 | client.on('Animation.animationCancelled', (event) => { 674 | const { id } = event as { id: string }; 675 | // console.log('animationCancelled', id); 676 | delete animationTimeById[id]; 677 | }); 678 | /* end animation synchronization */ 679 | 680 | if (!this.client._asyncWebSocket.isOpen) { 681 | await this.client.open(); 682 | 683 | const onMessage = async (action) => { 684 | try { 685 | if (!disableTouchIndicators) { 686 | await setupTouchIndicators(); 687 | } 688 | // https://github.com/wix/Detox/blob/ca620e760747ade9cb673c28262200b02e8e8a5d/docs/Troubleshooting.Synchronization.md#settimeout-and-setinterval 689 | // async function setupDetoxTimeouts() { 690 | // await page.evaluate(() => { 691 | // if (!window._detoxOriginalSetTimeout) 692 | // window._detoxOriginalSetTimeout = window.setTimeout; 693 | // if (!window._detoxOriginalClearTimeout) 694 | // window._detoxOriginalClearTimeout = window.clearTimeout; 695 | // if (!window._detoxTimeouts) window._detoxTimeouts = {}; 696 | // window.setTimeout = (callback, ms) => { 697 | // const stack = new Error().stack; 698 | // const isPuppeteerTimeout = stack.includes( 699 | // "waitForPredicatePageFunction" 700 | // ); 701 | // if (isPuppeteerTimeout) { 702 | // window._detoxOriginalSetTimeout(callback, ms); 703 | // return; 704 | // } 705 | 706 | // const timeout = window._detoxOriginalSetTimeout(() => { 707 | // delete window._detoxTimeouts[timeout]; 708 | // callback(); 709 | // }, ms); 710 | // window._detoxTimeouts[timeout] = true; 711 | // }; 712 | // window.clearTimeout = timeout => { 713 | // delete window._detoxTimeouts[timeout]; 714 | // window._detoxOriginalClearTimeout(timeout); 715 | // }; 716 | // }); 717 | // } 718 | 719 | try { 720 | // TODO figure out why we need a try catch here. Sometimes it errors as "Target closed" 721 | // Also firebase uses a setTimeout on repeat which doesn't seem compatible with timeout logic 722 | // https://github.com/firebase/firebase-js-sdk/blob/6b53e0058483c9002d2fe56119f86fc9fb96b56c/packages/auth/src/storage/indexeddb.js#L644 723 | // setupDetoxTimeouts(); 724 | } catch (e) { 725 | // console.warn(e); 726 | } 727 | 728 | // Always re-setup in case we created a new page object since 729 | // the last action 730 | this.setupNetworkSynchronization(); 731 | 732 | const sendResponse = async ( 733 | response, 734 | options: { skipSynchronization?: boolean } = {}, 735 | ) => { 736 | debugTestee('sendResponse', response); 737 | const performSynchronization = enableSynchronization && !options.skipSynchronization; 738 | const sendResponsePromise = performSynchronization 739 | ? this.synchronizeNetwork() 740 | : Promise.resolve(); 741 | 742 | const animationsSettledPromise = performSynchronization 743 | ? new Promise((resolve) => { 744 | const interval = setInterval(() => { 745 | Object.entries(animationTimeById).forEach(async ([id, duration]) => { 746 | let result: { currentTime: number | null } = { 747 | currentTime: null, 748 | }; 749 | try { 750 | result = (await client.send('Animation.getCurrentTime', { 751 | id: id, 752 | })) as any; 753 | // if this call errors out, just assume the animation is done 754 | } catch (e) {} 755 | if (result.currentTime === null || result.currentTime > duration) { 756 | delete animationTimeById[id]; 757 | } 758 | }); 759 | if (Object.keys(animationTimeById).length === 0) { 760 | clearInterval(interval); 761 | resolve(); 762 | } 763 | }, 100); 764 | }) 765 | : Promise.resolve(); 766 | 767 | return sendResponsePromise 768 | .then(() => animationsSettledPromise) 769 | .then(() => { 770 | if (!performSynchronization) return; 771 | return page!.waitForFunction(() => { 772 | // @ts-ignore 773 | return Object.keys(window._detoxTimeouts || {}).length === 0; 774 | }); 775 | }) 776 | .then(() => this.client.sendAction(response)); 777 | }; 778 | 779 | let messageId; 780 | try { 781 | messageId = action.messageId; 782 | debugTestee('PuppeteerTestee.message', JSON.stringify(action, null, 2)); 783 | if (!action.type) { 784 | return; 785 | } 786 | if (action.type === 'loginSuccess') { 787 | return; 788 | } else if (action.type === 'cleanup') { 789 | if (browser) { 790 | await browser.close(); 791 | browser = null; 792 | page = null; 793 | } 794 | await sendResponse( 795 | { 796 | type: 'cleanupDone', 797 | messageId: action.messageId, 798 | }, 799 | { skipSynchronization: true }, 800 | ); 801 | } else if (action.type === 'deliverPayload') { 802 | // Need to sychronize network here so that we dont have any network requests 803 | // lost in the page navigation 804 | if (enableSynchronization) { 805 | await this.synchronizeNetwork(); 806 | } 807 | if (action.params && action.params.url) { 808 | await page!.goto(action.params.url, { waitUntil: NETWORKIDLE }); 809 | // await setupDetoxTimeouts(); 810 | } 811 | await sendResponse({ 812 | type: 'deliverPayloadDone', 813 | messageId: action.messageId, 814 | }); 815 | } else if (action.type === 'currentStatus') { 816 | const status = `App is idle. 817 | 818 | Network requests (${Object.keys(this.inflightRequests).length}): ${Object.keys( 819 | this.inflightRequests, 820 | )} 821 | `.trim(); 822 | await sendResponse( 823 | { 824 | type: 'currentStatusResult', 825 | messageId: action.messageId, 826 | params: { status }, 827 | }, 828 | { skipSynchronization: true }, 829 | ); 830 | } else { 831 | try { 832 | if (enableSynchronization) { 833 | await this.synchronizeNetwork(); 834 | } 835 | const result = await this.invoke(action.params); 836 | if (result === false || result === null) throw new Error('invalid result'); 837 | await sendResponse({ 838 | type: 'invokeResult', 839 | params: result, 840 | messageId: action.messageId, 841 | }); 842 | } catch (error) { 843 | this.client.sendAction({ 844 | type: 'testFailed', 845 | messageId, 846 | params: { details: JSON.stringify(action) + '\n' + (error as Error).message }, 847 | }); 848 | } 849 | } 850 | } catch (error) { 851 | log.error(error); 852 | await sendResponse({ 853 | type: 'error', 854 | messageId: messageId, 855 | params: { error }, 856 | }); 857 | await browser!.close(); 858 | browser = null; 859 | page = null; 860 | } 861 | } catch (error) { 862 | console.error(error); 863 | } 864 | }; 865 | 866 | // list of possible actions can be found here: https://github.com/wix/Detox/blob/0beef1a7bfe0f4bf477fa5cdbb318b5c3a960aae/detox/ios/Detox/DetoxManager.swift#L233 867 | this.client.setEventCallback('invoke', onMessage); 868 | this.client.setEventCallback('cleanup', onMessage); 869 | this.client.setEventCallback('currentStatus', onMessage); 870 | this.client.setEventCallback('deliverPayload', onMessage); 871 | this.client.setEventCallback('testerDisconnected', () => {}); 872 | 873 | await this.client.sendAction(new LoginTestee(this.sessionId, 'app')); 874 | } 875 | } 876 | 877 | async disconnect() { 878 | this.client.cleanup(); 879 | } 880 | } 881 | 882 | class PuppeteerEnvironmentValidator { 883 | validate() { 884 | // const detoxFrameworkPath = await environment.getFrameworkPath(); 885 | // if (!fs.existsSync(detoxFrameworkPath)) { 886 | // throw new Error(`${detoxFrameworkPath} could not be found, this means either you changed a version of Xcode or Detox postinstall script was unsuccessful. 887 | // To attempt a fix try running 'detox clean-framework-cache && detox build-framework-cache'`); 888 | // } 889 | } 890 | } 891 | 892 | let recorder; 893 | let pendingRecordVideo = false; 894 | let _exportPath; 895 | async function startRecordVideo() { 896 | function getExportPath() { 897 | if (_exportPath) return _exportPath; 898 | const exportname = `puppet${Math.random()}.mp4`; 899 | _exportPath = path.join(os.homedir(), 'Downloads', exportname); 900 | return _exportPath; 901 | } 902 | 903 | const exportPath = getExportPath(); 904 | 905 | debug('startRecordVideo', { page: !!page, exportPath }); 906 | /* If page has not yet started, we cannot start recording so instead we set a flag telling 907 | * the driver to manually call startRecordVideo when the page is opened */ 908 | if (!page) { 909 | pendingRecordVideo = true; 910 | return exportPath; 911 | } 912 | pendingRecordVideo = false; 913 | recorder = new PuppeteerScreenRecorder(page, { fps: 60 }); 914 | recorder.start(exportPath); 915 | isRecording = true; 916 | return exportPath; 917 | } 918 | 919 | async function stopRecordVideo() { 920 | debug('stopVideo', { _exportPath }); 921 | await recorder?.stop(); 922 | recorder = undefined; 923 | } 924 | 925 | // TODO 926 | async function takeScreenshot() {} 927 | 928 | class PuppeteerArtifactPluginsProvider { 929 | declareArtifactPlugins(args) { 930 | debug('declareArtifactPlugins'); 931 | return { 932 | // instruments: (api) => new SimulatorInstrumentsPlugin({ api, client }), 933 | // log: (api) => new SimulatorLogPlugin({ api, appleSimUtils }), 934 | screenshot: (api) => new PuppeteerScreenshotPlugin({ api, driver: takeScreenshot }), 935 | video: (api) => 936 | new PuppeteerRecordVideoPlugin({ 937 | api, 938 | driver: { recordVideo: startRecordVideo, stopVideo: stopRecordVideo }, 939 | }), 940 | }; 941 | } 942 | } 943 | 944 | type PuppeteerAllocCookie = { 945 | id: string; 946 | }; 947 | 948 | class PuppeteerDeviceAllocation { 949 | private readonly emitter: any; 950 | 951 | constructor(deps) { 952 | this.emitter = deps.eventEmitter; 953 | } 954 | 955 | async allocate(deviceConfig): Promise { 956 | debug('PuppeteerAllocation.allocate', deviceConfig.device); 957 | return { id: Math.random().toString() }; 958 | } 959 | 960 | async free(deviceCookie: PuppeteerAllocCookie, { shutdown }) { 961 | const { id } = deviceCookie; 962 | 963 | if (shutdown) { 964 | await this.emitter.emit('beforeShutdownDevice', { deviceId: id }); 965 | await this.emitter.emit('shutdownDevice', { deviceId: id }); 966 | } 967 | } 968 | } 969 | 970 | class PuppeteerRuntimeDriver extends DeviceDriverBase { 971 | private readonly deviceId: any; 972 | private readonly app: PuppeteerTestee; 973 | 974 | constructor(deps: any, cookie: PuppeteerAllocCookie) { 975 | super(deps); 976 | debug('constructor'); 977 | 978 | this.app = new PuppeteerTestee(deps); 979 | this.deviceId = cookie.id; 980 | } 981 | 982 | getExternalId() { 983 | return this.deviceId; 984 | } 985 | 986 | getDeviceName() { 987 | return 'puppeteer'; 988 | } 989 | 990 | createPayloadFile(notification) { 991 | const notificationFilePath = path.join(this.createRandomDirectory(), `payload.json`); 992 | fs.writeFileSync(notificationFilePath, JSON.stringify(notification, null, 2)); 993 | return notificationFilePath; 994 | } 995 | 996 | async setURLBlacklist(urlList) { 997 | debug('TODO setURLBlacklist should go through client', urlList); 998 | urlBlacklist = urlList; 999 | } 1000 | 1001 | async enableSynchronization() { 1002 | debug('TODO enableSynchronization should go through client'); 1003 | enableSynchronization = true; 1004 | } 1005 | 1006 | async disableSynchronization() { 1007 | debug('TODO disableSynchronization should go through client'); 1008 | enableSynchronization = false; 1009 | } 1010 | 1011 | async shake() { 1012 | return await this.client.shake(); 1013 | } 1014 | 1015 | async setOrientation(orientation) { 1016 | const viewport = page!.viewport()!; 1017 | const isLandscape = orientation === 'landscape'; 1018 | const largerDimension = Math.max(viewport.width, viewport.height); 1019 | const smallerDimension = Math.min(viewport.width, viewport.height); 1020 | await page!.setViewport({ 1021 | ...viewport, 1022 | isLandscape, 1023 | width: isLandscape ? largerDimension : smallerDimension, 1024 | height: isLandscape ? smallerDimension : largerDimension, 1025 | }); 1026 | } 1027 | 1028 | getPlatform() { 1029 | return 'web'; 1030 | } 1031 | 1032 | async cleanup(bundleId) { 1033 | debug('cleanup', { bundleId, browser: !!browser }); 1034 | 1035 | if (browser) { 1036 | await browser.close(); 1037 | browser = null; 1038 | page = null; 1039 | } 1040 | 1041 | await this.app.disconnect(); 1042 | await super.cleanup(bundleId); 1043 | } 1044 | 1045 | async getBundleIdFromBinary(appPath) { 1046 | debug('PuppeteerDriver.getBundleIdFromBinary', appPath); 1047 | return appPath; 1048 | } 1049 | 1050 | async installApp(binaryPath) { 1051 | debug('installApp', { binaryPath }); 1052 | } 1053 | 1054 | async uninstallApp(bundleId) { 1055 | debug('uninstallApp', { bundleId }); 1056 | await this.emitter.emit('beforeUninstallApp', { deviceId: this.deviceId, bundleId }); 1057 | if (browser) { 1058 | await browser.close(); 1059 | browser = null; 1060 | page = null; 1061 | } 1062 | } 1063 | 1064 | async launchApp(bundleId, launchArgs, languageAndLocale) { 1065 | debug('launchApp', { 1066 | browser: !!browser, 1067 | bundleId, 1068 | launchArgs, 1069 | languageAndLocale, 1070 | config: this.deviceConfig, 1071 | }); 1072 | const { deviceId } = this; 1073 | 1074 | await this.emitter.emit('beforeLaunchApp', { 1075 | bundleId, 1076 | deviceId, 1077 | launchArgs, 1078 | }); 1079 | 1080 | if (launchArgs.detoxURLBlacklistRegex) { 1081 | const blacklistRegex = launchArgs.detoxURLBlacklistRegex; 1082 | await this.setURLBlacklist( 1083 | JSON.parse('[' + blacklistRegex.substr(2, blacklistRegex.length - 4) + ']'), 1084 | ); 1085 | } 1086 | 1087 | disableTouchIndicators = launchArgs.disableTouchIndicators; 1088 | const defaultViewport = launchArgs.viewport || this._getDefaultViewport(); 1089 | const headless = this._getDeviceOption('headless', process.env.CI ? true : false); 1090 | 1091 | browser = 1092 | browser || 1093 | (await puppeteer.launch({ 1094 | devtools: this._getDeviceOption('devtools', false), 1095 | headless, 1096 | defaultViewport, 1097 | // ignoreDefaultArgs: ['--enable-automation'], // works, but shows "not your default browser toolbar" 1098 | args: [ 1099 | '--no-sandbox', 1100 | // https://github.com/puppeteer/puppeteer/issues/8085 1101 | // This should be safe to disable since tests shouldn't be exposing any important information 1102 | // to potentially untrusted processes. Also, the developer controls all resources loaded 1103 | // on the page 1104 | '--disable-site-isolation-trials', 1105 | `--window-size=${defaultViewport.width},${defaultViewport.height + TOOLBAR_SIZE}`, 1106 | ], 1107 | })); 1108 | 1109 | if (bundleId && !this.binaryPath) { 1110 | this.binaryPath = bundleId; 1111 | } 1112 | const url = launchArgs.detoxURLOverride || this.binaryPath; 1113 | if (url) { 1114 | page = (await browser.pages())[0]; 1115 | await page!.goto(url, { waitUntil: NETWORKIDLE }); 1116 | if (pendingRecordVideo) { 1117 | await startRecordVideo(); 1118 | } 1119 | } 1120 | 1121 | await this._applyPermissions(); 1122 | 1123 | // const pid = await this.applesimutils.launch(deviceId, bundleId, launchArgs, languageAndLocale); 1124 | const pid = deviceId; 1125 | await this.emitter.emit('launchApp', { 1126 | bundleId, 1127 | deviceId, 1128 | launchArgs, 1129 | pid, 1130 | }); 1131 | 1132 | return pid; 1133 | } 1134 | 1135 | _getDeviceOption(key: string, defaultValue: T): T { 1136 | return this.deviceConfig.device?.[key] ?? this.deviceConfig?.[key] ?? defaultValue; 1137 | } 1138 | 1139 | _getDefaultViewport() { 1140 | return this._getDeviceOption('defaultViewport', { width: 1280, height: 720 }); 1141 | } 1142 | 1143 | async terminate(bundleId) { 1144 | debug('terminate', { bundleId }); 1145 | // If we're in the middle of recording, signal to the next launch that we should start 1146 | // in a recording state 1147 | if (isRecording) { 1148 | pendingRecordVideo = true; 1149 | } 1150 | await stopRecordVideo(); 1151 | await this.emitter.emit('beforeTerminateApp', { deviceId: this.deviceId, bundleId }); 1152 | if (browser) { 1153 | await browser.close(); 1154 | browser = null; 1155 | page = null; 1156 | } 1157 | // await this.applesimutils.terminate(deviceId, bundleId); 1158 | await this.emitter.emit('terminateApp', { deviceId: this.deviceId, bundleId }); 1159 | } 1160 | 1161 | async sendToHome() { 1162 | await page!.goto('https://google.com'); 1163 | } 1164 | 1165 | async setLocation(latitude, longitude) { 1166 | await page!.setGeolocation({ 1167 | latitude: Number.parseFloat(latitude), 1168 | longitude: Number.parseFloat(longitude), 1169 | }); 1170 | } 1171 | 1172 | async setPermissions(bundleId, permissions: { [key: string]: string }) { 1173 | debug('setPermissions', { bundleId, permissions }); 1174 | const PERMISSIONS_LOOKUP = { 1175 | // calendar: '', 1176 | camera: 'camera', 1177 | // contacts: '', 1178 | // faceid: '', 1179 | // health: '', 1180 | // homekit: '', 1181 | location: 'geolocation', 1182 | // medialibrary: '', 1183 | microphone: 'microphone', 1184 | // motion: '', 1185 | notifications: 'notifications', 1186 | // photos: '', 1187 | // reminders: '', 1188 | // siri: '', 1189 | // speech: '', 1190 | }; 1191 | this.requestedPermissions = []; 1192 | const requestedPermissions = Object.entries(permissions) 1193 | .filter(([key, value]) => { 1194 | return !['NO', 'unset', 'never', ''].includes(value || ''); 1195 | }) 1196 | .map(([key]) => PERMISSIONS_LOOKUP[key]) 1197 | .filter((equivalentPermission) => !!equivalentPermission); 1198 | this.requestedPermissions = requestedPermissions; 1199 | } 1200 | 1201 | async _applyPermissions() { 1202 | if (browser && this.requestedPermissions) { 1203 | const context = browser.defaultBrowserContext(); 1204 | await context.clearPermissionOverrides(); 1205 | const url = await page!.url(); 1206 | if (url) { 1207 | await context.overridePermissions(new URL(url).origin, this.requestedPermissions); 1208 | } 1209 | } 1210 | } 1211 | 1212 | async clearKeychain() {} 1213 | 1214 | async resetContentAndSettings() { 1215 | debug('TODO resetContentAndSettings'); 1216 | } 1217 | 1218 | validateDeviceConfig(deviceConfig) { 1219 | debug('validateDeviceConfig', deviceConfig); 1220 | this.deviceConfig = deviceConfig; 1221 | if (this.deviceConfig.binaryPath) { 1222 | this.binaryPath = this.deviceConfig.binaryPath; 1223 | } 1224 | } 1225 | 1226 | getLogsPaths() {} 1227 | 1228 | async waitForBackground() { 1229 | debug('TODO waitForBackground'); 1230 | // return await this.client.waitForBackground(); 1231 | return Promise.resolve(''); 1232 | } 1233 | 1234 | async takeScreenshot(screenshotName) { 1235 | const tempPath = await temporaryPath.for.png(); 1236 | await page!.screenshot({ path: tempPath }); 1237 | 1238 | await this.emitter.emit('createExternalArtifact', { 1239 | pluginId: 'screenshot', 1240 | artifactName: screenshotName, 1241 | artifactPath: tempPath, 1242 | }); 1243 | 1244 | return tempPath; 1245 | } 1246 | 1247 | async setStatusBar(flags) {} 1248 | 1249 | async resetStatusBar() {} 1250 | 1251 | async waitUntilReady() { 1252 | await this.app.connect(); 1253 | } 1254 | 1255 | async reloadReactNative() { 1256 | const url = this.binaryPath; 1257 | if (url) { 1258 | page = (await browser!.pages())[0]; 1259 | await page!.goto(url, { waitUntil: NETWORKIDLE }); 1260 | } 1261 | } 1262 | 1263 | getBrowser() { 1264 | return browser; 1265 | } 1266 | } 1267 | 1268 | export = { 1269 | EnvironmentValidatorClass: PuppeteerEnvironmentValidator, 1270 | ArtifactPluginsProviderClass: PuppeteerArtifactPluginsProvider, 1271 | DeviceAllocationDriverClass: PuppeteerDeviceAllocation, 1272 | RuntimeDriverClass: PuppeteerRuntimeDriver, 1273 | ExpectClass: WebExpect, 1274 | }; 1275 | --------------------------------------------------------------------------------