├── .editorconfig ├── .eslintignore ├── .eslintrc.cjs ├── .github ├── assets │ └── demo.gif ├── dependabot.yml └── workflows │ ├── expense.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .nvmrc ├── LICENSE.md ├── Readme.md ├── bin └── wdio-chrome-recorder.js ├── package-lock.json ├── package.json ├── src ├── cli │ └── index.ts ├── constants.ts ├── index.ts ├── stringifyExtension.ts ├── transform.ts ├── types.ts └── utils.ts ├── test ├── InMemoryLineWriter.ts └── stringifyExtension.test.ts ├── tsconfig.json └── vitest.conf.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .git 2 | node_modules 3 | dist 4 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | plugins: ['@typescript-eslint', 'import'], 4 | extends: [ 5 | 'plugin:@typescript-eslint/recommended', 6 | ], 7 | rules: { 8 | semi: ['error', 'never'], 9 | quotes: ['error', 'single'], 10 | indent: [2, 4], 11 | 'import/extensions': ['error', 'ignorePackages'] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdriverio/chrome-recorder/6471eabce2e196d394d21fcc9c2489fd9f5c2610/.github/assets/demo.gif -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | 4 | updates: 5 | - package-ecosystem: npm 6 | directory: "/" 7 | schedule: 8 | interval: weekly 9 | time: "11:00" 10 | open-pull-requests-limit: 10 11 | versioning-strategy: increase-if-necessary 12 | 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | schedule: 16 | interval: "weekly" 17 | time: "11:00" 18 | -------------------------------------------------------------------------------- /.github/workflows/expense.yml: -------------------------------------------------------------------------------- 1 | name: Expense Contribution 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | prNumber: 7 | description: "Number of the PR (without #)" 8 | required: true 9 | amount: 10 | description: "The expense amount you like to grant for the contribution in $" 11 | required: true 12 | type: choice 13 | options: 14 | - 15 15 | - 25 16 | - 35 17 | - 50 18 | - 100 19 | - 150 20 | - 200 21 | - 250 22 | - 300 23 | - 350 24 | - 400 25 | - 450 26 | - 500 27 | - 550 28 | - 600 29 | - 650 30 | - 700 31 | - 750 32 | - 800 33 | - 850 34 | - 900 35 | - 950 36 | - 1000 37 | 38 | jobs: 39 | authorize: 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: octokit/request-action@v2.3.1 43 | with: 44 | route: GET /orgs/:organisation/teams/:team/memberships/${{ github.actor }} 45 | team: technical-steering-committee 46 | organisation: webdriverio 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.WDIO_BOT_GITHUB_TOKEN }} 49 | expense: 50 | permissions: 51 | contents: write 52 | id-token: write 53 | needs: [authorize] 54 | runs-on: ubuntu-latest 55 | steps: 56 | - name: Run Expense Flow 57 | uses: webdriverio/expense-action@v1 58 | with: 59 | prNumber: ${{ github.event.inputs.prNumber }} 60 | amount: ${{ github.event.inputs.amount }} 61 | env: 62 | RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} 63 | GH_TOKEN: ${{ secrets.WDIO_BOT_GITHUB_TOKEN }} 64 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Manual NPM Publish 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | releaseType: 7 | description: "Release type - major, minor or patch" 8 | required: true 9 | type: choice 10 | default: "patch" 11 | options: 12 | - patch 13 | - minor 14 | - major 15 | distTag: 16 | description: 'NPM tag (e.g. use "next --preRelease=alpha --github.preRelease" to release a test version)' 17 | required: true 18 | default: 'latest' 19 | preRelease: 20 | description: If latest release was a pre-release (e.g. X.X.X-alpha.0) and you want to push another one, pick "yes" 21 | required: true 22 | type: choice 23 | default: "no" 24 | options: 25 | - "yes" 26 | - "no" 27 | 28 | env: 29 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | 31 | jobs: 32 | release: 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v4 36 | with: 37 | fetch-depth: 0 38 | - uses: actions/setup-node@v4 39 | with: 40 | node-version: 18.x 41 | - name: NPM Setup 42 | run: | 43 | npm set registry "https://registry.npmjs.org/" 44 | npm set //registry.npmjs.org/:_authToken $NPM_TOKEN 45 | npm whoami 46 | - name: Git Setup 47 | run: | 48 | git config --global user.email "bot@webdriver.io" 49 | git config --global user.name "WebdriverIO Release Bot" 50 | - name: Install Dependencies 51 | run: npm ci 52 | - name: Build Project 53 | run: npm run build 54 | env: 55 | NODE_ENV: production 56 | - name: Release 57 | run: npx release-it ${{github.event.inputs.releaseType}} --github.release --ci --npm.skipChecks --no-git.requireCleanWorkingDir --npm.tag=${{github.event.inputs.distTag}} 58 | env: 59 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | if: ${{ github.event.inputs.preRelease == 'no' }} 62 | - name: Pre-Release 63 | run: npx release-it --github.release --ci --npm.skipChecks --no-git.requireCleanWorkingDir --preRelease=alpha --github.preRelease --npm.tag=next 64 | env: 65 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 66 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 67 | if: ${{ github.event.inputs.preRelease == 'yes' }} 68 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Build tool and test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build-tool-and-test: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Use Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 18.x 21 | - name: Install 22 | run: npm ci 23 | - name: Build 24 | run: npm run build 25 | - name: Run Tests 26 | run: npm run test 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .DS_Store 4 | .vscode 5 | /*.js 6 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.20.2 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) OpenJS Foundation and other contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | 'Software'), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | WebdriverIO Chrome Recorder [![Build](https://github.com/webdriverio/chrome-recorder/actions/workflows/test.yml/badge.svg)](https://github.com/webdriverio/chrome-recorder/actions/workflows/build.yml) 2 | [![npm][npm-badge]][npm] 3 | =========================== 4 | 5 | This repo provide tools to convert JSON user flows from [Google Chrome DevTools Recorder](https://goo.gle/devtools-recorder) to WebdriverIO test scripts programmatically (WebdriverIO `v7.24.0` or higher required). 6 | 7 | ✅ Converts multiple recordings to WebdriverIO tests in one go (out-of-the-box glob support) 8 | 🗂 User can pass their custom path to export tests. 9 | 💃 Users can also use a dry run to see the interim output of the recordings 10 | 👨‍💻 Programmatic API which users can use in their own project to create plugins or custom scripts. 11 | 12 | Alternatively, you can export JSON user flows as WebdriverIO test scripts straight away from Chrome DevTools with our [WebdriverIO Recorder Chrome extension](https://chrome.google.com/webstore/detail/webdriverio-chrome-record/pllimkccefnbmghgcikpjkmmcadeddfn). 13 | 14 | ## 🏗 Installation 15 | 16 | ```sh 17 | npm install -g @wdio/chrome-recorder 18 | ``` 19 | 20 | ## 🚀 Usage 21 | 22 | To quickly run the interactive CLI, run: 23 | 24 | ```sh 25 | npx @wdio/chrome-recorder 26 | ``` 27 | 28 | > The CLI will prompt you to enter the path of directory or file of the chrome devtool recordings that you will modify and path to write the generated WebdriverIO tests 29 | 30 | **⚡️ Transform individual recordings** 31 | 32 | ```sh 33 | npx @wdio/chrome-recorder 34 | ``` 35 | 36 | **⚡️ Transform multiple recordings** 37 | 38 | ```sh 39 | npx @wdio/chrome-recorder *.json 40 | ``` 41 | 42 | 👉 By default output will be written to `webdriverio` folder. If you don't have these folders, tool will create it for you or install WebdriverIO by running `npm init webdriverio` in your project. 43 | 44 | You can specify different output directory, specify that via CLI: 45 | 46 | ```sh 47 | npx @wdio/chrome-recorder --output= 48 | ``` 49 | 50 | ## ⚙️ CLI Options 51 | 52 | | Option | Description | 53 | | ------------ | ------------------------------------------------------ | 54 | | -d, --dry | Dry run the output of the transformed recordings | 55 | | -o, --output | Output location of the files generated by the exporter | 56 | 57 | ## 💻 Programmatic API 58 | 59 | ```javascript 60 | import { stringifyChromeRecording } from '@wdio/chrome-recorder'; 61 | 62 | const recordingContent = { 63 | title: 'recording', 64 | steps: [ 65 | { 66 | type: 'setViewport', 67 | width: 1905, 68 | height: 223, 69 | deviceScaleFactor: 1, 70 | isMobile: false, 71 | hasTouch: false, 72 | isLandscape: false, 73 | }, 74 | ], 75 | }; 76 | 77 | const stringifiedContent = await stringifyChromeRecording( 78 | JSON.stringify(recordingContent), 79 | ); 80 | 81 | console.log(stringifiedContent); 82 | // Console Log output 83 | // 84 | // describe('recording', function () { 85 | // it('tests recording', function (browser) { 86 | // browser.setWindowRect({ width: 1905, height: 223 }); 87 | // }); 88 | // }); 89 | ``` 90 | 91 | ## 🐛 Issues 92 | 93 | Issues with this schematic can filed [here](https://github.com/webdriverio/chrome-recorder/issues) 94 | 95 | If you want to contribute (or have contributed in the past), feel free to add yourself to the list of contributors in the package.json before you open a PR! 96 | 97 | ## 👨‍💻 Development 98 | 99 | ### Getting started 100 | 101 | 🛠️ [Node.js and npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) are required for the scripts. Make sure it's installed on your machine. 102 | 103 | ⬇️ **Install** the dependencies for the WebdriverIO chrome recorder tool 104 | 105 | ```bash 106 | npm install 107 | ``` 108 | 109 | 👷‍♂️ **Build** the tools using typescript compiler 110 | 111 | ```bash 112 | npm run build 113 | ``` 114 | 115 | 🏃 **Run** the tool 116 | 117 | ```bash 118 | ./bin/wdio-chrome-recorder.js 119 | ``` 120 | 121 | ### 🧪 Unit Testing 122 | 123 | Run the unit tests using mocha as a runner and test framework 124 | 125 | ```bash 126 | npm run test 127 | ``` 128 | 129 | ### ♻️ Clean build files 130 | 131 | ```bash 132 | npm run clean 133 | ``` 134 | 135 | ## Supported Chrome Devtools Recorder Steps 136 | 137 | WebdriverIO supports all existing [StepTypes](https://github.com/puppeteer/replay/blob/bcee5b54d94ae3fa1398c41d9166892da617eaad/docs/api/enums/Schema.StepType.md). If any step type seems to be missing, please raise an issue so we can add it. Thanks! 138 | 139 | [npm-badge]: https://img.shields.io/npm/v/@wdio/chrome-recorder.svg 140 | [npm]: https://www.npmjs.com/package/@wdio/chrome-recorder 141 | 142 | --- 143 | 144 | For more information on WebdriverIO see the [homepage](https://webdriver.io). The initial implementation was inspired by [Nightwatch Chrome Recorder](https://github.com/nightwatchjs/nightwatch-chrome-recorder) 145 | -------------------------------------------------------------------------------- /bin/wdio-chrome-recorder.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import '../dist/cli/index.js' 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@wdio/chrome-recorder", 3 | "version": "0.6.0", 4 | "description": "Generate WebdriverIO Tests from Google Chrome DevTools Recordings.", 5 | "main": "dist/index.js", 6 | "bin": "bin/wdio-chrome-recorder.js", 7 | "files": [ 8 | "bin", 9 | "dist" 10 | ], 11 | "type": "module", 12 | "scripts": { 13 | "build": "tsc -p .", 14 | "clean": "rm -rf dist", 15 | "test": "run-s test:*", 16 | "test:lint": "eslint '**/*.{js,ts}' --fix .", 17 | "test:unit": "vitest --run", 18 | "watch": "tsc -w" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/webdriverio/chrome-recorder.git" 23 | }, 24 | "keywords": [ 25 | "webdriverio", 26 | "wdio", 27 | "testing", 28 | "e2e", 29 | "automation", 30 | "devtools" 31 | ], 32 | "author": "Christian Bromann ", 33 | "license": "MIT", 34 | "bugs": { 35 | "url": "https://github.com/webdriverio/chrome-recorder/issues" 36 | }, 37 | "homepage": "https://github.com/webdriverio/chrome-recorder#readme", 38 | "devDependencies": { 39 | "@types/chai": "^4.3.14", 40 | "@types/inquirer": "^9.0.7", 41 | "@types/node": "^20.12.7", 42 | "@types/prettier": "^3.0.0", 43 | "@typescript-eslint/eslint-plugin": "^7.7.0", 44 | "@typescript-eslint/parser": "^7.7.0", 45 | "chai": "^5.1.0", 46 | "eslint": "^8.41.0", 47 | "eslint-config-prettier": "^9.1.0", 48 | "eslint-plugin-import": "^2.29.1", 49 | "eslint-plugin-prettier": "^5.1.3", 50 | "npm-run-all2": "^6.1.2", 51 | "release-it": "^17.2.0", 52 | "ts-node": "^10.9.2", 53 | "typescript": "^5.4.5", 54 | "vitest": "^2.0.3" 55 | }, 56 | "dependencies": { 57 | "@puppeteer/replay": "^3.0.0", 58 | "chalk": "^5.3.0", 59 | "globby": "^14.0.1", 60 | "inquirer": "^10.0.1", 61 | "meow": "^13.2.0", 62 | "prettier": "^3.2.5" 63 | }, 64 | "publishConfig": { 65 | "access": "public" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/cli/index.ts: -------------------------------------------------------------------------------- 1 | import url from 'node:url' 2 | import path from 'node:path' 3 | import meow from 'meow' 4 | import inquirer from 'inquirer' 5 | 6 | import { expandedFiles } from '../utils.js' 7 | import { runTransformsOnChromeRecording } from '../transform.js' 8 | import type { InquirerAnswerTypes } from '../types.js' 9 | 10 | const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) 11 | 12 | const cli = meow(` 13 | 14 | Usage 15 | $ npx wdio-chrome-recorder [options] 16 | 17 | Options 18 | 19 | -d, --dry Dry run the output of the transformed recordings 20 | -o, --output Output location of the files generated by the exporter 21 | 22 | Examples 23 | 24 | $ npx wdio-chrome-recorder recordings.json 25 | $ npx wdio-chrome-recorder recordings/*.json 26 | `, { 27 | importMeta: import.meta, 28 | flags: { 29 | dry: { 30 | type: 'boolean', 31 | }, 32 | output: { 33 | type: 'string', 34 | shortFlag: 'o', 35 | }, 36 | }, 37 | }) 38 | 39 | inquirer.prompt([ 40 | { 41 | type: 'input', 42 | name: 'files', 43 | message: 44 | 'Which directory or files should be translated from Recorder JSON to WebdriverIO?', 45 | when: () => !cli.input.length, 46 | default: path.join(__dirname, '*.json'), 47 | filter: (files: string) => files.split(/\s+/).filter((f) => f.trim().length > 0) 48 | }, 49 | { 50 | type: 'input', 51 | name: 'outputPath', 52 | message: 'Where should be exported files to be output?', 53 | when: () => !cli.input.length, 54 | default: process.cwd(), 55 | } 56 | ]).then((answers: InquirerAnswerTypes) => { 57 | const { files: recordingFiles, outputPath: outputFolder } = answers 58 | const files = cli.input.length ? cli.input : recordingFiles 59 | const filesExpanded = expandedFiles(files) 60 | 61 | if (!filesExpanded) { 62 | console.log(`No recording files found matching ${files.join(' ')}`) 63 | return null 64 | } 65 | 66 | const outputPath = cli.flags?.output?.length 67 | ? cli.flags.output 68 | : outputFolder 69 | 70 | return runTransformsOnChromeRecording({ 71 | files: filesExpanded, 72 | outputPath: outputPath ?? process.cwd(), 73 | flags: cli.flags, 74 | }) 75 | }).catch((error) => { 76 | if (error.isTtyError) { 77 | // Prompt couldn't be rendered in the current environment 78 | } else { 79 | console.error(error) 80 | } 81 | }) 82 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const SUPPORTED_KEYS = { 2 | null: '\uE000', 3 | cancel: '\uE001', 4 | help: '\uE002', 5 | back_space: '\uE003', 6 | tab: '\uE004', 7 | clear: '\uE005', 8 | return: '\uE006', 9 | enter: '\uE007', 10 | shift: '\uE008', 11 | control: '\uE009', 12 | alt: '\uE00A', 13 | pause: '\uE00B', 14 | escape: '\uE00C', 15 | space: '\uE00D', 16 | page_up: '\uE00E', 17 | page_down: '\uE00F', 18 | end: '\uE010', 19 | home: '\uE011', 20 | arrow_left: '\uE012', 21 | left: '\uE012', 22 | arrow_up: '\uE013', 23 | up: '\uE013', 24 | arrow_right: '\uE014', 25 | right: '\uE014', 26 | arrow_down: '\uE015', 27 | down: '\uE015', 28 | insert: '\uE016', 29 | delete: '\uE017', 30 | semicolon: '\uE018', 31 | equals: '\uE019', 32 | 33 | numpad0: '\uE01A', 34 | numpad1: '\uE01B', 35 | numpad2: '\uE01C', 36 | numpad3: '\uE01D', 37 | numpad4: '\uE01E', 38 | numpad5: '\uE01F', 39 | numpad6: '\uE020', 40 | numpad7: '\uE021', 41 | numpad8: '\uE022', 42 | numpad9: '\uE023', 43 | multiply: '\uE024', 44 | add: '\uE025', 45 | separator: '\uE026', 46 | subtract: '\uE027', 47 | decimal: '\uE028', 48 | divide: '\uE029', 49 | 50 | f1: '\uE031', 51 | f2: '\uE032', 52 | f3: '\uE033', 53 | f4: '\uE034', 54 | f5: '\uE035', 55 | f6: '\uE036', 56 | f7: '\uE037', 57 | f8: '\uE038', 58 | f9: '\uE039', 59 | f10: '\uE03A', 60 | f11: '\uE03B', 61 | f12: '\uE03C', 62 | 63 | command: '\uE03D', 64 | meta: '\uE03D' 65 | } as const 66 | 67 | export const KEY_NOT_SUPPORTED_ERROR = `key "%s" not supported (supported are ${Object.keys(SUPPORTED_KEYS).join(', ')}), please file an issue in https://github.com/webdriverio/chrome-recorder to let us know, so we can add support.` 68 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { parse, Schema, stringify, stringifyStep } from '@puppeteer/replay' 2 | import { StringifyExtension } from './stringifyExtension.js' 3 | 4 | export function parseRecordingContent(recordingContent: string): Schema.UserFlow { 5 | return parse(JSON.parse(recordingContent)) 6 | } 7 | 8 | export async function transformParsedRecording(parsedRecording: Schema.UserFlow) { 9 | return await stringify(parsedRecording, { 10 | extension: new StringifyExtension() 11 | }) 12 | } 13 | 14 | export async function stringifyParsedStep(step: Schema.Step): Promise { 15 | return await stringifyStep(step, { 16 | extension: new StringifyExtension(), 17 | }) 18 | } 19 | 20 | export function stringifyChromeRecording(recording: string): Promise | undefined { 21 | if (recording.length === 0) { 22 | console.log('No recording found. Please create and upload before trying again') 23 | return 24 | } 25 | 26 | const parsedRecording = parseRecordingContent(recording) 27 | return transformParsedRecording(parsedRecording) 28 | } 29 | -------------------------------------------------------------------------------- /src/stringifyExtension.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeStep, 3 | ClickStep, 4 | DoubleClickStep, 5 | EmulateNetworkConditionsStep, 6 | HoverStep, 7 | KeyDownStep, 8 | KeyUpStep, 9 | LineWriter, 10 | NavigateStep, 11 | PuppeteerStringifyExtension, 12 | ScrollStep, 13 | Selector, 14 | SetViewportStep, 15 | Step, 16 | UserFlow, 17 | WaitForElementStep, 18 | WaitForExpressionStep, 19 | StepType 20 | } from '@puppeteer/replay' 21 | import { formatAsJSLiteral, findByCondition } from './utils.js' 22 | import { SUPPORTED_KEYS, KEY_NOT_SUPPORTED_ERROR } from './constants.js' 23 | 24 | const ARIA_PREFIX = 'aria/' 25 | const XPATH_PREFIX = 'xpath/' 26 | const DEFAULT_TARGET = 'main' 27 | 28 | export class StringifyExtension extends PuppeteerStringifyExtension { 29 | #target = DEFAULT_TARGET 30 | 31 | async beforeAllSteps(out: LineWriter, flow: UserFlow): Promise { 32 | out 33 | .appendLine(`describe(${formatAsJSLiteral(flow.title)}, () => {`) 34 | .startBlock() 35 | out 36 | .appendLine(`it(${formatAsJSLiteral(`tests ${flow.title}`)}, async () => {`) 37 | .startBlock() 38 | } 39 | 40 | async afterAllSteps(out: LineWriter): Promise { 41 | out.endBlock().appendLine('});') 42 | out.endBlock().appendLine('});') 43 | } 44 | 45 | async stringifyStep( 46 | out: LineWriter, 47 | step: Step, 48 | flow: UserFlow, 49 | ): Promise { 50 | this.#appendContext(out, step) 51 | this.#appendStepType(out, step, flow) 52 | this.#appendAssertedEvents(out, step) 53 | } 54 | 55 | #appendStepType(out: LineWriter, step: Step, flow: UserFlow): void { 56 | switch (step.type) { 57 | case StepType.SetViewport: 58 | return this.#appendViewportStep(out, step) 59 | case StepType.Navigate: 60 | return this.#appendNavigateStep(out, step) 61 | case StepType.Click: 62 | return this.#appendClickStep(out, step, flow) 63 | case StepType.Change: 64 | return this.#appendChangeStep(out, step, flow) 65 | case StepType.KeyDown: 66 | return this.#appendKeyDownStep(out, step) 67 | case StepType.KeyUp: 68 | return this.#appendKeyUpStep(out, step) 69 | case StepType.Scroll: 70 | return this.#appendScrollStep(out, step, flow) 71 | case StepType.DoubleClick: 72 | return this.#appendDoubleClickStep(out, step, flow) 73 | case StepType.EmulateNetworkConditions: 74 | return this.#appendEmulateNetworkConditionsStep(out, step) 75 | case StepType.Hover: 76 | return this.#appendHoverStep(out, step, flow) 77 | case StepType.WaitForElement: 78 | return this.#appendWaitForElementStep(out, step, flow) 79 | case StepType.WaitForExpression: 80 | return this.#appendWaitExpressionStep(out, step) 81 | default: 82 | return this.logStepsNotImplemented(step) 83 | } 84 | } 85 | 86 | #appendAssertedEvents(out: LineWriter, step: Step) { 87 | if (!step.assertedEvents || step.assertedEvents.length === 0) { 88 | return 89 | } 90 | 91 | for (const event of step.assertedEvents) { 92 | switch (event.type) { 93 | case 'navigation': 94 | if (event.url) { 95 | out.appendLine(`await expect(browser).toHaveUrl(${formatAsJSLiteral(event.url)})`) 96 | } 97 | } 98 | } 99 | } 100 | 101 | #appendContext(out: LineWriter, step: Step) { 102 | if (!step.target || step.target === this.#target) { 103 | return 104 | } 105 | 106 | if (step.target === DEFAULT_TARGET) { 107 | out.appendLine('await browser.switchToParentFrame()') 108 | return 109 | } 110 | 111 | out.appendLine('await browser.switchToFrame(').startBlock() 112 | out.appendLine( `await browser.$('iframe[src="${step.target}"]')`).endBlock() 113 | out.appendLine(')') 114 | } 115 | 116 | #appendNavigateStep(out: LineWriter, step: NavigateStep): void { 117 | out.appendLine(`await browser.url(${formatAsJSLiteral(step.url)})`) 118 | } 119 | 120 | #appendViewportStep(out: LineWriter, step: SetViewportStep): void { 121 | out.appendLine(`await browser.setWindowSize(${step.width}, ${step.height})`) 122 | } 123 | 124 | #appendClickStep(out: LineWriter, step: ClickStep, flow: UserFlow): void { 125 | const domSelector = this.getSelector(step.selectors, flow) 126 | 127 | const hasRightButton = step.button && step.button === 'secondary' 128 | if (domSelector) { 129 | hasRightButton 130 | ? out.appendLine(`await browser.$(${domSelector}).click({ button: 'right' })`) 131 | : out.appendLine(`await browser.$(${domSelector}).click()`) 132 | return 133 | } 134 | console.log(`Warning: The click on ${step.selectors} was not able to export to WebdriverIO. Please adjust selectors and try again`) 135 | } 136 | 137 | #appendChangeStep(out: LineWriter, step: ChangeStep, flow: UserFlow): void { 138 | const domSelector = this.getSelector(step.selectors, flow) 139 | if (domSelector) { 140 | out.appendLine(`await browser.$(${domSelector}).setValue(${formatAsJSLiteral(step.value)})`) 141 | } 142 | } 143 | 144 | #appendKeyDownStep(out: LineWriter, step: KeyDownStep): void { 145 | const pressedKey = step.key.toLowerCase() as keyof typeof SUPPORTED_KEYS 146 | 147 | if (!SUPPORTED_KEYS[pressedKey]) { 148 | return console.error(KEY_NOT_SUPPORTED_ERROR, pressedKey) 149 | } 150 | 151 | const keyValue = SUPPORTED_KEYS[pressedKey] 152 | out.appendLine('await browser.performActions([{').startBlock() 153 | out.appendLine( 'type: \'key\',') 154 | out.appendLine( 'id: \'keyboard\',') 155 | out.appendLine( `actions: [{ type: 'keyDown', value: '${keyValue}' }]`).endBlock() 156 | out.appendLine('}])') 157 | } 158 | 159 | #appendKeyUpStep(out: LineWriter, step: KeyUpStep): void { 160 | const pressedKey = step.key.toLowerCase() as keyof typeof SUPPORTED_KEYS 161 | 162 | if (!SUPPORTED_KEYS[pressedKey]) { 163 | return console.error(KEY_NOT_SUPPORTED_ERROR, pressedKey) 164 | } 165 | 166 | const keyValue = SUPPORTED_KEYS[pressedKey] 167 | out.appendLine('await browser.performActions([{').startBlock() 168 | out.appendLine( 'type: \'key\',') 169 | out.appendLine( 'id: \'keyboard\',') 170 | out.appendLine( `actions: [{ type: 'keyUp', value: '${keyValue}' }]`).endBlock() 171 | out.appendLine('}])') 172 | } 173 | 174 | #appendScrollStep(out: LineWriter, step: ScrollStep, flow: UserFlow): void { 175 | if ('selectors' in step) { 176 | const domSelector = this.getSelector(step.selectors, flow) 177 | out.appendLine(`await browser.$(${domSelector}).moveTo()`) 178 | return 179 | } 180 | 181 | out.appendLine(`await browser.execute(() => window.scrollTo(${step.x}, ${step.y}))`) 182 | } 183 | 184 | #appendDoubleClickStep( 185 | out: LineWriter, 186 | step: DoubleClickStep, 187 | flow: UserFlow, 188 | ): void { 189 | const domSelector = this.getSelector(step.selectors, flow) 190 | 191 | if (!domSelector) { 192 | console.log( 193 | `Warning: The click on ${step.selectors} was not able to be exported to WebdriverIO. Please adjust your selectors and try again.`, 194 | ) 195 | return 196 | } 197 | 198 | out.appendLine(`await browser.$(${domSelector}).doubleClick()`) 199 | } 200 | 201 | #appendHoverStep(out: LineWriter, step: HoverStep, flow: UserFlow): void { 202 | const domSelector = this.getSelector(step.selectors, flow) 203 | 204 | if (!domSelector) { 205 | console.log( 206 | `Warning: The Hover on ${step.selectors} was not able to be exported to WebdriverIO. Please adjust your selectors and try again.`, 207 | ) 208 | return 209 | } 210 | 211 | out.appendLine(`await browser.$(${domSelector}).moveTo()`) 212 | } 213 | 214 | #appendEmulateNetworkConditionsStep( 215 | out: LineWriter, 216 | step: EmulateNetworkConditionsStep, 217 | ): void { 218 | out.appendLine('await browser.setNetworkConditions({').startBlock() 219 | out.appendLine( 'offline: false,') 220 | out.appendLine( `latency: ${step.latency},`) 221 | out.appendLine( `download_throughput: ${step.download},`) 222 | out.appendLine( `upload_throughput: ${step.upload}`).endBlock() 223 | out.appendLine('})') 224 | } 225 | 226 | #appendWaitForElementStep( 227 | out: LineWriter, 228 | step: WaitForElementStep, 229 | flow: UserFlow, 230 | ): void { 231 | const domSelector = this.getSelector(step.selectors, flow) 232 | 233 | if (!domSelector) { 234 | console.log( 235 | `Warning: The WaitForElement on ${step.selectors} was not able to be exported to WebdriverIO. Please adjust your selectors and try again.`, 236 | ) 237 | } 238 | 239 | const opts = step.timeout ? `, { timeout: ${step.timeout} }` : '' 240 | switch (step.operator) { 241 | case '<=': 242 | out.appendLine(`await expect(browser.$$(${domSelector})).toBeElementsArrayOfSize({ lte: ${step.count} }${opts})`) 243 | break 244 | case '==': 245 | out.appendLine(`await expect(browser.$$(${domSelector})).toBeElementsArrayOfSize(${step.count}${opts})`) 246 | break 247 | case '>=': 248 | out.appendLine(`await expect(browser.$$(${domSelector})).toBeElementsArrayOfSize({ gte: ${step.count} }${opts})`) 249 | break 250 | } 251 | } 252 | 253 | #appendWaitExpressionStep( 254 | out: LineWriter, 255 | step: WaitForExpressionStep 256 | ): void { 257 | out.appendLine('await browser.waitUntil(() => (').startBlock() 258 | out.appendLine( `browser.execute(() => ${step.expression})`).endBlock() 259 | out.appendLine('))') 260 | } 261 | 262 | getSelector(selectors: Selector[], flow: UserFlow): string | undefined { 263 | /** 264 | * find by id first as it is the safest selector 265 | */ 266 | const idSelector = findByCondition( 267 | selectors, 268 | (s) => s.startsWith('#') && !s.includes(' ') && !s.includes('.') && !s.includes('>') && !s.includes('[') && !s.includes('~') && !s.includes(':') 269 | ) 270 | if (idSelector) return idSelector 271 | 272 | /** 273 | * Use WebdriverIOs aria selector as second option 274 | * https://webdriver.io/docs/selectors#accessibility-name-selector 275 | */ 276 | const ariaSelector = findByCondition( 277 | selectors, 278 | (s) => s.startsWith(ARIA_PREFIX) 279 | ) 280 | if (ariaSelector) return ariaSelector 281 | 282 | // Remove Aria selectors 283 | const nonAriaSelectors = this.filterArrayByString(selectors, ARIA_PREFIX) 284 | 285 | /** 286 | * use xPath selector if aria selector is not available 287 | */ 288 | const xPathSelector = findByCondition( 289 | selectors, 290 | (s) => s.startsWith(XPATH_PREFIX) 291 | ) 292 | if (xPathSelector) return `"${xPathSelector.slice(XPATH_PREFIX.length + 1)}` 293 | 294 | let preferredSelector 295 | 296 | // Give preference to user selector 297 | if (flow.selectorAttribute) { 298 | preferredSelector = this.filterArrayByString( 299 | nonAriaSelectors, 300 | flow.selectorAttribute, 301 | ) 302 | } 303 | 304 | if (preferredSelector && preferredSelector[0]) { 305 | return formatAsJSLiteral( 306 | Array.isArray(preferredSelector[0]) 307 | ? preferredSelector[0][0] 308 | : preferredSelector[0], 309 | ) 310 | } 311 | 312 | return formatAsJSLiteral( 313 | Array.isArray(nonAriaSelectors[0]) 314 | ? nonAriaSelectors[0][0] 315 | : nonAriaSelectors[0], 316 | ) 317 | } 318 | 319 | filterArrayByString(selectors: Selector[], filterValue: string): Selector[] { 320 | return selectors.filter((selector) => 321 | filterValue === 'aria/' 322 | ? !selector[0].includes(filterValue) 323 | : selector[0].includes(filterValue), 324 | ) 325 | } 326 | 327 | logStepsNotImplemented(step: Step): void { 328 | console.log( 329 | `Warning: WebdriverIO Chrome Recorder does not handle migration of types ${step.type}.`, 330 | ) 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /src/transform.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import fs from 'node:fs/promises' 3 | import { constants } from 'node:fs' 4 | import { format } from 'prettier' 5 | import chalk from 'chalk' 6 | 7 | import { stringifyChromeRecording } from './index.js' 8 | import { ExportToFile, TransformOpts } from './types.js' 9 | 10 | const __dirname = path.resolve(path.dirname('.')) 11 | 12 | export function formatParsedRecordingContent( 13 | transformedRecordingContent: string, 14 | ): Promise { 15 | return format(transformedRecordingContent, { 16 | semi: true, 17 | singleQuote: true, 18 | parser: 'babel', 19 | }) 20 | } 21 | 22 | export async function runTransformsOnChromeRecording({ files, outputPath, flags }: TransformOpts) { 23 | const outputFolder = path.resolve(__dirname, outputPath) 24 | const { dry } = flags 25 | 26 | return files.map(async (file) => { 27 | console.log( 28 | chalk.green(`🤖 Running WebdriverIO Chrome Recorder on ${file}\n`), 29 | ) 30 | 31 | const recordingContent = await fs.readFile(file, 'utf-8') 32 | const stringifiedFile = await stringifyChromeRecording(recordingContent) 33 | 34 | if (!stringifiedFile) { 35 | return 36 | } 37 | 38 | const formattedStringifiedFile = await formatParsedRecordingContent(stringifiedFile) 39 | const fileName = file.split('/').pop() 40 | const testName = fileName ? fileName.replace('.json', '') : undefined 41 | 42 | if (dry) { 43 | return console.log(formattedStringifiedFile) 44 | } 45 | 46 | if (!testName) { 47 | return chalk.red('Please try again. Now file or folder found') 48 | } 49 | 50 | exportFileToFolder({ 51 | stringifiedFile: formattedStringifiedFile, 52 | testName, 53 | outputPath, 54 | outputFolder, 55 | }) 56 | }) 57 | } 58 | 59 | async function exportFileToFolder({ stringifiedFile, testName, outputPath, outputFolder }: ExportToFile): Promise { 60 | const folderPath = path.join('.', outputPath) 61 | const folderExists = await fs.access(folderPath, constants.F_OK).then( 62 | () => true, 63 | () => false 64 | ) 65 | if (!folderExists) { 66 | return fs.mkdir(path.join('.', outputPath), { 67 | recursive: true, 68 | }).then(() => { 69 | return exportFileToFolder({ 70 | stringifiedFile, 71 | testName, 72 | outputFolder, 73 | outputPath, 74 | }) 75 | }, (err: Error) => { 76 | console.error( 77 | `😭 Something went wrong while creating ${outputPath}\n Stacktrace: ${err?.stack}`, 78 | ) 79 | }) 80 | } 81 | 82 | return fs.writeFile( 83 | path.join(outputFolder, `${testName}.js`), 84 | stringifiedFile 85 | ).then(() => { 86 | console.log( 87 | chalk.green( 88 | `✅ ${testName}.json exported to ${outputPath}/${testName}.js\n `, 89 | ), 90 | ) 91 | }, () => { 92 | console.log( 93 | chalk.red( 94 | `😭 Something went wrong exporting ${outputPath}/${testName}.js \n`, 95 | ), 96 | ) 97 | }) 98 | } 99 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface InquirerAnswerTypes { 2 | files: string[]; 3 | outputPath: string; 4 | } 5 | 6 | export type Flags = { 7 | dry?: boolean; 8 | output?: string; 9 | }; 10 | 11 | export type ExportToFile = { 12 | stringifiedFile: string; 13 | testName: string; 14 | outputPath: string; 15 | outputFolder: string; 16 | }; 17 | 18 | export interface TransformOpts { 19 | files: string[]; 20 | outputPath: string; 21 | flags: Flags; 22 | } 23 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { globbySync } from 'globby' 2 | import type { Selector } from '@puppeteer/replay' 3 | 4 | export function expandedFiles(files: string[]): string[] { 5 | const containsGlob = files.some((file: string) => file.includes('*')) 6 | return containsGlob ? globbySync(files) : files 7 | } 8 | 9 | export function formatAsJSLiteral(value: string) { 10 | return JSON.stringify(value) 11 | } 12 | 13 | export function findByCondition( 14 | selectors: Selector[], 15 | condition: (selector: string) => boolean 16 | ) { 17 | const ariaSelector = selectors.find((selector) => Array.isArray(selector) 18 | ? selector.find(condition) 19 | : condition(selector) ?? selector 20 | ) 21 | if (ariaSelector) { 22 | return formatAsJSLiteral(Array.isArray(ariaSelector) 23 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 24 | ? ariaSelector.find(condition)! 25 | : ariaSelector 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/InMemoryLineWriter.ts: -------------------------------------------------------------------------------- 1 | import { LineWriter } from '@puppeteer/replay' 2 | 3 | export class InMemoryLineWriter implements LineWriter { 4 | #indentation: string 5 | #currentIndentation = 0 6 | #lines: string[] = [] 7 | 8 | constructor(indentation: string) { 9 | this.#indentation = indentation 10 | } 11 | 12 | appendLine(line: string): LineWriter { 13 | const indentedLine = line 14 | ? this.#indentation.repeat(this.#currentIndentation) + line.trimEnd() 15 | : '' 16 | this.#lines.push(indentedLine) 17 | return this 18 | } 19 | 20 | startBlock(): LineWriter { 21 | this.#currentIndentation++ 22 | return this 23 | } 24 | 25 | endBlock(): LineWriter { 26 | this.#currentIndentation-- 27 | return this 28 | } 29 | 30 | toString(): string { 31 | // Scripts should end with a final blank line. 32 | return this.#lines.join('\n') + '\n' 33 | } 34 | 35 | getIndent(): string { 36 | return this.#indentation 37 | } 38 | 39 | getLines(): string[] { 40 | return this.#lines 41 | } 42 | 43 | getSize(): number { 44 | return this.#lines.length 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/stringifyExtension.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { Key, UserFlow, StepType } from '@puppeteer/replay' 3 | 4 | import { InMemoryLineWriter } from './InMemoryLineWriter.js' 5 | import { StringifyExtension } from '../src/stringifyExtension.js' 6 | 7 | 8 | describe('StringifyExtension', () => { 9 | it('creates async tests', async () => { 10 | const ext = new StringifyExtension() 11 | const writer = new InMemoryLineWriter(' ') 12 | await ext.beforeAllSteps(writer, { title: 'foobar' } as UserFlow) 13 | expect(writer.toString()).toContain('it("tests foobar", async () => {') 14 | }) 15 | 16 | it('should correctly exports setViewport step', async () => { 17 | const ext = new StringifyExtension() 18 | const step = { 19 | type: StepType.SetViewport as const, 20 | width: 1905, 21 | height: 223, 22 | deviceScaleFactor: 1, 23 | isMobile: false, 24 | hasTouch: false, 25 | isLandscape: false, 26 | } 27 | const flow = { title: 'setViewport step', steps: [step] } 28 | const writer = new InMemoryLineWriter(' ') 29 | await ext.stringifyStep(writer, step, flow) 30 | expect(writer.toString()).toBe('await browser.setWindowSize(1905, 223)\n') 31 | }) 32 | 33 | it('should correctly exports navigate step', async () => { 34 | const ext = new StringifyExtension() 35 | const step = { 36 | type: StepType.Navigate as const, 37 | url: 'chrome://new-tab-page/', 38 | } 39 | const flow = { title: 'navigate step', steps: [step] } 40 | const writer = new InMemoryLineWriter(' ') 41 | await ext.stringifyStep(writer, step, flow) 42 | expect(writer.toString()).toBe('await browser.url("chrome://new-tab-page/")\n') 43 | }) 44 | 45 | it('should correctly exports click step', async () => { 46 | const ext = new StringifyExtension() 47 | const step = { 48 | type: StepType.Click as const, 49 | target: 'main', 50 | selectors: ['#test'], 51 | offsetX: 1, 52 | offsetY: 1, 53 | } 54 | const flow = { title: 'click step', steps: [step] } 55 | const writer = new InMemoryLineWriter(' ') 56 | await ext.stringifyStep(writer, step, flow) 57 | expect(writer.toString()).toBe('await browser.$("#test").click()\n') 58 | }) 59 | 60 | it('should correctly exports change step', async () => { 61 | const ext = new StringifyExtension() 62 | const step = { 63 | type: StepType.WaitForExpression, 64 | expression: 'document.querySelector(\'#someElem\').innerText === \' x 2\'' 65 | } 66 | const flow = { title: 'change step', steps: [step] } 67 | const writer = new InMemoryLineWriter(' ') 68 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 69 | await ext.stringifyStep(writer, step as any, flow as any) 70 | expect(writer.toString()).toBe( 71 | 'await browser.waitUntil(() => (\n' + 72 | ' browser.execute(() => document.querySelector(\'#someElem\').innerText === \' x 2\')\n' + 73 | '))\n' 74 | ) 75 | }) 76 | 77 | it('supports waitForExpression', async () => { 78 | const ext = new StringifyExtension() 79 | const step = { 80 | type: StepType.Change as const, 81 | value: 'webdriverio', 82 | selectors: [['aria/Search'], ['#heading']], 83 | target: 'main', 84 | } 85 | const flow = { title: 'change step', steps: [step] } 86 | const writer = new InMemoryLineWriter(' ') 87 | await ext.stringifyStep(writer, step, flow) 88 | expect(writer.toString()).toBe('await browser.$("#heading").setValue("webdriverio")\n') 89 | }) 90 | 91 | it('should prefer aria selector over xpath', async () => { 92 | const ext = new StringifyExtension() 93 | const step = { 94 | type: StepType.Change as const, 95 | value: 'webdriverio', 96 | selectors: [['aria/Search'], ['xpath///*[@data-test="heading"]']], 97 | target: 'main', 98 | } 99 | const flow = { title: 'change step', steps: [step] } 100 | const writer = new InMemoryLineWriter(' ') 101 | await ext.stringifyStep(writer, step, flow) 102 | expect(writer.toString()).toBe('await browser.$("aria/Search").setValue("webdriverio")\n') 103 | }) 104 | 105 | it('should prefer link text selectors', async () => { 106 | const ext = new StringifyExtension() 107 | const step = { 108 | type: StepType.Change as const, 109 | value: 'webdriverio', 110 | selectors: [[ 111 | 'aria/Guides' 112 | ], [ 113 | '#__docusaurus > div.main-wrapper.docs-wrapper.docs-doc-page > div > aside > div > nav > ul > li:nth-child(4) > div > a' 114 | ]], 115 | target: 'main', 116 | } 117 | const flow = { title: 'change step', steps: [step] } 118 | const writer = new InMemoryLineWriter(' ') 119 | await ext.stringifyStep(writer, step, flow) 120 | expect(writer.toString()).toBe('await browser.$("aria/Guides").setValue("webdriverio")\n') 121 | }) 122 | 123 | it('should fetch by text', async () => { 124 | const ext = new StringifyExtension() 125 | const step = { 126 | type: StepType.Change as const, 127 | value: 'webdriverio', 128 | selectors: [[ 129 | 'aria/Flat White $18.00' 130 | ], [ 131 | '#app > div:nth-child(4) > ul > li:nth-child(5) > h4' 132 | ]], 133 | target: 'main', 134 | } 135 | const flow = { title: 'change step', steps: [step] } 136 | const writer = new InMemoryLineWriter(' ') 137 | await ext.stringifyStep(writer, step, flow) 138 | expect(writer.toString()).toBe('await browser.$("aria/Flat White $18.00").setValue("webdriverio")\n') 139 | }) 140 | 141 | it('should fetch by text with pseudo selector', async () => { 142 | const ext = new StringifyExtension() 143 | const step = { 144 | type: StepType.Change as const, 145 | value: 'webdriverio', 146 | selectors: [[ 147 | 'aria/Yes' 148 | ], [ 149 | '[data-cy=add-to-cart-modal] > form > button:nth-child(1)' 150 | ]], 151 | target: 'main', 152 | } 153 | const flow = { title: 'change step', steps: [step] } 154 | const writer = new InMemoryLineWriter(' ') 155 | await ext.stringifyStep(writer, step, flow) 156 | expect(writer.toString()).toBe('await browser.$("aria/Yes").setValue("webdriverio")\n') 157 | }) 158 | 159 | it('should correctly exports keyDown step', async () => { 160 | const ext = new StringifyExtension() 161 | const step = { 162 | type: StepType.KeyDown as const, 163 | target: 'main', 164 | key: 'Enter' as Key, 165 | } 166 | const flow = { title: 'keyDown step', steps: [step] } 167 | const writer = new InMemoryLineWriter(' ') 168 | await ext.stringifyStep(writer, step, flow) 169 | expect(writer.toString()).toBe( 170 | 'await browser.performActions([{\n' + 171 | ' type: \'key\',\n' + 172 | ' id: \'keyboard\',\n' + 173 | ' actions: [{ type: \'keyDown\', value: \'\uE007\' }]\n' + 174 | '}])\n' 175 | ) 176 | }) 177 | 178 | it('should handle keyDown step when key is not supported', async () => { 179 | const ext = new StringifyExtension() 180 | const step = { 181 | type: StepType.KeyDown as const, 182 | target: 'main', 183 | key: 'KEY_DOESNT_EXIST' as Key, 184 | } 185 | const flow = { title: 'keyDown step', steps: [step] } 186 | const writer = new InMemoryLineWriter(' ') 187 | await ext.stringifyStep(writer, step, flow) 188 | expect(writer.toString()).toBe('\n') 189 | }) 190 | 191 | it('should correctly exports keyUp step', async () => { 192 | const ext = new StringifyExtension() 193 | const step = { 194 | type: StepType.KeyUp as const, 195 | target: 'main', 196 | key: 'Enter' as Key 197 | } 198 | const flow = { title: 'keyUp step', steps: [step] } 199 | const writer = new InMemoryLineWriter(' ') 200 | await ext.stringifyStep(writer, step, flow) 201 | expect(writer.toString()).toBe( 202 | 'await browser.performActions([{\n' + 203 | ' type: \'key\',\n' + 204 | ' id: \'keyboard\',\n' + 205 | ' actions: [{ type: \'keyUp\', value: \'\uE007\' }]\n' + 206 | '}])\n' 207 | ) 208 | }) 209 | 210 | it('should correctly exports scroll step', async () => { 211 | const ext = new StringifyExtension() 212 | const step = { 213 | type: StepType.Scroll as const, 214 | target: 'main', 215 | x: 0, 216 | y: 805, 217 | } 218 | const flow = { title: 'scroll step', steps: [step] } 219 | const writer = new InMemoryLineWriter(' ') 220 | await ext.stringifyStep(writer, step, flow) 221 | expect(writer.toString()).toBe('await browser.execute(() => window.scrollTo(0, 805))\n') 222 | }) 223 | 224 | it('should correctly exports doubleClick step', async () => { 225 | const ext = new StringifyExtension() 226 | const step = { 227 | type: StepType.DoubleClick as const, 228 | target: 'main', 229 | selectors: [['aria/Test'], ['#test']], 230 | offsetX: 1, 231 | offsetY: 1, 232 | } 233 | const flow = { title: 'doubleClick step', steps: [step] } 234 | const writer = new InMemoryLineWriter(' ') 235 | await ext.stringifyStep(writer, step, flow) 236 | expect(writer.toString()).toBe('await browser.$("#test").doubleClick()\n') 237 | }) 238 | 239 | it('should correctly exports emulateNetworkConditions step', async () => { 240 | const ext = new StringifyExtension() 241 | const step = { 242 | type: StepType.EmulateNetworkConditions as const, 243 | download: 50000, 244 | upload: 50000, 245 | latency: 2000, 246 | } 247 | const flow = { title: 'emulateNetworkConditions step', steps: [step] } 248 | const writer = new InMemoryLineWriter(' ') 249 | await ext.stringifyStep(writer, step, flow) 250 | expect(writer.toString()).toBe( 251 | 'await browser.setNetworkConditions({\n' + 252 | ' offline: false,\n' + 253 | ' latency: 2000,\n' + 254 | ' download_throughput: 50000,\n' + 255 | ' upload_throughput: 50000\n' + 256 | '})\n' 257 | ) 258 | }) 259 | 260 | it('should correctly exports waitForElement step if operator is "=="', async () => { 261 | const ext = new StringifyExtension() 262 | const step = { 263 | type: StepType.WaitForElement as const, 264 | selectors: ['#test'], 265 | operator: '==' as const, 266 | count: 2, 267 | } 268 | const flow = { title: 'waitForElement step', steps: [step] } 269 | const writer = new InMemoryLineWriter(' ') 270 | await ext.stringifyStep(writer, step, flow) 271 | expect(writer.toString()).toBe( 272 | 'await expect(browser.$$("#test")).toBeElementsArrayOfSize(2)\n' 273 | ) 274 | }) 275 | 276 | it('should correctly exports waitForElement step with timeout', async () => { 277 | const ext = new StringifyExtension() 278 | const step = { 279 | type: StepType.WaitForElement as const, 280 | selectors: ['#test'], 281 | operator: '==' as const, 282 | count: 2, 283 | timeout: 2000, 284 | } 285 | const flow = { title: 'waitForElement step', steps: [step] } 286 | const writer = new InMemoryLineWriter(' ') 287 | await ext.stringifyStep(writer, step, flow) 288 | expect(writer.toString()).toBe( 289 | 'await expect(browser.$$("#test")).toBeElementsArrayOfSize(2, { timeout: 2000 })\n' 290 | ) 291 | }) 292 | 293 | it('should correctly exports waitForElement step if operator is "<="', async () => { 294 | const ext = new StringifyExtension() 295 | const step = { 296 | type: StepType.WaitForElement as const, 297 | selectors: ['#test'], 298 | operator: '<=' as const, 299 | count: 2, 300 | } 301 | const flow = { title: 'waitForElement step', steps: [step] } 302 | const writer = new InMemoryLineWriter(' ') 303 | await ext.stringifyStep(writer, step, flow) 304 | expect(writer.toString()).toBe( 305 | 'await expect(browser.$$("#test")).toBeElementsArrayOfSize({ lte: 2 })\n' 306 | ) 307 | }) 308 | 309 | it('should correctly exports waitForElement step if operator is ">="', async () => { 310 | const ext = new StringifyExtension() 311 | const step = { 312 | type: StepType.WaitForElement as const, 313 | selectors: ['#test'], 314 | operator: '>=' as const, 315 | count: 2, 316 | } 317 | const flow = { title: 'waitForElement step', steps: [step] } 318 | const writer = new InMemoryLineWriter(' ') 319 | await ext.stringifyStep(writer, step, flow) 320 | expect(writer.toString()).toBe( 321 | 'await expect(browser.$$("#test")).toBeElementsArrayOfSize({ gte: 2 })\n' 322 | ) 323 | }) 324 | 325 | it('should correctly add Hover Step', async () => { 326 | const ext = new StringifyExtension() 327 | const step = { 328 | type: StepType.Hover as const, 329 | selectors: ['#test'], 330 | } 331 | const flow = { title: 'Hover step', steps: [step] } 332 | const writer = new InMemoryLineWriter(' ') 333 | await ext.stringifyStep(writer, step, flow) 334 | expect(writer.toString()).to.equal('await browser.$("#test").moveTo()\n') 335 | }) 336 | 337 | it('should correctly assert event after step', async () => { 338 | const ext = new StringifyExtension() 339 | const step = { 340 | type: StepType.Hover as const, 341 | selectors: ['#test'], 342 | assertedEvents: [{ 343 | type: 'navigation' as const, 344 | url: 'https://webdriver.io', 345 | title: '' 346 | }] 347 | } 348 | const flow = { title: 'Hover step', steps: [step] } 349 | const writer = new InMemoryLineWriter(' ') 350 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 351 | await ext.stringifyStep(writer, step as any, flow as any) 352 | expect(writer.toString()).to.equal( 353 | 'await browser.$("#test").moveTo()\n' + 354 | 'await expect(browser).toHaveUrl("https://webdriver.io")\n' 355 | ) 356 | }) 357 | 358 | it('switches target if needed', async () => { 359 | const ext = new StringifyExtension() 360 | const stepA = { 361 | type: StepType.Click as const, 362 | target: 'main', 363 | selectors: ['#test'], 364 | offsetX: 1, 365 | offsetY: 1, 366 | } 367 | const stepB = { 368 | type: StepType.Click as const, 369 | target: 'https://webdriver.io', 370 | selectors: ['#test'], 371 | offsetX: 1, 372 | offsetY: 1, 373 | } 374 | const flow = { title: 'Hover step', steps: [stepA, stepB] } 375 | const writer = new InMemoryLineWriter(' ') 376 | await ext.stringifyStep(writer, stepA, flow) 377 | await ext.stringifyStep(writer, stepB, flow) 378 | expect(writer.toString()).to.equal( 379 | 'await browser.$("#test").click()\n' + 380 | 'await browser.switchToFrame(\n' + 381 | ' await browser.$(\'iframe[src="https://webdriver.io"]\')\n' + 382 | ')\n' + 383 | 'await browser.$("#test").click()\n' 384 | ) 385 | }) 386 | }) 387 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "NodeNext", 4 | "target": "ES2020", 5 | "moduleResolution": "Node16", 6 | "declaration": true, 7 | "outDir": "dist", 8 | "stripInternal": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "strict": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true 13 | }, 14 | "include": ["src/**/*"], 15 | "exclude": ["node_modules", "dist", "src/**/__tests__/*"] 16 | } 17 | -------------------------------------------------------------------------------- /vitest.conf.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from 'vite' 3 | 4 | export default defineConfig({ 5 | test: { 6 | include: ['test/**/*.test.ts'], 7 | /** 8 | * not to ESM ported packages 9 | */ 10 | exclude: [ 11 | 'dist', '.idea', '.git', '.cache', 12 | '**/node_modules/**' 13 | ], 14 | coverage: { 15 | enabled: true, 16 | exclude: ['**/build/**', '**/__fixtures__/**', '**/*.test.ts'], 17 | thresholds: { 18 | lines: 100, 19 | functions: 100, 20 | branches: 100, 21 | statements: 100 22 | } 23 | } 24 | } 25 | }) 26 | --------------------------------------------------------------------------------