├── 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.} 290 | */ 291 | controllers.title = async function() { 292 | return (this.pageIframe || this.page).title(); 293 | }; 294 | 295 | /** 296 | * Inject a snippet of JavaScript into the page for execution in the context of the currently selected frame. 297 | * 298 | * @module execute 299 | * @param script script 300 | * @return {Promise.} 301 | */ 302 | controllers.execute = async function(script) { 303 | 304 | const value = await sendJSCommand.call(this, script); 305 | 306 | if (Array.isArray(value)) { 307 | return value.map(convertAtoms2Element.bind(this)); 308 | } 309 | return value; 310 | 311 | }; 312 | 313 | /** 314 | * Retrieve the URL of the current page. 315 | * 316 | * @module url 317 | * @return {Promise.} 318 | */ 319 | controllers.url = async function() { 320 | return (this.pageIframe || this.page).url(); 321 | }; 322 | 323 | /** 324 | * Navigate to a new URL. 325 | * 326 | * @module get 327 | * @param url get a new url. 328 | * @return {Promise.} 329 | */ 330 | controllers.get = async function(url) { 331 | this.pageIframe = null; 332 | await this.page.goto(url, { 333 | waitUntil: 'load' || 'networkidle', 334 | }); 335 | return null; 336 | }; 337 | 338 | /** 339 | * Navigate forwards in the browser history, if possible. 340 | * 341 | * @module forward 342 | * @return {Promise.} 343 | */ 344 | controllers.forward = async function() { 345 | this.pageIframe = null; 346 | await this.page.goForward(); 347 | return null; 348 | }; 349 | 350 | /** 351 | * Navigate backwards in the browser history, if possible. 352 | * 353 | * @module back 354 | * @return {Promise.} 355 | */ 356 | controllers.back = async function() { 357 | this.pageIframe = null; 358 | await this.page.goBack(); 359 | return null; 360 | }; 361 | 362 | /** 363 | * Get all window handlers. 364 | * 365 | * @module back 366 | * @return {Promise} 367 | */ 368 | controllers.getWindows = async function() { 369 | return this.page.frames(); 370 | }; 371 | 372 | /** 373 | * Change focus to another window. 374 | * 375 | * @module setWindow 376 | * @return {Promise.} 377 | */ 378 | controllers.setWindow = async function(name) { 379 | await this._switchContextPage(name); 380 | return null; 381 | }; 382 | 383 | /** 384 | * Close the current window. 385 | * 386 | * @module deleteWindow 387 | * @return {Promise.} 388 | */ 389 | controllers.deleteWindow = async function() { 390 | await this.page?.close(); 391 | return null; 392 | }; 393 | 394 | /** 395 | * Set the size of the specified window. 396 | * 397 | * @module setWindowSize 398 | * @param [windowHandle] window handle to set size for (optional, default: 'current') 399 | * @param width 400 | * @param height 401 | * @return {Promise.} 402 | */ 403 | controllers.setWindowSize = async function(windowHandle, width, height) { 404 | if (windowHandle && windowHandle !== 'current') { 405 | const index = this.browserContexts.findIndex(item => item.name === windowHandle); 406 | await this.pages[index]?.setViewportSize({ 407 | width, 408 | height, 409 | }); 410 | return null; 411 | } 412 | await this.page.setViewportSize({ 413 | width, 414 | height, 415 | }); 416 | return null; 417 | }; 418 | 419 | /** 420 | * Get the size of the specified window. 421 | * 422 | * @module getWindowSize 423 | * @param [windowHandle] window handle to set size for (optional, default: 'current') 424 | */ 425 | controllers.getWindowSize = function(windowHandle) { 426 | if (windowHandle && windowHandle !== 'current') { 427 | const index = this.browserContexts.findIndex(item => item.name === windowHandle); 428 | return this.pages[index]?.viewportSize(); 429 | } 430 | return this.page.viewportSize(); 431 | }; 432 | 433 | /** 434 | * Maximize the specified window if not already maximized. 435 | * 436 | * @module maximize 437 | * @param windowHandle window handle 438 | * @return {Promise.} 439 | */ 440 | controllers.maximize = async function(windowHandle) { 441 | return this.setWindowSize(windowHandle, 1920, 1080); 442 | }; 443 | 444 | /** 445 | * Refresh the current page. 446 | * 447 | * @module refresh 448 | * @return {Promise.} 449 | */ 450 | controllers.refresh = async function() { 451 | this.pageIframe = null; 452 | return this.page.reload(); 453 | }; 454 | 455 | /** 456 | * Get the current page source. 457 | * 458 | * @module getSource 459 | * @return {Promise.} 460 | */ 461 | controllers.getSource = async function() { 462 | const cmd = 'return document.getElementsByTagName(\'html\')[0].outerHTML'; 463 | return this.execute(cmd); 464 | }; 465 | 466 | /** 467 | * Take a screenshot of the current page. 468 | * 469 | * @module getScreenshot 470 | * @return {Promise.} The screenshot as a base64 encoded PNG. 471 | */ 472 | controllers.getScreenshot = async function(context, params = {}) { 473 | // boolean 参数安全处理 474 | if (typeof params.fullPage === 'string') { 475 | params.fullPage = params.fullPage === 'true'; 476 | } 477 | if (params.video) { 478 | return await this.page.video()?.path?.() || null; 479 | } 480 | const image = await this.page.screenshot(params); 481 | const base64 = image.toString('base64'); 482 | 483 | if (params.dir) { 484 | const img = new Buffer(base64, 'base64'); 485 | const dir = path.resolve(params.dir); 486 | mkdirp(path.dirname(dir)); 487 | fs.writeFileSync(dir, img.toString('binary'), 'binary'); 488 | } 489 | return base64; 490 | }; 491 | 492 | /** 493 | * Query the value of an element's computed CSS property. 494 | * 495 | * https://www.w3.org/TR/webdriver/#dfn-get-element-css-value 496 | * @module getComputedCss 497 | * @return {Promise.} 498 | */ 499 | controllers.getComputedCss = async function(elementId, propertyName) { 500 | const element = this.elements[elementId]; 501 | /* istanbul ignore next */ 502 | return this.page.evaluate(([ element, propertyName ]) => window.getComputedStyle(element)[propertyName], [ element, propertyName ]); 503 | }; 504 | 505 | /** 506 | * Returns all cookies associated with the address of the current browsing context’s active document. 507 | * 508 | * @module getAllCookies 509 | * @return {Promise.} 510 | */ 511 | controllers.getAllCookies = async function() { 512 | return this.browserContext.cookies(); 513 | }; 514 | 515 | /** 516 | * Returns the cookie with the requested name from the associated cookies in the cookie store of the current browsing context’s active document. If no cookie is found, a no such cookie error is returned. 517 | * 518 | * @module getNamedCookie 519 | * @return {Promise.} 520 | */ 521 | controllers.getNamedCookie = async function() { 522 | return this.browserContext.cookies(); 523 | }; 524 | 525 | /** 526 | * Adds a single cookie to the cookie store associated with the active document’s address. 527 | * 528 | * @module addCookie 529 | * @return {Promise.} 530 | */ 531 | controllers.addCookie = async function(cookie) { 532 | await this.browserContext.addCookies(cookie); 533 | return null; 534 | }; 535 | 536 | /** 537 | * Delete either a single cookie by parameter name, or all the cookies associated with the active document’s address if name is undefined. 538 | * 539 | * @module deleteCookie 540 | * @return {Promise.} 541 | */ 542 | controllers.deleteCookie = async function(cookie) { 543 | await this.browserContext.clearCookies(cookie); 544 | return true; 545 | }; 546 | 547 | /** 548 | * Delete All Cookies command allows deletion of all cookies associated with the active document’s address. 549 | * 550 | * @module deleteAllCookies 551 | * @return {Promise.} 552 | */ 553 | controllers.deleteAllCookies = async function() { 554 | await this.browserContext.clearCookies(); 555 | return true; 556 | }; 557 | 558 | controllers.getContexts = async function() { 559 | return this.browserContexts; 560 | }; 561 | 562 | controllers.getContext = async function() { 563 | return this.browserContext; 564 | }; 565 | 566 | controllers.setContext = async function(name, ctxOpts) { 567 | const index = this.browserContexts.findIndex(item => item.name === name); 568 | if (index !== -1) { 569 | this._setContext(index); 570 | } else { 571 | await this._createContext(name, ctxOpts); 572 | } 573 | return true; 574 | }; 575 | 576 | controllers.getRect = async function(elementId) { 577 | const element = this.elements[elementId]; 578 | return element.boundingBox(); 579 | }; 580 | 581 | /** 582 | * next 标准扩展 583 | * @return {Promise} 584 | */ 585 | controllers.next = async function(method, args = []) { 586 | assert(method, 'method name is required'); 587 | return nextActions[method].call(this, ...args); 588 | }; 589 | 590 | module.exports = controllers; 591 | --------------------------------------------------------------------------------