├── test
├── mocha.opts
├── webpages
│ ├── 2.html
│ ├── 4.html
│ ├── 3.html
│ └── 1.html
└── macaca-playwright.test.js
├── .eslintignore
├── playground
├── package.json
├── README.md
└── get-cookie.js
├── .gitignore
├── lib
├── logger.js
├── helper.js
├── redirect-console.js
├── next-actions.js
├── macaca-playwright.ts
└── controllers.js
├── tsconfig.json
├── .github
└── workflows
│ └── ci.yml
├── .eslintrc.js
├── package.json
└── README.md
/test/mocha.opts:
--------------------------------------------------------------------------------
1 | --reporter spec
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | coverage/
3 | docs/
--------------------------------------------------------------------------------
/playground/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "playground",
3 | "private": true
4 | }
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | coverage
3 | videos
4 | .nyc_output
5 | *.sw*
6 | *.un~
7 | .idea
8 | dist
--------------------------------------------------------------------------------
/lib/logger.js:
--------------------------------------------------------------------------------
1 |
2 |
3 | const logger = require('xlogger');
4 |
5 | module.exports = logger.Logger({
6 | closeFile: true,
7 | });
8 |
--------------------------------------------------------------------------------
/playground/README.md:
--------------------------------------------------------------------------------
1 | # Macaca Playwright Playground
2 |
3 | ---
4 |
5 | ## Get Cookies
6 |
7 | ```bash
8 | $ node ./get-cookie.js
9 | ```
10 |
--------------------------------------------------------------------------------
/test/webpages/2.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Document 2
6 |
7 |
8 | page 2
9 |
10 |
11 |
--------------------------------------------------------------------------------
/test/webpages/4.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Document 5
6 |
7 |
8 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/test/webpages/3.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Document 3
6 |
7 |
8 | page 3
9 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/playground/get-cookie.js:
--------------------------------------------------------------------------------
1 | const Playwright = require('../dist/lib/macaca-playwright');
2 |
3 | async function main() {
4 | const driver = new Playwright();
5 | await driver.startDevice({
6 | headless: false,
7 | });
8 | await driver.get('https://www.baidu.com');
9 | const cookies = await driver.getAllCookies();
10 | const res = cookies.find(item => item.name === 'BAIDUID');
11 | await driver.stopDevice();
12 | return res?.value;
13 | }
14 |
15 | main()
16 | .then(res => {
17 | console.log(res);
18 | })
19 | .catch(e => {
20 | console.log(e);
21 | });
22 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2018",
4 | "module": "commonjs",
5 | "moduleResolution": "node",
6 | "experimentalDecorators": true,
7 | "emitDecoratorMetadata": true,
8 | "resolveJsonModule": true,
9 | "inlineSourceMap":true,
10 | "noImplicitThis": true,
11 | "esModuleInterop": true,
12 | "noUnusedLocals": true,
13 | "stripInternal": true,
14 | "pretty": true,
15 | "allowJs": true,
16 | "declaration": true,
17 | "removeComments": false,
18 | "types": [ "node" ],
19 | "outDir": "./dist"
20 | },
21 | "include": [
22 | "lib"
23 | ]
24 | }
25 |
--------------------------------------------------------------------------------
/test/webpages/1.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Document 1
6 |
14 |
15 |
16 |
17 |
18 |
19 | new page
20 |
21 | open new page
22 |
23 |
24 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | workflow_dispatch:
5 |
6 | push:
7 | branches:
8 | - "**"
9 |
10 | jobs:
11 | test:
12 | name: 'Run test: playwright docker'
13 | timeout-minutes: 60
14 | runs-on: ubuntu-latest
15 | container: mcr.microsoft.com/playwright:focal
16 | env:
17 | HEADLESS: true
18 | steps:
19 | - name: Checkout
20 | uses: actions/checkout@v3
21 | with:
22 | fetch-depth: 0
23 |
24 | - name: Set node version to 16
25 | uses: actions/setup-node@v3
26 | with:
27 | node-version: "16"
28 |
29 | - name: Install deps
30 | run: |
31 | npm i npm@6 -g
32 | npm i
33 |
34 | - name: Run lint
35 | run: npm run lint
36 |
37 | - name: Run test
38 | run: npm run test
39 |
40 | - name: Codecov
41 | uses: codecov/codecov-action@v3.0.0
42 | with:
43 | token: ${{ secrets.CODECOV_TOKEN }}
44 |
--------------------------------------------------------------------------------
/lib/helper.js:
--------------------------------------------------------------------------------
1 |
2 |
3 | const _ = require('lodash');
4 |
5 | _.sleep = function(ms) {
6 | return new Promise((resolve) => {
7 | setTimeout(resolve, ms);
8 | });
9 | };
10 |
11 | _.waitForCondition = function(func, wait/* ms*/, interval/* ms*/) {
12 | wait = wait || 5000;
13 | interval = interval || 500;
14 | const start = Date.now();
15 | const end = start + wait;
16 |
17 | const fn = function() {
18 | return new Promise((resolve, reject) => {
19 | const continuation = (res, rej) => {
20 | const now = Date.now();
21 |
22 | if (now < end) {
23 | res(_.sleep(interval).then(fn));
24 | } else {
25 | rej(`Wait For Condition timeout ${wait}`);
26 | }
27 | };
28 | func().then(isOk => {
29 |
30 | if (isOk) {
31 | resolve();
32 | } else {
33 | continuation(resolve, reject);
34 | }
35 | }).catch(() => {
36 | continuation(resolve, reject);
37 | });
38 | });
39 | };
40 | return fn();
41 | };
42 |
43 | module.exports = _;
44 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | extends: 'eslint-config-egg/typescript',
4 | globals: {
5 | window: true,
6 | },
7 | rules: {
8 | 'valid-jsdoc': 0,
9 | 'no-script-url': 0,
10 | 'no-multi-spaces': 0,
11 | 'default-case': 0,
12 | 'no-case-declarations': 0,
13 | 'one-var-declaration-per-line': 0,
14 | 'no-restricted-syntax': 0,
15 | 'jsdoc/require-param': 0,
16 | 'jsdoc/check-param-names': 0,
17 | 'jsdoc/require-param-description': 0,
18 | 'jsdoc/require-returns-description': 0,
19 | 'arrow-parens': 0,
20 | 'prefer-promise-reject-errors': 0,
21 | 'no-control-regex': 0,
22 | 'no-use-before-define': 0,
23 | 'array-callback-return': 0,
24 | 'no-bitwise': 0,
25 | 'no-self-compare': 0,
26 | '@typescript-eslint/no-var-requires': 0,
27 | '@typescript-eslint/ban-ts-ignore': 0,
28 | '@typescript-eslint/no-use-before-define': 0,
29 | '@typescript-eslint/no-this-alias': 0,
30 | 'one-var': 0,
31 | 'no-sparse-arrays': 0,
32 | 'no-useless-concat': 0,
33 | },
34 | };
35 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "macaca-playwright",
3 | "version": "1.12.1",
4 | "description": "Macaca Playwright driver",
5 | "keywords": [
6 | "playwright",
7 | "macaca"
8 | ],
9 | "files": [
10 | "dist"
11 | ],
12 | "main": "./dist/macaca-playwright",
13 | "repository": {
14 | "type": "git",
15 | "url": "https://github.com/macacajs/macaca-playwright"
16 | },
17 | "dependencies": {
18 | "@playwright/browser-chromium": "1.43.0",
19 | "@playwright/browser-firefox": "1.43.0",
20 | "@playwright/browser-webkit": "1.43.0",
21 | "driver-base": "^0.1.4",
22 | "kleur": "^4.1.4",
23 | "lodash": "^4.17.21",
24 | "mkdirp": "^1.0.4",
25 | "playwright": "1.43.0",
26 | "selenium-atoms": "^1.0.4",
27 | "webdriver-dfn-error-code": "^1.0.4",
28 | "xlogger": "^1.0.6"
29 | },
30 | "devDependencies": {
31 | "@types/node": "^18.7.14",
32 | "eslint": "7",
33 | "eslint-config-egg": "^11.0.1",
34 | "eslint-config-prettier": "^6.9.0",
35 | "eslint-plugin-mocha": "^4.11.0",
36 | "git-contributor": "1",
37 | "husky": "^1.3.1",
38 | "macaca-ecosystem": "1",
39 | "mocha": "^4.0.1",
40 | "nyc": "^13.1.0",
41 | "power-assert": "^1.6.1",
42 | "ts-node": "^10.9.1",
43 | "typescript": "^4.8.2"
44 | },
45 | "scripts": {
46 | "test": "nyc --reporter=lcov --reporter=text mocha --require ts-node/register",
47 | "lint": "eslint --ext js,ts lib test",
48 | "lint:fix": "eslint --ext js,ts --fix lib test",
49 | "prepublishOnly": "npm run build",
50 | "build": "sh ./build.sh",
51 | "contributor": "git-contributor"
52 | },
53 | "husky": {
54 | "hooks": {
55 | "pre-commit": "npm run lint"
56 | }
57 | },
58 | "license": "MIT"
59 | }
60 |
--------------------------------------------------------------------------------
/lib/redirect-console.js:
--------------------------------------------------------------------------------
1 |
2 |
3 | const kleur = require('kleur');
4 |
5 | const messageTypeToConsoleFn = {
6 | log: console.log,
7 | warning: console.warn,
8 | error: console.error,
9 | info: console.info,
10 | assert: console.assert,
11 | debug: console.debug,
12 | trace: console.trace,
13 | dir: console.dir,
14 | dirxml: console.dirxml,
15 | profile: console.profile,
16 | profileEnd: console.profileEnd,
17 | startGroup: console.group,
18 | startGroupCollapsed: console.groupCollapsed,
19 | endGroup: console.groupEnd,
20 | table: console.table,
21 | count: console.count,
22 | timeEnd: console.info,
23 | };
24 |
25 | module.exports = async (context) => {
26 | const { page } = context;
27 |
28 | async function redirectConsole(msg) {
29 | const type = msg.type();
30 | const consoleFn = messageTypeToConsoleFn[type];
31 |
32 | if (!consoleFn) {
33 | return;
34 | }
35 | const text = msg.text();
36 | const { url, lineNumber, columnNumber } = msg.location();
37 | let msgArgs;
38 |
39 | try {
40 | msgArgs = await Promise.all(
41 | msg.args().map((arg) => arg.jsonValue()),
42 | );
43 | } catch {
44 | // ignore error runner was probably force stopped
45 | }
46 |
47 | if (msgArgs && msgArgs.length > 0) {
48 | consoleFn.apply(console, msgArgs);
49 | } else if (text) {
50 | let color = 'white';
51 |
52 | if (
53 | text.includes(
54 | 'Synchronous XMLHttpRequest on the main thread is deprecated',
55 | )
56 | ) {
57 | return;
58 | }
59 | switch (type) {
60 | case 'error':
61 | color = 'red';
62 | break;
63 | case 'warning':
64 | color = 'yellow';
65 | break;
66 | case 'info':
67 | case 'debug':
68 | color = 'blue';
69 | break;
70 | default:
71 | break;
72 | }
73 |
74 | consoleFn(kleur[color](text));
75 |
76 | console.info(
77 | kleur.gray(
78 | `${url}${
79 | lineNumber
80 | ? ':' + lineNumber + (columnNumber ? ':' + columnNumber : '')
81 | : ''
82 | }`,
83 | ),
84 | );
85 | }
86 | }
87 | page.on('console', redirectConsole);
88 | };
89 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # macaca-playwright
2 |
3 | ---
4 |
5 | [![NPM version][npm-image]][npm-url]
6 | [![CI][ci-image]][ci-url]
7 | [![Test coverage][codecov-image]][codecov-url]
8 | [![node version][node-image]][node-url]
9 |
10 | [npm-image]: https://img.shields.io/npm/v/macaca-playwright.svg?logo=npm
11 | [npm-url]: https://npmjs.org/package/macaca-playwright
12 | [ci-image]: https://github.com/macacajs/macaca-playwright/actions/workflows/ci.yml/badge.svg
13 | [ci-url]: https://github.com/macacajs/macaca-playwright/actions/workflows/ci.yml
14 | [codecov-image]: https://img.shields.io/codecov/c/github/macacajs/macaca-playwright.svg?logo=codecov
15 | [codecov-url]: https://codecov.io/gh/macacajs/macaca-playwright
16 | [node-image]: https://img.shields.io/badge/node.js-%3E=_16-green.svg?logo=node.js
17 | [node-url]: http://nodejs.org/download/
18 |
19 | > [Playwright](//github.com/microsoft/playwright) is a framework for Web Testing and Automation. It allows testing Chromium, Firefox and WebKit with a single API. Macaca Playwright is a long-term maintained browser driver as a candidate for Macaca Playwright driver.
20 |
21 |
22 |
23 | ## Contributors
24 |
25 | |[
xudafeng](https://github.com/xudafeng)
|[
yihuineng](https://github.com/yihuineng)
|[
Jodeee](https://github.com/Jodeee)
|[
snapre](https://github.com/snapre)
|[
chen201724](https://github.com/chen201724)
|[
echizen](https://github.com/echizen)
|
26 | | :---: | :---: | :---: | :---: | :---: | :---: |
27 | [
ilimei](https://github.com/ilimei)
28 |
29 | This project follows the git-contributor [spec](https://github.com/xudafeng/git-contributor), auto updated at `Thu Aug 15 2024 17:34:56 GMT+0800`.
30 |
31 |
32 |
33 | ## Installment
34 |
35 | ```bash
36 | $ npm i macaca-playwright --save-dev
37 | ```
38 |
39 | ## Usage as module
40 |
41 | ```javascript
42 | const fs = require('fs');
43 | const path = require('path');
44 | const Playwright = require('macaca-playwright');
45 |
46 | const playwright = new Playwright();
47 |
48 | async function() {
49 | /**
50 | default options
51 | {
52 | headless: false,
53 | x: 0,
54 | y: 0,
55 | width: 800,
56 | height: 600,
57 | userAgent: 'userAgent string'
58 | }
59 | */
60 | await playwright.startDevice({
61 | headless: true // in silence
62 | });
63 |
64 | await playwright.maximize();
65 | await playwright.setWindowSize(null, 500, 500);
66 | await playwright.get('https://www.baidu.com');
67 | const imgData = await playwright.getScreenshot();
68 | const img = new Buffer(imgData, 'base64');
69 | const p = path.join(__dirname, '..', 'screenshot.png')
70 | fs.writeFileSync(p, img.toString('binary'), 'binary');
71 | console.log(`screenshot: ${p}`);
72 |
73 | await playwright.stopDevice();
74 | };
75 | ```
76 |
--------------------------------------------------------------------------------
/lib/next-actions.js:
--------------------------------------------------------------------------------
1 | const logger = require('./logger');
2 | const _ = require('./helper');
3 | const nextActions = {};
4 |
5 | const locatorReturnLocatorCommonFuncs = [
6 | 'locator',
7 | 'getByRole',
8 | 'getByAltText',
9 | 'getByLabel',
10 | 'getByPlaceholder',
11 | 'getByTestId',
12 | 'getByText',
13 | 'getByTitle',
14 | ];
15 |
16 | nextActions.fileChooser = async function(filePath) {
17 | const fileChooser = await this.page.waitForEvent('filechooser');
18 | await fileChooser.setFiles(filePath);
19 | return true;
20 | };
21 |
22 | nextActions.keyboard = async function({ type, args }) {
23 | const target = this.pageIframe || this.page;
24 | await target.keyboard[type].apply(target.keyboard, args);
25 | return true;
26 | };
27 |
28 | nextActions.mouse = async function({ type, args }) {
29 | const target = this.pageIframe || this.page;
30 | await target.mouse[type].apply(target.mouse, args);
31 | return true;
32 | };
33 |
34 | nextActions.browserType = async function({ func, args }) {
35 | if (this.browserType && this.browserType[func]) {
36 | const result = await this.browserType[func].apply(this.browserType, args);
37 | return result || '';
38 | }
39 | logger.error('browserType instance is not found');
40 | };
41 |
42 | nextActions.browser = async function({ func, args }) {
43 | if (this.browser && this.browser.isConnected() && this.browser[func]) {
44 | const result = await this.browser[func].apply(this.browser, args);
45 | return result || '';
46 | }
47 | logger.error('browser or func is not found');
48 | };
49 |
50 | nextActions.locator = async function({ func, args }) {
51 | if (this.locator && this.locator[func]) {
52 | const result = await this.locator[func].apply(this.locator, args);
53 | // 返回locator对象时,缓存
54 | if (
55 | result
56 | && locatorReturnLocatorCommonFuncs.concat([
57 | 'and',
58 | 'or',
59 | 'filter',
60 | 'first',
61 | 'last',
62 | 'nth',
63 | ]).includes(func)
64 | ) {
65 | this.locator = result;
66 | }
67 | return result || '';
68 | }
69 | logger.error('browser or func is not found');
70 | };
71 |
72 | // 对当前页面进行方法调用
73 | nextActions.page = async function({ func, args }) {
74 | if (this.page && !this.page.isClosed() && this.page[func]) {
75 | await this.page.waitForLoadState();
76 | const result = await this.page[func].apply(this.page, args);
77 | // 返回locator相关的方法时需要给 this.locator 赋值
78 | if (
79 | result
80 | && locatorReturnLocatorCommonFuncs.concat([
81 | 'frameLocator',
82 | 'waitForSelector',
83 | ]).includes(func)) {
84 | this.locator = result;
85 | }
86 | return result || '';
87 | }
88 | logger.error('page or func is not found');
89 | };
90 |
91 | // 对弹出页面进行方法调用
92 | nextActions.pagePopup = async function({ func, args }) {
93 | // 等待新弹出页面
94 | if (!this.pagePopup || this.pagePopup.isClosed()) {
95 | await _.sleep(2E3);
96 | }
97 | if (this.pagePopup && !this.pagePopup.isClosed() && this.pagePopup[func]) {
98 | await this.pagePopup.waitForLoadState();
99 | const result = await this.pagePopup[func].apply(this.pagePopup, args);
100 | return result || '';
101 | }
102 | logger.error('pagePopup or func is not found');
103 | };
104 |
105 | /**
106 | * 当前page中frame对象的方法调用
107 | * @param index 指定为当前page中第几个iframe
108 | * @param func
109 | * @param args
110 | * @return {Promise<*|string>}
111 | */
112 | nextActions.pageIframe = async function({ index, func, args }) {
113 | if (_.isNumber(index)) {
114 | this._setPageIframeByIndex(index);
115 | }
116 | if (this.pageIframe && this.pageIframe[func]) {
117 | await this.pageIframe.waitForLoadState();
118 | const result = await this.pageIframe[func].apply(this.pageIframe, args);
119 | if (
120 | result
121 | && locatorReturnLocatorCommonFuncs.concat([
122 | 'frameLocator',
123 | 'frameElement',
124 | 'waitForSelector',
125 | ]).includes(func)) {
126 | this.locator = result;
127 | }
128 | return result || '';
129 | }
130 | logger.error('pageIframe or func is not found');
131 | };
132 |
133 | nextActions.elementStatus = async function(elementId) {
134 | const element = this.elements[elementId];
135 | if (!element) {
136 | logger.error('Element is not found');
137 | return null;
138 | }
139 | return {
140 | disabled: await element.isDisabled(),
141 | editable: await element.isEditable(),
142 | enabled: await element.isEnabled(),
143 | hidden: await element.isHidden(),
144 | visible: await element.isVisible(),
145 | };
146 | };
147 |
148 | module.exports = nextActions;
149 |
--------------------------------------------------------------------------------
/test/macaca-playwright.test.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const assert = require('power-assert');
3 |
4 | const _ = require('../lib/helper');
5 | const Playwright = require('../lib/macaca-playwright');
6 |
7 | const headless = !!process.env.CI;
8 |
9 | describe('test/macaca-playwright.test.js', function() {
10 | let res;
11 | this.timeout(5 * 60E3);
12 | const customUserAgent = 'custom userAgent';
13 |
14 | describe('methods testing with chromium', function() {
15 |
16 | const driver = new Playwright();
17 |
18 | beforeEach(async () => {
19 | const videoDir = path.resolve(__dirname, '..', 'videos');
20 | await driver.startDevice({
21 | headless,
22 | userAgent: customUserAgent,
23 | recordVideo: {
24 | dir: videoDir,
25 | },
26 | });
27 | await driver.get('file://' + path.resolve(__dirname, 'webpages/1.html'));
28 | });
29 |
30 | afterEach(async () => {
31 | await driver.stopDevice();
32 | });
33 |
34 | it('getSource', async () => {
35 | res = await driver.getSource();
36 | assert(res.includes(''));
37 | });
38 |
39 | it('execute', async () => {
40 | res = await driver.execute('return navigator.userAgent');
41 | assert.equal(res, customUserAgent);
42 | });
43 |
44 | it('title', async () => {
45 | res = await driver.title();
46 | assert.equal(res, 'Document 1');
47 | });
48 |
49 | it('setWindowSize', async () => {
50 | await driver.setWindowSize(null, 600, 600);
51 | await driver.maximize();
52 | });
53 |
54 | it('screenshot', async () => {
55 | res = await driver.getScreenshot();
56 | assert(res);
57 | });
58 |
59 | it('element screenshot', async () => {
60 | await driver.page.setContent('');
61 | await driver.findElement('xpath', '//*[@id="input"]');
62 | res = await driver.takeElementScreenshot();
63 | assert(res);
64 | });
65 |
66 | it('setValue and clearText', async () => {
67 | const input = await driver.findElement('id', 'input');
68 | await driver.setValue(input.ELEMENT, [ 'aaa' ]);
69 | await driver.clearText(input.ELEMENT);
70 | await driver.setValue(input.ELEMENT, [ 'macaca' ]);
71 | });
72 |
73 | it('isDisplayed', async () => {
74 | const button = await driver.findElement('id', 'input');
75 | res = await driver.isDisplayed(button.ELEMENT);
76 | assert.equal(res, true);
77 | });
78 |
79 | it('elementStatus', async () => {
80 | const button = await driver.findElement('id', 'input');
81 | res = await driver.elementStatus(button.ELEMENT);
82 | assert(res.disabled === false);
83 | assert(res.editable === true);
84 | assert(res.enabled === true);
85 | assert(res.hidden === false);
86 | assert(res.visible === true);
87 | });
88 |
89 | it('click', async () => {
90 | const button = await driver.findElement('id', 'input');
91 | await driver.click(button.ELEMENT, { delay: 300 });
92 | });
93 |
94 | it('getRect', async () => {
95 | const button = await driver.findElement('id', 'input');
96 | res = await driver.getRect(button.ELEMENT);
97 | assert(res.x);
98 | assert(res.y);
99 | assert(res.width);
100 | assert(res.height);
101 | });
102 |
103 | it('getComputedCss', async () => {
104 | const button = await driver.findElement('id', 'button-1');
105 | res = await driver.getComputedCss(button.ELEMENT, 'padding');
106 | assert.equal(res, '5px 10px');
107 | });
108 |
109 | it('redirect location', async () => {
110 | const link = await driver.findElement('id', 'link-1');
111 | await driver.click(link.ELEMENT);
112 | res = await driver.title();
113 | assert.equal(res, 'Document 2');
114 | await driver.back();
115 | await _.sleep(1000);
116 | await driver.refresh();
117 | await _.sleep(1000);
118 | res = await driver.title();
119 | assert.equal(res, 'Document 1');
120 | });
121 |
122 | it('open in new window', async () => {
123 | const link = await driver.findElement('id', 'link-2');
124 | await driver.click(link.ELEMENT);
125 | await driver.maximize();
126 | });
127 |
128 | it('window handlers', async () => {
129 | const windows = await driver.getWindows();
130 | assert.equal(windows.length, 1);
131 | res = await driver.title();
132 | assert.equal(res, 'Document 1');
133 | });
134 |
135 | it('getAllCookies', async () => {
136 | await driver.get('https://www.google.com.hk');
137 | res = await driver.getAllCookies();
138 | assert(Array.isArray(res));
139 | });
140 | });
141 | });
142 |
--------------------------------------------------------------------------------
/lib/macaca-playwright.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { sync as mkdirp } from 'mkdirp';
3 | import playwright, { ElementHandle, Frame, FrameLocator, Locator } from 'playwright';
4 | import DriverBase from 'driver-base';
5 |
6 | import _ from './helper';
7 | import initRedirectConsole from './redirect-console';
8 | import controllers from './controllers';
9 | import extraActions from './next-actions';
10 | import os from 'os';
11 |
12 | const DEFAULT_CONTEXT = 'DEFAULT_CONTEXT';
13 |
14 | type TContextOptions = {
15 | ignoreHTTPSErrors: boolean,
16 | locale: string,
17 | userAgent?: string,
18 | recordVideo?: object,
19 | viewport?: object,
20 | permissions?: string[],
21 | proxy?: {
22 | server: string;
23 | username?: string;
24 | password?: string;
25 | };
26 | };
27 |
28 | type TDeviceCaps = {
29 | port?: number;
30 | locale?: string;
31 | userAgent?: string;
32 | recordVideo?: {
33 | dir: string;
34 | };
35 | width?: number;
36 | height?: number;
37 | redirectConsole?: boolean;
38 | proxy?: {
39 | server: string;
40 | username?: string;
41 | password?: string;
42 | };
43 | navigationTimeout?: number;
44 | };
45 |
46 | class Playwright extends DriverBase {
47 | args: TDeviceCaps = null;
48 | browserType = null;
49 | browser = null;
50 | browserContext = null;
51 | newContextOptions = {};
52 | pageIframes: Frame[] = [];
53 | page = null;
54 | pagePopup = null;
55 | pageIframe: Frame = null;
56 | locator: ElementHandle | Locator | FrameLocator = null; // 当前选中的 element 或 locator
57 | atoms = [];
58 | pages = [];
59 | elements = {};
60 | browserContexts = [];
61 |
62 | static DEFAULT_CONTEXT = DEFAULT_CONTEXT;
63 |
64 | async startDevice(caps: TDeviceCaps = {}) {
65 | this.args = _.clone(caps);
66 |
67 | const launchOptions = {
68 | headless: true,
69 | browserName: 'chromium',
70 | ...this.args,
71 | };
72 | delete launchOptions.port;
73 | if (
74 | this.args.proxy
75 | && launchOptions.browserName === 'chromium'
76 | && os.platform() === 'win32'
77 | ) {
78 | // Browser proxy option is required for Chromium on Windows.
79 | launchOptions.proxy = { server: 'per-context' };
80 | }
81 | this.browserType = await playwright[launchOptions.browserName];
82 | this.browser = await this.browserType.launch(launchOptions);
83 | const permissions = launchOptions.browserName === 'chromium' ? [
84 | 'clipboard-read',
85 | 'clipboard-write',
86 | ] : [];
87 | const newContextOptions: TContextOptions = {
88 | locale: this.args.locale,
89 | ignoreHTTPSErrors: true,
90 | permissions,
91 | };
92 |
93 | if (this.args.proxy) {
94 | newContextOptions.proxy = this.args.proxy;
95 | }
96 |
97 | if (this.args.userAgent) {
98 | newContextOptions.userAgent = this.args.userAgent;
99 | }
100 |
101 | if (this.args.recordVideo) {
102 | const dir = this.args.recordVideo.dir || path.resolve(process.cwd(), 'videos');
103 | mkdirp(dir);
104 | newContextOptions.recordVideo = {
105 | dir,
106 | size: { width: 1280, height: 800 },
107 | ...this.args.recordVideo,
108 | };
109 | }
110 |
111 | if (this.args.width && this.args.height) {
112 | newContextOptions.viewport = {
113 | width: this.args.width,
114 | height: this.args.height,
115 | };
116 | // 录像大小为窗口大小
117 | if (this.args.recordVideo) {
118 | newContextOptions.recordVideo = {
119 | ...newContextOptions.recordVideo,
120 | size: {
121 | width: this.args.width,
122 | height: this.args.height,
123 | },
124 | };
125 | }
126 | }
127 |
128 | this.newContextOptions = newContextOptions;
129 | await this._createContext();
130 |
131 | if (this.args.redirectConsole) {
132 | await initRedirectConsole(this);
133 | }
134 | }
135 |
136 | async _createContext(contextName?: string, contextOptions = {}) {
137 | if (!contextName) {
138 | contextName = DEFAULT_CONTEXT;
139 | }
140 | const index = this.browserContexts.length;
141 | const newContextOptions = {
142 | ...this.newContextOptions,
143 | ...contextOptions,
144 | };
145 | const browserContext = await this.browser.newContext(newContextOptions);
146 | browserContext.name = contextName;
147 | browserContext.index = index;
148 | if (typeof this.args.navigationTimeout === 'number') {
149 | browserContext.setDefaultNavigationTimeout(this.args.navigationTimeout);
150 | }
151 | this.browserContexts.push(browserContext);
152 | this.browserContext = this.browserContexts[index];
153 | this.pages.push(await this.browserContext.newPage());
154 | this.page = this.pages[index];
155 | // Get all popups when they open
156 | this.page.on('popup', async (popup) => {
157 | this.pagePopup = popup;
158 | });
159 | return index;
160 | }
161 |
162 | /**
163 | * 切换窗口
164 | * @param contextName
165 | */
166 | async _switchContextPage(contextName?: string) {
167 | if (!contextName) {
168 | contextName = DEFAULT_CONTEXT;
169 | }
170 | const index = this.browserContexts.findIndex(it => it.name === contextName);
171 | this._setContext(index);
172 | return index;
173 | }
174 |
175 | _setContext(index: number) {
176 | this.browserContext = this.browserContexts[index];
177 | this.page = this.pages[index];
178 | }
179 |
180 | /**
181 | * 设置当前的page的Iframes
182 | */
183 | _freshPageIframes() {
184 | this.pageIframes = this.page.mainFrame().childFrames();
185 | }
186 |
187 | /**
188 | * 设置当前操作的iframe
189 | */
190 | _setPageIframeByIndex(index = 0) {
191 | this._freshPageIframes();
192 | if (!this.pageIframes[index]) {
193 | console.error('target iframe not found');
194 | return;
195 | }
196 | this.pageIframe = this.pageIframes[index];
197 | }
198 |
199 | async stopDevice() {
200 | await this.browserContext.close();
201 | await this.browser.close();
202 | this.browser = null;
203 | }
204 |
205 | isProxy() {
206 | return false;
207 | }
208 |
209 | whiteList(context) {
210 | const basename = path.basename(context.url);
211 | const whiteList = [];
212 | return !!~whiteList.indexOf(basename);
213 | }
214 | }
215 |
216 | _.extend(Playwright.prototype, controllers);
217 | _.extend(Playwright.prototype, extraActions);
218 |
219 | module.exports = Playwright;
220 |
--------------------------------------------------------------------------------
/lib/controllers.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 | const assert = require('assert');
4 | const { sync: mkdirp } = require('mkdirp');
5 | const { getByName: getAtom } = require('selenium-atoms');
6 | const { errors } = require('webdriver-dfn-error-code');
7 |
8 | const _ = require('./helper');
9 | const logger = require('./logger');
10 | const nextActions = require('./next-actions');
11 |
12 | const ELEMENT_OFFSET = 1000;
13 |
14 | const implicitWaitForCondition = function(func) {
15 | return _.waitForCondition(func, this?.implicitWaitMs);
16 | };
17 |
18 | const sendJSCommand = async function(script) {
19 | const atomScript = getAtom('execute_script');
20 | const command = `(${atomScript})(${JSON.stringify(script)})`;
21 |
22 | let res;
23 | await implicitWaitForCondition.call(this, async () => {
24 | await (this.pageIframe || this.page).waitForLoadState();
25 | res = await (this.pageIframe || this.page).evaluate(command);
26 | await (this.pageIframe || this.page).waitForLoadState();
27 | return !!res;
28 | });
29 |
30 | if (res.value) {
31 | return res.value;
32 | }
33 |
34 | try {
35 | return JSON.parse(res).value;
36 | } catch (e) {
37 | return null;
38 | }
39 | };
40 |
41 | const convertAtoms2Element = function(atoms) {
42 | const atomsId = atoms && atoms.ELEMENT;
43 |
44 | if (!atomsId) {
45 | return null;
46 | }
47 |
48 | const index = this.atoms.push(atomsId) - 1;
49 |
50 | return {
51 | ELEMENT: index + ELEMENT_OFFSET,
52 | };
53 | };
54 |
55 | const findElementOrElements = async function(strategy, selector, ctx, many) {
56 | strategy = strategy.toLowerCase();
57 |
58 | let result;
59 | this.elements = {};
60 |
61 | // cache locator
62 | if (strategy === 'xpath') {
63 | this.locator = (this.pageIframe || this.page).locator(selector);
64 | }
65 | /**
66 | * `css selector` and `xpath` is default
67 | */
68 | if (strategy === 'name') {
69 | selector = `text=${selector}`;
70 | } else if (strategy === 'id') {
71 | selector = `//*[@id="${selector}"]`;
72 | }
73 |
74 | try {
75 | await (this.pageIframe || this.page).waitForSelector(selector, {
76 | state: 'attached',
77 | timeout: 500,
78 | });
79 | } catch (_) {
80 | result = [];
81 | }
82 |
83 | if (many) {
84 | try {
85 | result = await (this.pageIframe || this.page).$$(selector);
86 | } catch (e) {
87 | logger.debug(e);
88 | result = [];
89 | }
90 | const elements = [];
91 | for (const item of result) {
92 | const isVisible = await item.isVisible();
93 | if (!isVisible) {
94 | continue;
95 | }
96 | this.elements[item._guid] = item;
97 | elements.push({
98 | ELEMENT: item._guid,
99 | });
100 | }
101 | return elements;
102 | }
103 |
104 | result = await (this.pageIframe || this.page).$(selector);
105 |
106 | if (!result || _.size(result) === 0) {
107 | throw new errors.NoSuchElement();
108 | }
109 |
110 | this.elements[result._guid] = result;
111 |
112 | return {
113 | ELEMENT: result._guid,
114 | };
115 | };
116 |
117 | const controllers = {};
118 |
119 | /**
120 | * Change focus to another frame on the page.
121 | *
122 | * @module setFrame
123 | * @return {Promise}
124 | * @param frameElement
125 | */
126 | controllers.setFrame = async function(frameElement) {
127 | let ele;
128 | if (frameElement) {
129 | ele = await this.elements[frameElement.ELEMENT];
130 | if (!ele) {
131 | throw new errors.NoSuchElement();
132 | }
133 | this.pageIframe = await ele.contentFrame();
134 | if (!this.pageIframe) {
135 | throw new errors.NoSuchFrame();
136 | }
137 | } else {
138 | // clear pageIframe
139 | this.pageIframe = null;
140 | return null;
141 | }
142 | return null;
143 | };
144 |
145 | /**
146 | * Click on an element.
147 | * @module click
148 | * @return {Promise}
149 | */
150 | controllers.click = async function(elementId, options = {}) {
151 | const element = this.elements[elementId];
152 | if (!element) {
153 | logger.error('click element is not found');
154 | return null;
155 | }
156 | if (!element.isVisible()) {
157 | logger.error('click element is not visible');
158 | return null;
159 | }
160 | await element.click({
161 | timeout: 2E3,
162 | delay: 200,
163 | ...options,
164 | }).catch(async e => {
165 | // 处理一些需要自动重试的异常
166 | if (e.message.includes('Element is not attached')) {
167 | await _.sleep(2E3);
168 | await element.click({
169 | timeout: 2E3,
170 | delay: 200,
171 | ...options,
172 | });
173 | } else {
174 | throw e;
175 | }
176 | });
177 | return null;
178 | };
179 |
180 | /**
181 | * take element screenshot.
182 | *
183 | * @module takeElementScreenshot
184 | * @return {Promise}
185 | */
186 | controllers.takeElementScreenshot = async function(elementId, params = {}) {
187 | const { file } = params;
188 | let image;
189 | if (elementId) {
190 | image = await this.elements[elementId].screenshot();
191 | } else if (this.locator) {
192 | image = await this.locator.screenshot();
193 | } else {
194 | throw new errors.NoSuchElement();
195 | }
196 | const base64 = image.toString('base64');
197 | if (file) {
198 | const img = new Buffer(base64, 'base64');
199 | const realPath = path.resolve(file);
200 | mkdirp(path.dirname(realPath));
201 | fs.writeFileSync(realPath, img.toString('binary'), 'binary');
202 | }
203 | return base64;
204 | };
205 |
206 | /**
207 | * Search for an element on the page, starting from the document root.
208 | * @module findElement
209 | * @param {string} strategy The type
210 | * @param selector Selector string
211 | * @param {string} ctx The search target.
212 | * @return {Promise.}
213 | */
214 | controllers.findElement = async function(strategy, selector, ctx) {
215 | return findElementOrElements.call(this, strategy, selector, ctx, false);
216 | };
217 |
218 | controllers.findElements = async function(strategy, selector, ctx) {
219 | return findElementOrElements.call(this, strategy, selector, ctx, true);
220 | };
221 |
222 | /**
223 | * Returns the visible text for the element.
224 | *
225 | * @module getText
226 | * @return {Promise.}
227 | */
228 | controllers.getText = async function(elementId) {
229 | const element = this.elements[elementId];
230 | return element.innerText();
231 | };
232 |
233 | /**
234 | * Clear a TEXTAREA or text INPUT element's value.
235 | *
236 | * @module clearText
237 | * @return {Promise.}
238 | */
239 | controllers.clearText = async function(elementId) {
240 | const element = this.elements[elementId];
241 | await element.fill('');
242 | return null;
243 | };
244 |
245 | /**
246 | * Set element's value.
247 | *
248 | * @module setValue
249 | * @param elementId
250 | * @param value
251 | * @return {Promise.}
252 | */
253 | controllers.setValue = async function(elementId, value) {
254 | if (!Array.isArray(value)) {
255 | value = [ value ];
256 | }
257 | const element = this.elements[elementId];
258 | await element.fill(...value);
259 | return null;
260 | };
261 |
262 |
263 | /**
264 | * Determine if an element is currently displayed.
265 | *
266 | * @module isDisplayed
267 | * @return {Promise.}
268 | */
269 | controllers.isDisplayed = async function(elementId) {
270 | const element = this.elements[elementId];
271 | return element.isVisible();
272 | };
273 |
274 | /**
275 | * Get the value of an element's property.
276 | *
277 | * @module getProperty
278 | * @return {Promise.}
279 | */
280 | controllers.getProperty = async function(elementId, attrName) {
281 | const element = this.elements[elementId];
282 | return element.getAttribute(attrName);
283 | };
284 |
285 | /**
286 | * Get the current page title.
287 | *
288 | * @module title
289 | * @return {Promise.