├── .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 |
--------------------------------------------------------------------------------