├── .changeset ├── README.md ├── config.json └── dull-knives-develop.md ├── .github ├── CODEOWNERS └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── logo ├── dark.svg └── light.svg ├── package-lock.json ├── package.json ├── scripts └── bundle.sh ├── src ├── index.ts ├── integrations │ └── ai-sdk.ts └── recorder │ ├── call-metadata.ts │ ├── crypto.ts │ ├── css-utils.ts │ ├── generator.ts │ ├── javascript-generator.ts │ ├── language.ts │ ├── recorder-types.ts │ ├── recorder.ts │ ├── time.ts │ ├── timeout-runner.ts │ ├── utils.ts │ └── utils │ └── isomorphic │ ├── css-parser.ts │ ├── css-tokenizer.ts │ ├── locator-generators.ts │ ├── locator-parser.ts │ ├── locator-utils.ts │ ├── selector-parser.ts │ └── string-utils.ts ├── test └── lib.test.ts ├── tsconfig.browser.json └── tsconfig.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.2/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.changeset/dull-knives-develop.md: -------------------------------------------------------------------------------- 1 | --- 2 | '@browserbasehq/sdk': patch 3 | --- 4 | 5 | Deprecate the current SDK in favor of v2.0.0 6 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @browserbase/eng 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [18.x, 20.x, 22.x] 17 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | cache: 'npm' 26 | - run: npm ci 27 | - run: npm run build 28 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write # This is required to push the release commit 16 | pull-requests: write # This is required to create the release pull request 17 | steps: 18 | - name: Checkout Repo 19 | uses: actions/checkout@v3 20 | 21 | - name: Setup Node.js 20 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: 20 25 | 26 | - name: Install Dependencies 27 | run: npm install 28 | 29 | - name: Create Release Pull Request 30 | uses: changesets/action@v1 31 | with: 32 | publish: npm run build 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !dist/**/* 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "singleQuote": true, 4 | "semi": false 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @browserbasehq/sdk 2 | 3 | ## 1.5.0 4 | 5 | ### Minor Changes 6 | 7 | - ee75dc0: Use new API URL 8 | 9 | ## 1.4.4 10 | 11 | ### Patch Changes 12 | 13 | - 86f0a90: Fix type for custom proxies 14 | 15 | ## 1.4.3 16 | 17 | ### Patch Changes 18 | 19 | - 507f453: Provide additional types 20 | - 123c9c2: Preview release of the new Browserbase Codegen feature. 21 | 22 | ## 1.4.3-next.0 23 | 24 | ### Patch Changes 25 | 26 | - 8b58c10: Preview release of the new Browserbase Codegen feature. 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Browserbase Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DEPRECATED. Please refer to v2.0.0 at [browserbase/sdk-node](https://github.com/browserbase/sdk-node) 2 | 3 |

4 | 5 | 6 | Browserbase logo 7 | 8 |

9 | 10 |

11 | Documentation 12 |  ·  13 | Playground 14 |

15 |
16 | 17 | ## Browserbase JS SDK 18 | 19 | Browserbase is the best developer platform to reliably run, manage, and monitor headless browsers. 20 | Leverage Browserbase to power your automation, test suites, and LLM data retrievals. 21 | 22 | ## Installation and setup 23 | 24 | ### 1. Install the SDK: 25 | 26 | ```bash 27 | npm install @browserbasehq/sdk 28 | ``` 29 | 30 | ### 2. Get your Browserbase API Key and Project ID: 31 | 32 | - [Create an account](https://www.browserbase.com/sign-up) or [log in to Browserbase](https://www.browserbase.com/sign-in) 33 | - Copy your API Key and Project ID [from your Settings page](https://www.browserbase.com/settings) 34 | 35 | ## Usage 36 | 37 | ```js 38 | import { Browserbase } from '@browserbasehq/sdk' 39 | 40 | // Init the SDK 41 | const browserbase = new Browserbase() 42 | 43 | // Load a webpage 44 | const rawHtml = await browserbase.load('https://www.browserbase.com') 45 | 46 | // Load multiple webpages (returns iterator) 47 | const rawHtmls = browserbase.loadURLs([ 48 | 'https://www.browserbase.com', 49 | 'https://docs.browserbase.com', 50 | ]) 51 | 52 | for await (let rawHtml of rawHtmls) { 53 | // ... 54 | } 55 | 56 | // Text-only mode 57 | const text = await browserbase.load('https://www.browserbase.com', { 58 | textContent: true, 59 | }) 60 | 61 | // Screenshot (returns bytes) 62 | const result = await browserbase.screenshot('https://www.browserbase.com', { 63 | textContent: true, 64 | }) 65 | ``` 66 | 67 | ### Vercel AI SDK Integration 68 | 69 | Install the additional dependencies: 70 | 71 | ``` 72 | npm install ai openai zod 73 | ``` 74 | 75 | ```js 76 | import OpenAI from 'openai' 77 | import { Browserbase, BrowserbaseAISDK } from '@browserbasehq/sdk' 78 | import { 79 | OpenAIStream, 80 | StreamingTextResponse, 81 | generateText, 82 | } from 'ai' 83 | 84 | // Create new OpenAI client 85 | const openai = new OpenAI({ 86 | apiKey: process.env.OPENAI_API_KEY, 87 | }) 88 | 89 | // Init the Browserbase SDK 90 | const browserbase = new Browserbase() 91 | 92 | // Init the tool 93 | const browserTool = BrowserbaseAISDK(browserbase, { textContent: true }) 94 | 95 | // Load completions 96 | const result = await generateText({ 97 | model: openai.chat('gpt-3.5-turbo'), 98 | tools: { 99 | browserTool, 100 | }, 101 | prompt: 'What is the weather in San Francisco?', 102 | }) 103 | ``` 104 | 105 | ## Further reading 106 | 107 | - [See how to leverage the Session Live View for faster development](https://docs.browserbase.com/features/session-live-view) 108 | - [Sessions API Reference](https://docs.browserbase.com/reference/api/create-a-session) 109 | -------------------------------------------------------------------------------- /logo/dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /logo/light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@browserbasehq/sdk", 3 | "version": "1.5.0", 4 | "description": "Browserbase JS SDK", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "build": "tsc -p tsconfig.json", 9 | "build:browser": "tsc -p tsconfig.browser.json", 10 | "test": "tsx --test test/*" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/browserbase/js-sdk.git" 15 | }, 16 | "keywords": [ 17 | "automation", 18 | "testing", 19 | "playwright" 20 | ], 21 | "author": "Browserbase ", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/browserbase/js-sdk/issues" 25 | }, 26 | "homepage": "https://github.com/browserbase/js-sdk#readme", 27 | "dependencies": { 28 | "playwright-core": "^1.45.1", 29 | "puppeteer-core": "^22.10.0", 30 | "zod": "^3.22.5" 31 | }, 32 | "devDependencies": { 33 | "@changesets/cli": "^2.27.9", 34 | "@types/node": "^20.14.10", 35 | "browserify": "^17.0.0", 36 | "chai": "^5.1.0", 37 | "tsx": "^4.7.2", 38 | "typescript": "^5.5.2" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /scripts/bundle.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # NOTE: This script is used to bundle the recorder.js file for the browser. 4 | # It is highly experimental and may not work as expected. Use at your own risk. 5 | 6 | npm run build:browser 7 | 8 | browserify dist/browser/tsc/recorder.js -o dist/browser/bundle.js 9 | 10 | # Use sed to insert the new string after the line containing the search string 11 | sed -i '' "/exports.BrowserbaseCodeGenerator = BrowserbaseCodeGenerator;/a\\ 12 | window.BrowserbaseCodeGenerator = BrowserbaseCodeGenerator; 13 | " dist/browser/bundle.js 14 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import puppeteer from 'puppeteer-core' 2 | import BrowserbaseAISDK from './integrations/ai-sdk.js' 3 | import { 4 | BrowserbaseCodeGenerator, 5 | type GeneratorOptions, 6 | type ActionsEvent, 7 | } from './recorder/recorder' 8 | 9 | export type ClientOptions = { 10 | apiKey?: string 11 | projectId?: string 12 | baseURL?: string 13 | baseConnectURL?: string 14 | } 15 | 16 | export type ConnectOptions = { 17 | sessionId?: string 18 | proxy?: boolean 19 | } 20 | 21 | export type LoadOptions = { 22 | textContent?: boolean 23 | } & ConnectOptions 24 | 25 | export type ScreenshotOptions = { 26 | fullPage?: boolean 27 | } & ConnectOptions 28 | 29 | export type CreateSessionOptions = { 30 | projectId?: string 31 | extensionId?: string 32 | browserSettings?: { 33 | fingerprint?: { 34 | browserListQuery?: string 35 | httpVersion?: 1 | 2 36 | browsers?: Array<'chrome' | 'firefox' | 'edge' | 'safari'> 37 | devices?: Array<'desktop' | 'mobile'> 38 | locales?: string[] 39 | operatingSystems?: Array< 40 | 'windows' | 'macos' | 'linux' | 'ios' | 'android' 41 | > 42 | screen?: { 43 | maxHeight?: number 44 | maxWidth?: number 45 | minHeight?: number 46 | minWidth?: number 47 | } 48 | } 49 | viewport?: { 50 | width?: number 51 | height?: number 52 | } 53 | context?: { 54 | id: string 55 | persist: boolean 56 | } 57 | blockAds?: boolean 58 | solveCaptchas?: boolean 59 | recordSession?: boolean 60 | logSession?: boolean 61 | } 62 | keepAlive?: boolean 63 | // duration in seconds. Minimum 60 (1 minute), maximum 21600 (6 hours) 64 | timeout?: number 65 | proxies?: 66 | | boolean 67 | | Array< 68 | | { 69 | type: 'browserbase' 70 | geolocation?: { country: string; state?: string; city?: string } 71 | domainPattern?: string 72 | } 73 | | { 74 | type: 'external' 75 | server: string 76 | domainPattern?: string 77 | username?: string 78 | password?: string 79 | } 80 | > 81 | } 82 | 83 | export type Session = { 84 | id: string 85 | createdAt: string 86 | startedAt: string 87 | endedAt?: string 88 | expiresAt: string 89 | projectId: string 90 | status: 'RUNNING' | 'COMPLETED' | 'ERROR' | 'TIMED_OUT' 91 | proxyBytes?: number 92 | keepAlive: boolean 93 | contextId?: string 94 | } 95 | 96 | export type SessionRecording = { 97 | type?: number 98 | time?: string 99 | data?: any 100 | } 101 | 102 | export type DebugConnectionURLs = { 103 | debuggerFullscreenUrl?: string 104 | debuggerUrl?: string 105 | wsUrl?: string 106 | pages?: DebugConnectionPage 107 | } 108 | 109 | export type DebugConnectionPage = { 110 | id: string 111 | url: string 112 | title: string 113 | debuggerUrl: string 114 | debuggerFullscreenUrl: string 115 | } 116 | 117 | export type SessionLog = { 118 | sessionId?: string 119 | id: string 120 | timestamp?: string 121 | method?: string 122 | request?: { 123 | timestamp?: string 124 | params?: any 125 | rawBody?: string 126 | } 127 | response?: { 128 | timestamp?: string 129 | result?: any 130 | rawBody?: string 131 | } 132 | pageId?: string 133 | } 134 | 135 | export default class Browserbase { 136 | private apiKey: string 137 | private projectId: string 138 | private baseAPIURL: string 139 | private baseConnectURL: string 140 | 141 | constructor(options: ClientOptions = {}) { 142 | this.apiKey = options.apiKey || process.env.BROWSERBASE_API_KEY! 143 | this.projectId = options.projectId || process.env.BROWSERBASE_PROJECT_ID! 144 | this.baseAPIURL = options.baseURL || 'https://api.browserbase.com' 145 | this.baseConnectURL = 146 | options.baseConnectURL || 'wss://connect.browserbase.com' 147 | } 148 | 149 | getConnectURL({ sessionId, proxy = false }: ConnectOptions = {}): string { 150 | return `${this.baseConnectURL}?apiKey=${this.apiKey}${sessionId ? `&sessionId=${sessionId}` : '' 151 | }${proxy ? `&enableProxy=true` : ''}` 152 | } 153 | 154 | async listSessions(): Promise { 155 | const response = await fetch(`${this.baseAPIURL}/v1/sessions`, { 156 | headers: { 157 | 'x-bb-api-key': this.apiKey, 158 | 'Content-Type': 'application/json', 159 | }, 160 | }) 161 | 162 | return await response.json() 163 | } 164 | 165 | async createSession(options?: CreateSessionOptions): Promise { 166 | const mergedOptions = { projectId: this.projectId, ...options } 167 | 168 | if (!mergedOptions.projectId) { 169 | throw new Error( 170 | 'a projectId is missing: use the options.projectId or BROWSERBASE_PROJECT_ID environment variable to set one.' 171 | ) 172 | } 173 | 174 | const response = await fetch(`${this.baseAPIURL}/v1/sessions`, { 175 | method: 'POST', 176 | headers: { 177 | 'x-bb-api-key': this.apiKey, 178 | 'Content-Type': 'application/json', 179 | }, 180 | body: JSON.stringify(mergedOptions), 181 | }) 182 | 183 | return await response.json() 184 | } 185 | 186 | async completeSession(sessionId: string): Promise { 187 | if (!sessionId || sessionId === '') { 188 | throw new Error('sessionId is required') 189 | } 190 | if (!this.projectId) { 191 | throw new Error( 192 | 'a projectId is missing: use the options.projectId or BROWSERBASE_PROJECT_ID environment variable to set one.' 193 | ) 194 | } 195 | 196 | const response = await fetch( 197 | `${this.baseAPIURL}/v1/sessions/${sessionId}`, 198 | { 199 | method: 'POST', 200 | headers: { 201 | 'x-bb-api-key': this.apiKey, 202 | 'Content-Type': 'application/json', 203 | }, 204 | body: JSON.stringify({ 205 | projectId: this.projectId, 206 | status: 'REQUEST_RELEASE', 207 | }), 208 | } 209 | ) 210 | 211 | return await response.json() 212 | } 213 | 214 | async getSession(sessionId: string): Promise { 215 | const response = await fetch( 216 | `${this.baseAPIURL}/v1/sessions/${sessionId}`, 217 | { 218 | headers: { 219 | 'x-bb-api-key': this.apiKey, 220 | 'Content-Type': 'application/json', 221 | }, 222 | } 223 | ) 224 | 225 | return await response.json() 226 | } 227 | 228 | async getSessionRecording(sessionId: string): Promise { 229 | const response = await fetch( 230 | `${this.baseAPIURL}/v1/sessions/${sessionId}/recording`, 231 | { 232 | headers: { 233 | 'x-bb-api-key': this.apiKey, 234 | 'Content-Type': 'application/json', 235 | }, 236 | } 237 | ) 238 | 239 | return await response.json() 240 | } 241 | 242 | async getSessionDownloads( 243 | sessionId: string, 244 | retryInterval: number = 2000, 245 | retryCount: number = 2 246 | ) { 247 | return new Promise((resolve, reject) => { 248 | const fetchDownload = async () => { 249 | const response = await fetch( 250 | `${this.baseAPIURL}/v1/sessions/${sessionId}/downloads`, 251 | { 252 | method: 'GET', 253 | headers: { 254 | 'x-bb-api-key': this.apiKey, 255 | }, 256 | } 257 | ) 258 | 259 | const arrayBuffer = await response.arrayBuffer() 260 | if (arrayBuffer.byteLength > 0) { 261 | resolve(Buffer.from(arrayBuffer)) 262 | } else { 263 | retryCount-- 264 | if (retryCount <= 0) { 265 | reject() 266 | } 267 | 268 | setTimeout(fetchDownload, retryInterval) 269 | } 270 | } 271 | 272 | fetchDownload() 273 | }) 274 | } 275 | 276 | async getDebugConnectionURLs( 277 | sessionId: string 278 | ): Promise { 279 | const response = await fetch( 280 | `${this.baseAPIURL}/v1/sessions/${sessionId}/debug`, 281 | { 282 | method: 'GET', 283 | headers: { 284 | 'x-bb-api-key': this.apiKey, 285 | 'Content-Type': 'application/json', 286 | }, 287 | } 288 | ) 289 | 290 | const json = await response.json() 291 | return json 292 | } 293 | 294 | async getSessionLogs(sessionId: string): Promise { 295 | const response = await fetch( 296 | `${this.baseAPIURL}/v1/sessions/${sessionId}/logs`, 297 | { 298 | headers: { 299 | 'x-bb-api-key': this.apiKey, 300 | 'Content-Type': 'application/json', 301 | }, 302 | } 303 | ) 304 | 305 | return await response.json() 306 | } 307 | 308 | load(url: string | string[], options: LoadOptions = {}) { 309 | if (typeof url === 'string') { 310 | return this.loadURL(url, options) 311 | } else if (Array.isArray(url)) { 312 | return this.loadURLs(url, options) 313 | } else { 314 | throw new TypeError('Input must be a URL string or array of URLs') 315 | } 316 | } 317 | 318 | async loadURL(url: string, options: LoadOptions = {}): Promise { 319 | if (!url) { 320 | throw new Error('Page URL was not provided') 321 | } 322 | 323 | const browser = await puppeteer.connect({ 324 | browserWSEndpoint: this.getConnectURL({ 325 | sessionId: options.sessionId, 326 | proxy: options.proxy, 327 | }), 328 | }) 329 | 330 | const pages = await browser.pages() 331 | const page = pages[0] 332 | await page.goto(url) 333 | let html = await page.content() 334 | 335 | if (options.textContent) { 336 | const readable: { title?: string; textContent?: string } = 337 | await page.evaluate(` 338 | import('https://cdn.skypack.dev/@mozilla/readability').then(readability => { 339 | return new readability.Readability(document).parse() 340 | })`) 341 | html = `${readable.title}\n${readable.textContent}` 342 | } 343 | 344 | await browser.close() 345 | return html 346 | } 347 | 348 | async *loadURLs( 349 | urls: string[], 350 | options: LoadOptions = {} 351 | ): AsyncGenerator { 352 | if (!urls.length) { 353 | throw new Error('Page URLs were not provided') 354 | } 355 | 356 | const browser = await puppeteer.connect({ 357 | browserWSEndpoint: this.getConnectURL({ 358 | sessionId: options.sessionId, 359 | proxy: options.proxy, 360 | }), 361 | }) 362 | 363 | const pages = await browser.pages() 364 | const page = pages[0] 365 | 366 | for (const url of urls) { 367 | await page.goto(url) 368 | let html = await page.content() 369 | 370 | if (options.textContent) { 371 | const readable: { title?: string; textContent?: string } = 372 | await page.evaluate(` 373 | import('https://cdn.skypack.dev/@mozilla/readability').then(readability => { 374 | return new readability.Readability(document).parse() 375 | })`) 376 | html = `${readable.title}\n${readable.textContent}` 377 | } 378 | 379 | yield html 380 | } 381 | 382 | await browser.close() 383 | } 384 | 385 | async screenshot( 386 | url: string, 387 | options: ScreenshotOptions = { fullPage: false } 388 | ): Promise { 389 | if (!url) { 390 | throw new Error('Page URL was not provided') 391 | } 392 | 393 | const browser = await puppeteer.connect({ 394 | browserWSEndpoint: this.getConnectURL({ 395 | sessionId: options.sessionId, 396 | proxy: options.proxy, 397 | }), 398 | }) 399 | 400 | const pages = await browser.pages() 401 | const page = pages[0] 402 | await page.goto(url) 403 | const screenshot = await page.screenshot({ fullPage: options.fullPage }) 404 | await browser.close() 405 | return screenshot 406 | } 407 | 408 | async createContext(): Promise<{ id: string }> { 409 | const response = await fetch(`${this.baseAPIURL}/v1/contexts`, { 410 | method: 'POST', 411 | headers: { 412 | 'x-bb-api-key': this.apiKey, 413 | 'Content-Type': 'application/json', 414 | }, 415 | body: JSON.stringify({ projectId: this.projectId }), 416 | }) 417 | 418 | if (!response.ok) { 419 | throw new Error('Failed to create context') 420 | } 421 | 422 | const data = await response.json() 423 | return data 424 | } 425 | } 426 | 427 | export { 428 | Browserbase, 429 | BrowserbaseAISDK, 430 | BrowserbaseCodeGenerator, 431 | type GeneratorOptions, 432 | type ActionsEvent, 433 | } 434 | -------------------------------------------------------------------------------- /src/integrations/ai-sdk.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import Browserbase, { LoadOptions } from '../index.js' 3 | 4 | export default function BrowserbaseAISDK( 5 | b: Browserbase, 6 | options: LoadOptions = {} 7 | ) { 8 | return { 9 | description: 10 | 'Load a webpage in a browser hosted by Browserbase and return its contents', 11 | parameters: z.object({ 12 | url: z.string().describe('Page URL to be loaded'), 13 | }), 14 | execute: async ({ url }: { url: string }) => { 15 | const page = await b.loadURL(url, options) 16 | return { page } 17 | }, 18 | } 19 | } 20 | 21 | export { BrowserbaseAISDK } 22 | -------------------------------------------------------------------------------- /src/recorder/call-metadata.ts: -------------------------------------------------------------------------------- 1 | import type { Point } from './recorder-types' 2 | 3 | export type SerializedValue = { 4 | n?: number 5 | b?: boolean 6 | s?: string 7 | v?: 'null' | 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0' 8 | d?: string 9 | u?: string 10 | bi?: string 11 | r?: { 12 | p: string 13 | f: string 14 | } 15 | a?: SerializedValue[] 16 | o?: { 17 | k: string 18 | v: SerializedValue 19 | }[] 20 | h?: number 21 | id?: number 22 | ref?: number 23 | } 24 | export type SerializedError = { 25 | error?: { 26 | message: string 27 | name: string 28 | stack?: string 29 | } 30 | value?: SerializedValue 31 | } 32 | 33 | export type CallMetadata = { 34 | id: string 35 | startTime: number 36 | endTime: number 37 | pauseStartTime?: number 38 | pauseEndTime?: number 39 | type: string 40 | method: string 41 | params: any 42 | apiName?: string 43 | // Client is making an internal call that should not show up in 44 | // the inspector or trace. 45 | internal?: boolean 46 | // Service-side is making a call to itself, this metadata does not go 47 | // through the dispatcher, so is always excluded from inspector / tracing. 48 | isServerSide?: boolean 49 | // Test runner step id. 50 | stepId?: string 51 | location?: { file: string; line?: number; column?: number } 52 | log: string[] 53 | error?: SerializedError 54 | result?: any 55 | point?: Point 56 | objectId?: string 57 | pageId?: string 58 | frameId?: string 59 | potentiallyClosesScope?: boolean 60 | } 61 | 62 | export type ActionMetadata = { 63 | apiName?: string 64 | objectId?: string 65 | pageId?: string 66 | frameId?: string 67 | params: any 68 | method: string 69 | type: string 70 | } 71 | -------------------------------------------------------------------------------- /src/recorder/crypto.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | 3 | export function createGuid(): string { 4 | return crypto.randomBytes(16).toString('hex') 5 | } 6 | -------------------------------------------------------------------------------- /src/recorder/css-utils.ts: -------------------------------------------------------------------------------- 1 | export function cssEscape(s: string): string { 2 | let result = '' 3 | for (let i = 0; i < s.length; i++) result += cssEscapeOne(s, i) 4 | return result 5 | } 6 | 7 | export function quoteCSSAttributeValue(text: string): string { 8 | return `"${cssEscape(text).replace(/\\ /g, ' ')}"` 9 | } 10 | 11 | function cssEscapeOne(s: string, i: number): string { 12 | // https://drafts.csswg.org/cssom/#serialize-an-identifier 13 | const c = s.charCodeAt(i) 14 | if (c === 0x0000) return '\uFFFD' 15 | if ( 16 | (c >= 0x0001 && c <= 0x001f) || 17 | (c >= 0x0030 && 18 | c <= 0x0039 && 19 | (i === 0 || (i === 1 && s.charCodeAt(0) === 0x002d))) 20 | ) 21 | return '\\' + c.toString(16) + ' ' 22 | if (i === 0 && c === 0x002d && s.length === 1) return '\\' + s.charAt(i) 23 | if ( 24 | c >= 0x0080 || 25 | c === 0x002d || 26 | c === 0x005f || 27 | (c >= 0x0030 && c <= 0x0039) || 28 | (c >= 0x0041 && c <= 0x005a) || 29 | (c >= 0x0061 && c <= 0x007a) 30 | ) 31 | return s.charAt(i) 32 | return '\\' + s.charAt(i) 33 | } 34 | -------------------------------------------------------------------------------- /src/recorder/generator.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | import type { 3 | BrowserContextOptions, 4 | LaunchOptions, 5 | Frame, 6 | } from 'playwright-core' 7 | import type { LanguageGenerator, LanguageGeneratorOptions } from './language' 8 | import type { Action, Signal, FrameDescription } from './recorder-types' 9 | 10 | export type ActionInContext = { 11 | frame: FrameDescription 12 | action: Action 13 | committed?: boolean 14 | } 15 | 16 | export class CodeGenerator extends EventEmitter { 17 | private _currentAction: ActionInContext | null = null 18 | private _lastAction: ActionInContext | null = null 19 | private _actions: ActionInContext[] = [] 20 | private _enabled: boolean 21 | private _options: LanguageGeneratorOptions 22 | 23 | constructor( 24 | browserName: string, 25 | enabled: boolean, 26 | launchOptions: LaunchOptions, 27 | contextOptions: BrowserContextOptions, 28 | deviceName: string | undefined, 29 | saveStorage: string | undefined 30 | ) { 31 | super() 32 | 33 | // Make a copy of options to modify them later. 34 | launchOptions = { headless: false, ...launchOptions } 35 | contextOptions = { ...contextOptions } 36 | this._enabled = enabled 37 | this._options = { 38 | browserName, 39 | launchOptions, 40 | contextOptions, 41 | deviceName, 42 | saveStorage, 43 | } 44 | this.restart() 45 | } 46 | 47 | restart() { 48 | this._currentAction = null 49 | this._lastAction = null 50 | this._actions = [] 51 | this.emit('change') 52 | } 53 | 54 | setEnabled(enabled: boolean) { 55 | this._enabled = enabled 56 | } 57 | 58 | addAction(action: ActionInContext) { 59 | if (!this._enabled) return 60 | this.willPerformAction(action) 61 | this.didPerformAction(action) 62 | } 63 | 64 | willPerformAction(action: ActionInContext) { 65 | if (!this._enabled) return 66 | this._currentAction = action 67 | } 68 | 69 | performedActionFailed(action: ActionInContext) { 70 | if (!this._enabled) return 71 | if (this._currentAction === action) this._currentAction = null 72 | } 73 | 74 | didPerformAction(actionInContext: ActionInContext) { 75 | if (!this._enabled) return 76 | const action = actionInContext.action 77 | let eraseLastAction = false 78 | if ( 79 | this._lastAction && 80 | this._lastAction.frame.pageAlias === actionInContext.frame.pageAlias 81 | ) { 82 | const lastAction = this._lastAction.action 83 | // We augment last action based on the type. 84 | if ( 85 | this._lastAction && 86 | action.name === 'fill' && 87 | lastAction.name === 'fill' 88 | ) { 89 | if (action.selector === lastAction.selector) eraseLastAction = true 90 | } 91 | if ( 92 | lastAction && 93 | action.name === 'click' && 94 | lastAction.name === 'click' 95 | ) { 96 | if ( 97 | action.selector === lastAction.selector && 98 | action.clickCount > lastAction.clickCount 99 | ) 100 | eraseLastAction = true 101 | } 102 | if ( 103 | lastAction && 104 | action.name === 'navigate' && 105 | lastAction.name === 'navigate' 106 | ) { 107 | if (action.url === lastAction.url) { 108 | // Already at a target URL. 109 | this._currentAction = null 110 | return 111 | } 112 | } 113 | // Check and uncheck erase click. 114 | if ( 115 | lastAction && 116 | (action.name === 'check' || action.name === 'uncheck') && 117 | lastAction.name === 'click' 118 | ) { 119 | if (action.selector === lastAction.selector) eraseLastAction = true 120 | } 121 | } 122 | 123 | this._lastAction = actionInContext 124 | this._currentAction = null 125 | if (eraseLastAction) this._actions.pop() 126 | this._actions.push(actionInContext) 127 | this.emit('change') 128 | } 129 | 130 | commitLastAction() { 131 | if (!this._enabled) return 132 | const action = this._lastAction 133 | if (action) action.committed = true 134 | } 135 | 136 | signal(pageAlias: string, frame: Frame, signal: Signal) { 137 | if (!this._enabled) return 138 | 139 | // Signal either arrives while action is being performed or shortly after. 140 | if (this._currentAction) { 141 | this._currentAction.action.signals.push(signal) 142 | return 143 | } 144 | 145 | if ( 146 | this._lastAction && 147 | (!this._lastAction.committed || signal.name !== 'navigation') 148 | ) { 149 | const signals = this._lastAction.action.signals 150 | if ( 151 | signal.name === 'navigation' && 152 | signals.length && 153 | signals[signals.length - 1].name === 'download' 154 | ) 155 | return 156 | if ( 157 | signal.name === 'download' && 158 | signals.length && 159 | signals[signals.length - 1].name === 'navigation' 160 | ) 161 | signals.length = signals.length - 1 162 | this._lastAction.action.signals.push(signal) 163 | this.emit('change') 164 | return 165 | } 166 | 167 | // @ts-ignore 168 | if (signal.name === 'navigation' && frame.page().mainFrame() === frame) { 169 | this.addAction({ 170 | frame: { 171 | pageAlias, 172 | isMainFrame: true, 173 | }, 174 | committed: true, 175 | action: { 176 | name: 'navigate', 177 | url: frame.url(), 178 | signals: [], 179 | }, 180 | }) 181 | } 182 | } 183 | 184 | generateStructure(languageGenerator: LanguageGenerator) { 185 | const header = languageGenerator.generateHeader(this._options) 186 | const footer = languageGenerator.generateFooter(this._options.saveStorage) 187 | const actions = this._actions 188 | .map((a) => languageGenerator.generateAction(a)) 189 | .filter(Boolean) 190 | const text = [header, ...actions, footer].join('\n') 191 | return { header, footer, actions, text } 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/recorder/javascript-generator.ts: -------------------------------------------------------------------------------- 1 | import type { BrowserContextOptions } from 'playwright-core' 2 | import type { 3 | Language, 4 | LanguageGenerator, 5 | LanguageGeneratorOptions, 6 | Action, 7 | } from './language' 8 | import { toSignalMap } from './language' 9 | import { toModifiers } from './utils' 10 | import type { MouseClickOptions } from './utils' 11 | import type { ActionInContext } from './generator' 12 | // TODO: rename to files 13 | import { asLocator } from './utils/isomorphic/locator-generators' 14 | import { escapeWithQuotes } from './utils/isomorphic/string-utils' 15 | 16 | export class JavaScriptLanguageGenerator implements LanguageGenerator { 17 | id: string 18 | groupName = 'Node.js' 19 | name: string 20 | highlighter = 'javascript' as Language 21 | private _isTest: boolean 22 | 23 | constructor(isTest: boolean) { 24 | this.id = isTest ? 'playwright-test' : 'javascript' 25 | this.name = isTest ? 'Test Runner' : 'Library' 26 | this._isTest = isTest 27 | } 28 | 29 | generateAction(actionInContext: ActionInContext): string { 30 | const action = actionInContext.action 31 | if ( 32 | this._isTest && 33 | (action.name === 'openPage' || action.name === 'closePage') 34 | ) 35 | return '' 36 | 37 | const pageAlias = actionInContext.frame.pageAlias 38 | const formatter = new JavaScriptFormatter(2) 39 | 40 | if (action.name === 'openPage') { 41 | formatter.add(`const ${pageAlias} = await context.newPage();`) 42 | if ( 43 | action.url && 44 | action.url !== 'about:blank' && 45 | action.url !== 'chrome://newtab/' 46 | ) 47 | formatter.add(`await ${pageAlias}.goto(${quote(action.url)});`) 48 | return formatter.format() 49 | } 50 | 51 | let subject: string 52 | if (actionInContext.frame.isMainFrame) { 53 | subject = pageAlias 54 | } else { 55 | // @ts-expect-error 56 | const locators = actionInContext.frame.selectorsChain.map( 57 | (selector: string) => `.frameLocator(${quote(selector)})` 58 | ) 59 | subject = `${pageAlias}${locators.join('')}` 60 | } 61 | 62 | const signals = toSignalMap(action) 63 | 64 | if (signals.dialog) { 65 | formatter.add(` ${pageAlias}.once('dialog', dialog => { 66 | console.log(\`Dialog message: $\{dialog.message()}\`); 67 | dialog.dismiss().catch(() => {}); 68 | });`) 69 | } 70 | 71 | if (signals.popup) 72 | formatter.add( 73 | `const ${signals.popup.popupAlias}Promise = ${pageAlias}.waitForEvent('popup');` 74 | ) 75 | if (signals.download) 76 | formatter.add( 77 | `const download${signals.download.downloadAlias}Promise = ${pageAlias}.waitForEvent('download');` 78 | ) 79 | 80 | formatter.add(this._generateActionCall(subject, action)) 81 | 82 | if (signals.popup) 83 | formatter.add( 84 | `const ${signals.popup.popupAlias} = await ${signals.popup.popupAlias}Promise;` 85 | ) 86 | if (signals.download) 87 | formatter.add( 88 | `const download${signals.download.downloadAlias} = await download${signals.download.downloadAlias}Promise;` 89 | ) 90 | 91 | return formatter.format() 92 | } 93 | 94 | private _generateActionCall(subject: string, action: Action): string { 95 | switch (action.name) { 96 | case 'openPage': 97 | throw Error('Not reached') 98 | case 'closePage': 99 | return `await ${subject}.close();` 100 | case 'click': { 101 | let method = 'click' 102 | if (action.clickCount === 2) method = 'dblclick' 103 | const modifiers = toModifiers(action.modifiers) 104 | const options: MouseClickOptions = {} 105 | if (action.button !== 'left') options.button = action.button 106 | if (modifiers.length) options.modifiers = modifiers 107 | if (action.clickCount > 2) options.clickCount = action.clickCount 108 | if (action.position) options.position = action.position 109 | const optionsString = formatOptions(options, false) 110 | return `await ${subject}.${this._asLocator(action.selector)}.${method}(${optionsString});` 111 | } 112 | case 'check': 113 | return `await ${subject}.${this._asLocator(action.selector)}.check();` 114 | case 'uncheck': 115 | return `await ${subject}.${this._asLocator(action.selector)}.uncheck();` 116 | case 'fill': 117 | return `await ${subject}.${this._asLocator(action.selector)}.fill(${quote(action.text)});` 118 | case 'setInputFiles': 119 | return `await ${subject}.${this._asLocator(action.selector)}.setInputFiles(${formatObject(action.files.length === 1 ? action.files[0] : action.files)});` 120 | case 'press': { 121 | const modifiers = toModifiers(action.modifiers) 122 | const shortcut = [...modifiers, action.key].join('+') 123 | return `await ${subject}.${this._asLocator(action.selector)}.press(${quote(shortcut)});` 124 | } 125 | case 'navigate': 126 | return `await ${subject}.goto(${quote(action.url)});` 127 | case 'select': 128 | return `await ${subject}.${this._asLocator(action.selector)}.selectOption(${formatObject(action.options.length > 1 ? action.options : action.options[0])});` 129 | case 'assertText': 130 | return `${this._isTest ? '' : '// '}await expect(${subject}.${this._asLocator(action.selector)}).${action.substring ? 'toContainText' : 'toHaveText'}(${quote(action.text)});` 131 | case 'assertChecked': 132 | return `${this._isTest ? '' : '// '}await expect(${subject}.${this._asLocator(action.selector)})${action.checked ? '' : '.not'}.toBeChecked();` 133 | case 'assertVisible': 134 | return `${this._isTest ? '' : '// '}await expect(${subject}.${this._asLocator(action.selector)}).toBeVisible();` 135 | case 'assertValue': { 136 | const assertion = action.value 137 | ? `toHaveValue(${quote(action.value)})` 138 | : `toBeEmpty()` 139 | return `${this._isTest ? '' : '// '}await expect(${subject}.${this._asLocator(action.selector)}).${assertion};` 140 | } 141 | } 142 | } 143 | 144 | private _asLocator(selector: string) { 145 | return asLocator('javascript', selector) 146 | } 147 | 148 | generateHeader(options: LanguageGeneratorOptions): string { 149 | if (this._isTest) return this.generateTestHeader(options) 150 | return this.generateStandaloneHeader(options) 151 | } 152 | 153 | generateFooter(saveStorage: string | undefined): string { 154 | if (this._isTest) return this.generateTestFooter(saveStorage) 155 | return this.generateStandaloneFooter(saveStorage) 156 | } 157 | 158 | generateTestHeader(options: LanguageGeneratorOptions): string { 159 | const formatter = new JavaScriptFormatter() 160 | const useText = formatContextOptions( 161 | options.contextOptions, 162 | options.deviceName 163 | ) 164 | formatter.add(` 165 | import { test, expect${options.deviceName ? ', devices' : ''} } from '@playwright/test'; 166 | ${useText ? '\ntest.use(' + useText + ');\n' : ''} 167 | test('test', async ({ page }) => {`) 168 | return formatter.format() 169 | } 170 | 171 | generateTestFooter(saveStorage: string | undefined): string { 172 | return `});` 173 | } 174 | 175 | // TODO: fix header 176 | generateStandaloneHeader(options: LanguageGeneratorOptions): string { 177 | const formatter = new JavaScriptFormatter() 178 | formatter.add(` 179 | const { ${options.browserName}${options.deviceName ? ', devices' : ''} } = require('playwright'); 180 | 181 | (async () => { 182 | const browser = await ${options.browserName}.launch(${formatObjectOrVoid(options.launchOptions)}); 183 | const context = await browser.newContext(${formatContextOptions(options.contextOptions, options.deviceName)});`) 184 | return formatter.format() 185 | } 186 | 187 | generateStandaloneFooter(saveStorage: string | undefined): string { 188 | const storageStateLine = saveStorage 189 | ? `\n await context.storageState({ path: ${quote(saveStorage)} });` 190 | : '' 191 | return `\n // ---------------------${storageStateLine} 192 | await context.close(); 193 | await browser.close(); 194 | })();` 195 | } 196 | } 197 | 198 | function formatOptions(value: any, hasArguments: boolean): string { 199 | const keys = Object.keys(value) 200 | if (!keys.length) return '' 201 | return (hasArguments ? ', ' : '') + formatObject(value) 202 | } 203 | 204 | function formatObject(value: any, indent = ' '): string { 205 | if (typeof value === 'string') return quote(value) 206 | if (Array.isArray(value)) 207 | return `[${value.map((o) => formatObject(o)).join(', ')}]` 208 | if (typeof value === 'object') { 209 | const keys = Object.keys(value) 210 | .filter((key) => value[key] !== undefined) 211 | .sort() 212 | if (!keys.length) return '{}' 213 | const tokens: string[] = [] 214 | for (const key of keys) tokens.push(`${key}: ${formatObject(value[key])}`) 215 | return `{\n${indent}${tokens.join(`,\n${indent}`)}\n}` 216 | } 217 | return String(value) 218 | } 219 | 220 | function formatObjectOrVoid(value: any, indent = ' '): string { 221 | const result = formatObject(value, indent) 222 | return result === '{}' ? '' : result 223 | } 224 | 225 | // NOTE: disabled device for now to simplify build 226 | function formatContextOptions( 227 | options: BrowserContextOptions, 228 | deviceName: string | undefined 229 | ): string { 230 | // const device = deviceName && deviceDescriptors[deviceName] 231 | // if (!device) return formatObjectOrVoid(options) 232 | return formatObjectOrVoid(options) 233 | // Filter out all the properties from the device descriptor. 234 | // let serializedObject = formatObjectOrVoid( 235 | // sanitizeDeviceOptions(device, options) 236 | // ) 237 | // // When there are no additional context options, we still want to spread the device inside. 238 | // if (!serializedObject) serializedObject = '{\n}' 239 | // const lines = serializedObject.split('\n') 240 | // lines.splice(1, 0, `...devices[${quote(deviceName!)}],`) 241 | // return lines.join('\n') 242 | } 243 | 244 | export class JavaScriptFormatter { 245 | private _baseIndent: string 246 | private _baseOffset: string 247 | private _lines: string[] = [] 248 | 249 | constructor(offset = 0) { 250 | this._baseIndent = ' '.repeat(2) 251 | this._baseOffset = ' '.repeat(offset) 252 | } 253 | 254 | prepend(text: string) { 255 | this._lines = text 256 | .trim() 257 | .split('\n') 258 | .map((line) => line.trim()) 259 | .concat(this._lines) 260 | } 261 | 262 | add(text: string) { 263 | this._lines.push( 264 | ...text 265 | .trim() 266 | .split('\n') 267 | .map((line) => line.trim()) 268 | ) 269 | } 270 | 271 | newLine() { 272 | this._lines.push('') 273 | } 274 | 275 | format(): string { 276 | let spaces = '' 277 | let previousLine = '' 278 | return this._lines 279 | .map((line: string) => { 280 | if (line === '') return line 281 | if (line.startsWith('}') || line.startsWith(']')) 282 | spaces = spaces.substring(this._baseIndent.length) 283 | 284 | const extraSpaces = /^(for|while|if|try).*\(.*\)$/.test(previousLine) 285 | ? this._baseIndent 286 | : '' 287 | previousLine = line 288 | 289 | const callCarryOver = line.startsWith('.set') 290 | line = 291 | spaces + extraSpaces + (callCarryOver ? this._baseIndent : '') + line 292 | if (line.endsWith('{') || line.endsWith('[')) spaces += this._baseIndent 293 | return this._baseOffset + line 294 | }) 295 | .join('\n') 296 | } 297 | } 298 | 299 | function quote(text: string) { 300 | return escapeWithQuotes(text, "'") 301 | } 302 | -------------------------------------------------------------------------------- /src/recorder/language.ts: -------------------------------------------------------------------------------- 1 | import type { BrowserContextOptions, LaunchOptions } from 'playwright-core' 2 | import type { ActionInContext } from './generator' 3 | import type { Point } from './recorder-types' 4 | 5 | export type Signal = 6 | | NavigationSignal 7 | | PopupSignal 8 | | DownloadSignal 9 | | DialogSignal 10 | 11 | export type ActionName = 12 | | 'check' 13 | | 'click' 14 | | 'closePage' 15 | | 'fill' 16 | | 'navigate' 17 | | 'openPage' 18 | | 'press' 19 | | 'select' 20 | | 'uncheck' 21 | | 'setInputFiles' 22 | | 'assertText' 23 | | 'assertValue' 24 | | 'assertChecked' 25 | | 'assertVisible' 26 | 27 | export type ActionBase = { 28 | name: ActionName 29 | signals: Signal[] 30 | } 31 | 32 | export type ClickAction = ActionBase & { 33 | name: 'click' 34 | selector: string 35 | button: 'left' | 'middle' | 'right' 36 | modifiers: number 37 | clickCount: number 38 | position?: Point 39 | } 40 | 41 | export type CheckAction = ActionBase & { 42 | name: 'check' 43 | selector: string 44 | } 45 | 46 | export type UncheckAction = ActionBase & { 47 | name: 'uncheck' 48 | selector: string 49 | } 50 | 51 | export type FillAction = ActionBase & { 52 | name: 'fill' 53 | selector: string 54 | text: string 55 | } 56 | 57 | export type NavigateAction = ActionBase & { 58 | name: 'navigate' 59 | url: string 60 | } 61 | 62 | export type OpenPageAction = ActionBase & { 63 | name: 'openPage' 64 | url: string 65 | } 66 | 67 | export type ClosesPageAction = ActionBase & { 68 | name: 'closePage' 69 | } 70 | 71 | export type PressAction = ActionBase & { 72 | name: 'press' 73 | selector: string 74 | key: string 75 | modifiers: number 76 | } 77 | 78 | export type SelectAction = ActionBase & { 79 | name: 'select' 80 | selector: string 81 | options: string[] 82 | } 83 | 84 | export type SetInputFilesAction = ActionBase & { 85 | name: 'setInputFiles' 86 | selector: string 87 | files: string[] 88 | } 89 | 90 | export type AssertTextAction = ActionBase & { 91 | name: 'assertText' 92 | selector: string 93 | text: string 94 | substring: boolean 95 | } 96 | 97 | export type AssertValueAction = ActionBase & { 98 | name: 'assertValue' 99 | selector: string 100 | value: string 101 | } 102 | 103 | export type AssertCheckedAction = ActionBase & { 104 | name: 'assertChecked' 105 | selector: string 106 | checked: boolean 107 | } 108 | 109 | export type AssertVisibleAction = ActionBase & { 110 | name: 'assertVisible' 111 | selector: string 112 | } 113 | export type Action = 114 | | ClickAction 115 | | CheckAction 116 | | ClosesPageAction 117 | | OpenPageAction 118 | | UncheckAction 119 | | FillAction 120 | | NavigateAction 121 | | PressAction 122 | | SelectAction 123 | | SetInputFilesAction 124 | | AssertTextAction 125 | | AssertValueAction 126 | | AssertCheckedAction 127 | | AssertVisibleAction 128 | export type AssertAction = 129 | | AssertCheckedAction 130 | | AssertValueAction 131 | | AssertTextAction 132 | | AssertVisibleAction 133 | 134 | // Signals. 135 | 136 | export type BaseSignal = {} 137 | 138 | export type NavigationSignal = BaseSignal & { 139 | name: 'navigation' 140 | url: string 141 | } 142 | 143 | export type PopupSignal = BaseSignal & { 144 | name: 'popup' 145 | popupAlias: string 146 | } 147 | 148 | export type DownloadSignal = BaseSignal & { 149 | name: 'download' 150 | downloadAlias: string 151 | } 152 | 153 | export type DialogSignal = BaseSignal & { 154 | name: 'dialog' 155 | dialogAlias: string 156 | } 157 | 158 | export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl' 159 | 160 | export type LanguageGeneratorOptions = { 161 | browserName: string 162 | launchOptions: LaunchOptions 163 | contextOptions: BrowserContextOptions 164 | deviceName?: string 165 | saveStorage?: string 166 | } 167 | 168 | export interface LanguageGenerator { 169 | id: string 170 | groupName: string 171 | name: string 172 | highlighter: Language 173 | generateHeader(options: LanguageGeneratorOptions): string 174 | generateAction(actionInContext: ActionInContext): string 175 | generateFooter(saveStorage: string | undefined): string 176 | } 177 | 178 | export function toSignalMap(action: Action) { 179 | let popup: PopupSignal | undefined 180 | let download: DownloadSignal | undefined 181 | let dialog: DialogSignal | undefined 182 | for (const signal of action.signals) { 183 | if (signal.name === 'popup') popup = signal 184 | else if (signal.name === 'download') download = signal 185 | else if (signal.name === 'dialog') dialog = signal 186 | } 187 | return { 188 | popup, 189 | download, 190 | dialog, 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/recorder/recorder-types.ts: -------------------------------------------------------------------------------- 1 | export type Point = { x: number; y: number } 2 | 3 | export type ActionName = 4 | | 'check' 5 | | 'click' 6 | | 'closePage' 7 | | 'fill' 8 | | 'navigate' 9 | | 'openPage' 10 | | 'press' 11 | | 'select' 12 | | 'uncheck' 13 | | 'setInputFiles' 14 | | 'assertText' 15 | | 'assertValue' 16 | | 'assertChecked' 17 | | 'assertVisible' 18 | 19 | export type ActionBase = { 20 | name: ActionName 21 | signals: Signal[] 22 | } 23 | 24 | export type ClickAction = ActionBase & { 25 | name: 'click' 26 | selector: string 27 | button: 'left' | 'middle' | 'right' 28 | modifiers: number 29 | clickCount: number 30 | position?: Point 31 | } 32 | 33 | export type CheckAction = ActionBase & { 34 | name: 'check' 35 | selector: string 36 | } 37 | 38 | export type UncheckAction = ActionBase & { 39 | name: 'uncheck' 40 | selector: string 41 | } 42 | 43 | export type FillAction = ActionBase & { 44 | name: 'fill' 45 | selector: string 46 | text: string 47 | } 48 | 49 | export type NavigateAction = ActionBase & { 50 | name: 'navigate' 51 | url: string 52 | } 53 | 54 | export type OpenPageAction = ActionBase & { 55 | name: 'openPage' 56 | url: string 57 | } 58 | 59 | export type ClosesPageAction = ActionBase & { 60 | name: 'closePage' 61 | } 62 | 63 | export type PressAction = ActionBase & { 64 | name: 'press' 65 | selector: string 66 | key: string 67 | modifiers: number 68 | } 69 | 70 | export type SelectAction = ActionBase & { 71 | name: 'select' 72 | selector: string 73 | options: string[] 74 | } 75 | 76 | export type SetInputFilesAction = ActionBase & { 77 | name: 'setInputFiles' 78 | selector: string 79 | files: string[] 80 | } 81 | 82 | export type AssertTextAction = ActionBase & { 83 | name: 'assertText' 84 | selector: string 85 | text: string 86 | substring: boolean 87 | } 88 | 89 | export type AssertValueAction = ActionBase & { 90 | name: 'assertValue' 91 | selector: string 92 | value: string 93 | } 94 | 95 | export type AssertCheckedAction = ActionBase & { 96 | name: 'assertChecked' 97 | selector: string 98 | checked: boolean 99 | } 100 | 101 | export type AssertVisibleAction = ActionBase & { 102 | name: 'assertVisible' 103 | selector: string 104 | } 105 | 106 | export type Action = 107 | | ClickAction 108 | | CheckAction 109 | | ClosesPageAction 110 | | OpenPageAction 111 | | UncheckAction 112 | | FillAction 113 | | NavigateAction 114 | | PressAction 115 | | SelectAction 116 | | SetInputFilesAction 117 | | AssertTextAction 118 | | AssertValueAction 119 | | AssertCheckedAction 120 | | AssertVisibleAction 121 | export type AssertAction = 122 | | AssertCheckedAction 123 | | AssertValueAction 124 | | AssertTextAction 125 | | AssertVisibleAction 126 | 127 | // Signals. 128 | 129 | export type BaseSignal = {} 130 | 131 | export type NavigationSignal = BaseSignal & { 132 | name: 'navigation' 133 | url: string 134 | } 135 | 136 | export type PopupSignal = BaseSignal & { 137 | name: 'popup' 138 | popupAlias: string 139 | } 140 | 141 | export type DownloadSignal = BaseSignal & { 142 | name: 'download' 143 | downloadAlias: string 144 | } 145 | 146 | export type DialogSignal = BaseSignal & { 147 | name: 'dialog' 148 | dialogAlias: string 149 | } 150 | 151 | export type Signal = 152 | | NavigationSignal 153 | | PopupSignal 154 | | DownloadSignal 155 | | DialogSignal 156 | 157 | type FrameDescriptionMainFrame = { 158 | isMainFrame: true 159 | } 160 | 161 | type FrameDescriptionChildFrame = { 162 | isMainFrame: false 163 | selectorsChain: string[] 164 | } 165 | 166 | export type FrameDescription = { pageAlias: string } & ( 167 | | FrameDescriptionMainFrame 168 | | FrameDescriptionChildFrame 169 | ) 170 | -------------------------------------------------------------------------------- /src/recorder/recorder.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | import { CodeGenerator, ActionInContext } from './generator' 3 | import * as actions from './recorder-types' 4 | import type { BrowserContext, Dialog, Page, Frame } from 'playwright-core' 5 | import { BindingSource } from 'playwright-core/types/structs' 6 | import { monotonicTime } from './time' 7 | import { raceAgainstDeadline } from './timeout-runner' 8 | import { quoteCSSAttributeValue } from './css-utils' 9 | import { CallMetadata } from './call-metadata' 10 | import { createGuid } from './crypto' 11 | import { toClickOptions, toModifiers } from './utils' 12 | import { JavaScriptLanguageGenerator } from './javascript-generator' 13 | 14 | export type GeneratorOptions = { 15 | enabled?: boolean 16 | } 17 | 18 | export type ActionsEvent = { 19 | actions: string[] 20 | } 21 | 22 | export class BrowserbaseCodeGenerator extends EventEmitter { 23 | private _context: BrowserContext 24 | // This is used in the playground to bypass the verification step since it doesnt work 25 | // in the browser 26 | private _skipVerify = false 27 | private _enabled: boolean 28 | private _generator: CodeGenerator 29 | private _pageAliases = new Map() 30 | private _lastPopupOrdinal = 0 31 | private _lastDialogOrdinal = -1 32 | private _lastDownloadOrdinal = -1 33 | private _timers = new Set() 34 | private _javascriptGenerator: JavaScriptLanguageGenerator 35 | 36 | constructor(context: BrowserContext, options?: GeneratorOptions) { 37 | super() 38 | this._context = context 39 | this._enabled = options?.enabled ?? false 40 | this._generator = new CodeGenerator( 41 | 'chromium', 42 | this._enabled, 43 | {}, 44 | {}, 45 | undefined, 46 | undefined 47 | ) 48 | this._javascriptGenerator = new JavaScriptLanguageGenerator(false) 49 | 50 | this._generator.on('change', () => { 51 | const { header, footer, actions, text } = 52 | this._generator.generateStructure(this._javascriptGenerator) 53 | this.emit('action', { actions }) 54 | }) 55 | } 56 | 57 | static async init(context: BrowserContext, options?: GeneratorOptions) { 58 | const browserbaseCodeGenerator = new BrowserbaseCodeGenerator( 59 | context, 60 | options 61 | ) 62 | await browserbaseCodeGenerator.install() 63 | return browserbaseCodeGenerator 64 | } 65 | 66 | setEnabled(enabled: boolean) { 67 | this._enabled = enabled 68 | this._generator.setEnabled(enabled) 69 | } 70 | 71 | async install() { 72 | if (!this._skipVerify) { 73 | const codegenMode = await this._ensureCodegenEnabled() 74 | if (!codegenMode) { 75 | throw new Error( 76 | 'Failed to install generator: codegenMode not enabled on this session' 77 | ) 78 | } 79 | } 80 | this._context.on('page', (page: Page) => { 81 | this._onPage(page) 82 | }) 83 | for (const page of this._context.pages()) { 84 | this._onPage(page) 85 | } 86 | this._context.on('dialog', (dialog: Dialog) => { 87 | this._onDialog(dialog.page()) 88 | }) 89 | 90 | await this._context.exposeBinding( 91 | '__bb_recorderPerformAction', 92 | (source: BindingSource, action: actions.Action) => 93 | this._performAction(source.frame, action) 94 | ) 95 | 96 | await this._context.exposeBinding( 97 | '__bb_recorderRecordAction', 98 | (source: BindingSource, action: actions.Action) => 99 | this._recordAction(source.frame, action) 100 | ) 101 | 102 | await this._context.exposeBinding( 103 | '__bb_recorderEnabled', 104 | (_source: BindingSource) => true 105 | ) 106 | } 107 | 108 | private _onDialog(page: Page) { 109 | const pageAlias = this._pageAliases.get(page)! 110 | ++this._lastDialogOrdinal 111 | this._generator.signal(pageAlias, page.mainFrame(), { 112 | name: 'dialog', 113 | dialogAlias: this._lastDialogOrdinal 114 | ? String(this._lastDialogOrdinal) 115 | : '', 116 | }) 117 | } 118 | 119 | // when a browser is created with record mode it will add a global variable to the page 120 | // this function checks if the global variable is present and returns true if it is 121 | private async _ensureCodegenEnabled(): Promise { 122 | const enabledState = this._enabled 123 | // disable generator so we dont capture these actions 124 | if (enabledState) { 125 | this.setEnabled(false) 126 | } 127 | const page = await this._context.newPage() 128 | await page.waitForLoadState('domcontentloaded') 129 | 130 | const codegenMode = await page.evaluate(() => { 131 | // @ts-expect-error 132 | if (window.__bb_codegenEnabled) { 133 | // @ts-expect-error 134 | return window.__bb_codegenEnabled() 135 | } 136 | return false 137 | }) 138 | 139 | await page.close() 140 | 141 | // re-enable generator 142 | if (enabledState) { 143 | this.setEnabled(true) 144 | } 145 | 146 | return codegenMode 147 | } 148 | 149 | private async _onPage(page: Page) { 150 | const frame = page.mainFrame() 151 | 152 | page.on('close', () => { 153 | this._generator.addAction({ 154 | frame: this._describeMainFrame(page), 155 | committed: true, 156 | action: { 157 | name: 'closePage', 158 | signals: [], 159 | }, 160 | }) 161 | this._pageAliases.delete(page) 162 | }) 163 | 164 | // @ts-ignore 165 | frame._eventEmitter.on('navigated', (event) => { 166 | this._onFrameNavigated(frame, page) 167 | }) 168 | 169 | page.on('download', () => this._onDownload(page)) 170 | 171 | const suffix = this._pageAliases.size 172 | ? String(++this._lastPopupOrdinal) 173 | : '' 174 | const pageAlias = 'page' + suffix 175 | this._pageAliases.set(page, pageAlias) 176 | 177 | const opener = await page.opener() 178 | 179 | if (opener) { 180 | this._onPopup(opener, page) 181 | } else { 182 | this._generator.addAction({ 183 | frame: this._describeMainFrame(page), 184 | committed: true, 185 | action: { 186 | name: 'openPage', 187 | url: page.mainFrame().url(), 188 | signals: [], 189 | }, 190 | }) 191 | } 192 | } 193 | private _describeMainFrame(page: Page): actions.FrameDescription { 194 | return { 195 | pageAlias: this._pageAliases.get(page)!, 196 | isMainFrame: true, 197 | } 198 | } 199 | 200 | private async _describeFrame( 201 | frame: Frame 202 | ): Promise { 203 | // @ts-expect-error 204 | const page = frame._page 205 | const pageAlias = this._pageAliases.get(page)! 206 | const chain: Frame[] = [] 207 | for ( 208 | let ancestor: Frame | null = frame; 209 | ancestor; 210 | ancestor = ancestor.parentFrame() 211 | ) 212 | chain.push(ancestor) 213 | chain.reverse() 214 | 215 | if (chain.length === 1) return this._describeMainFrame(page) 216 | 217 | const selectorPromises: Promise[] = [] 218 | for (let i = 0; i < chain.length - 1; i++) 219 | selectorPromises.push(findFrameSelector(chain[i + 1])) 220 | 221 | const result = await raceAgainstDeadline( 222 | () => Promise.all(selectorPromises), 223 | monotonicTime() + 2000 224 | ) 225 | if ( 226 | !result.timedOut && 227 | // @ts-expect-error 228 | result.result.every((selector: string | undefined) => !!selector) 229 | ) { 230 | return { 231 | pageAlias, 232 | isMainFrame: false, 233 | // @ts-expect-error 234 | selectorsChain: result.result as string[], 235 | } 236 | } 237 | // Best effort to find a selector for the frame. 238 | const selectorsChain = [] 239 | for (let i = 0; i < chain.length - 1; i++) { 240 | if (chain[i].name()) 241 | selectorsChain.push( 242 | `iframe[name=${quoteCSSAttributeValue(chain[i].name())}]` 243 | ) 244 | else 245 | selectorsChain.push( 246 | `iframe[src=${quoteCSSAttributeValue(chain[i].url())}]` 247 | ) 248 | } 249 | return { 250 | pageAlias, 251 | isMainFrame: false, 252 | selectorsChain, 253 | } 254 | } 255 | 256 | private async _performAction(frame: Frame, action: actions.Action) { 257 | // console.log('perform action', action) 258 | // Commit last action so that no further signals are added to it. 259 | this._generator.commitLastAction() 260 | 261 | const frameDescription = await this._describeFrame(frame) 262 | const actionInContext: ActionInContext = { 263 | frame: frameDescription, 264 | action, 265 | } 266 | 267 | const perform = async ( 268 | action: string, 269 | params: any, 270 | cb: (callMetadata: CallMetadata) => Promise 271 | ) => { 272 | const callMetadata: CallMetadata = { 273 | id: `call@${createGuid()}`, 274 | apiName: 'frame.' + action, 275 | // @ts-expect-error 276 | objectId: frame._guid, 277 | // @ts-expect-error 278 | pageId: frame._page._guid, 279 | // @ts-expect-error 280 | frameId: frame._guid, 281 | startTime: monotonicTime(), 282 | endTime: 0, 283 | type: 'Frame', 284 | method: action, 285 | params, 286 | log: [], 287 | } 288 | 289 | this._generator.willPerformAction(actionInContext) 290 | //// console.log('will perform action', actionInContext) 291 | try { 292 | //await frame.instrumentation.onBeforeCall(frame, callMetadata) 293 | await cb(callMetadata) 294 | } catch (e) { 295 | console.error('error performing action', e) 296 | callMetadata.endTime = monotonicTime() 297 | //await frame.instrumentation.onAfterCall(frame, callMetadata) 298 | this._generator.performedActionFailed(actionInContext) 299 | return 300 | } 301 | 302 | callMetadata.endTime = monotonicTime() 303 | //await frame.instrumentation.onAfterCall(frame, callMetadata) 304 | 305 | this._setCommittedAfterTimeout(actionInContext) 306 | this._generator.didPerformAction(actionInContext) 307 | //console.log('did perform action', actionInContext) 308 | } 309 | 310 | const kActionTimeout = 5000 311 | if (action.name === 'click') { 312 | const { options } = toClickOptions(action) 313 | await perform('click', { selector: action.selector }, (callMetadata) => { 314 | return frame.click(action.selector, { 315 | ...options, 316 | force: true, 317 | timeout: kActionTimeout, 318 | strict: true, 319 | }) 320 | }) 321 | } 322 | if (action.name === 'press') { 323 | const modifiers = toModifiers(action.modifiers) 324 | const shortcut = [...modifiers, action.key].join('+') 325 | await perform( 326 | 'press', 327 | { selector: action.selector, key: shortcut }, 328 | (callMetadata) => 329 | frame.press(action.selector, shortcut, { 330 | timeout: kActionTimeout, 331 | strict: true, 332 | }) 333 | ) 334 | } 335 | if (action.name === 'check') 336 | await perform('check', { selector: action.selector }, (callMetadata) => 337 | frame.check(action.selector, { 338 | timeout: kActionTimeout, 339 | strict: true, 340 | }) 341 | ) 342 | if (action.name === 'uncheck') 343 | await perform('uncheck', { selector: action.selector }, (callMetadata) => 344 | frame.uncheck(action.selector, { 345 | timeout: kActionTimeout, 346 | strict: true, 347 | }) 348 | ) 349 | if (action.name === 'select') { 350 | const values = action.options.map((value) => ({ value })) 351 | await perform( 352 | 'selectOption', 353 | { selector: action.selector, values }, 354 | (callMetadata) => 355 | frame.selectOption(action.selector, values, { 356 | timeout: kActionTimeout, 357 | strict: true, 358 | }) 359 | ) 360 | } 361 | } 362 | 363 | private async _recordAction(frame: Frame, action: actions.Action) { 364 | // console.log('record action', action) 365 | // Commit last action so that no further signals are added to it. 366 | this._generator.commitLastAction() 367 | 368 | // console.log('committed action') 369 | 370 | const frameDescription = await this._describeFrame(frame) 371 | // console.log('described frame') 372 | const actionInContext: ActionInContext = { 373 | frame: frameDescription, 374 | action, 375 | } 376 | this._setCommittedAfterTimeout(actionInContext) 377 | // console.log('committed after timeout') 378 | this._generator.addAction(actionInContext) 379 | // console.log('action added') 380 | } 381 | 382 | private _setCommittedAfterTimeout(actionInContext: ActionInContext) { 383 | const timer = setTimeout(() => { 384 | // Commit the action after 5 seconds so that no further signals are added to it. 385 | actionInContext.committed = true 386 | this._timers.delete(timer) 387 | }, 5000) 388 | this._timers.add(timer) 389 | } 390 | 391 | private _onFrameNavigated(frame: Frame, page: Page) { 392 | const pageAlias = this._pageAliases.get(page) 393 | this._generator.signal(pageAlias!, frame, { 394 | name: 'navigation', 395 | url: frame.url(), 396 | }) 397 | } 398 | 399 | private _onPopup(page: Page, popup: Page) { 400 | const pageAlias = this._pageAliases.get(page)! 401 | const popupAlias = this._pageAliases.get(popup)! 402 | this._generator.signal(pageAlias, page.mainFrame(), { 403 | name: 'popup', 404 | popupAlias, 405 | }) 406 | } 407 | 408 | private _onDownload(page: Page) { 409 | const pageAlias = this._pageAliases.get(page)! 410 | ++this._lastDownloadOrdinal 411 | this._generator.signal(pageAlias, page.mainFrame(), { 412 | name: 'download', 413 | downloadAlias: this._lastDownloadOrdinal 414 | ? String(this._lastDownloadOrdinal) 415 | : '', 416 | }) 417 | } 418 | } 419 | 420 | async function findFrameSelector(frame: Frame): Promise { 421 | try { 422 | const parent = frame.parentFrame() 423 | const frameElement = await frame.frameElement() 424 | if (!frameElement || !parent) return 425 | const selector = await parent.evaluate((element) => { 426 | //@ts-expect-error 427 | return window.__bb_injectedScript.generateSelectorSimple( 428 | element as Element, 429 | { 430 | testIdAttributeName: '', 431 | omitInternalEngines: true, 432 | } 433 | ) 434 | }, frameElement) 435 | return selector 436 | } catch (e) {} 437 | } 438 | -------------------------------------------------------------------------------- /src/recorder/time.ts: -------------------------------------------------------------------------------- 1 | // polyfil for window.performance.now 2 | var performance = global.performance || {} 3 | var performanceNow = 4 | // @ts-expect-error 5 | performance.now || 6 | // @ts-expect-error 7 | performance.mozNow || 8 | // @ts-expect-error 9 | performance.msNow || 10 | // @ts-expect-error 11 | performance.oNow || 12 | // @ts-expect-error 13 | performance.webkitNow || 14 | function () { 15 | return new Date().getTime() 16 | } 17 | 18 | // generate timestamp or delta 19 | // see http://nodejs.org/api/process.html#process_process_hrtime 20 | function _hrtime(previousTimestamp: any) { 21 | var clocktime = performanceNow.call(performance) * 1e-3 22 | var seconds = Math.floor(clocktime) 23 | var nanoseconds = Math.floor((clocktime % 1) * 1e9) 24 | if (previousTimestamp) { 25 | seconds = seconds - previousTimestamp[0] 26 | nanoseconds = nanoseconds - previousTimestamp[1] 27 | if (nanoseconds < 0) { 28 | seconds-- 29 | nanoseconds += 1e9 30 | } 31 | } 32 | return [seconds, nanoseconds] 33 | } 34 | 35 | const hrtime = process.hrtime || _hrtime 36 | 37 | // @ts-expect-error 38 | const initialTime = hrtime() 39 | 40 | export function monotonicTime(): number { 41 | const [seconds, nanoseconds] = hrtime(initialTime) 42 | return seconds * 1000 + ((nanoseconds / 1000) | 0) / 1000 43 | } 44 | -------------------------------------------------------------------------------- /src/recorder/timeout-runner.ts: -------------------------------------------------------------------------------- 1 | import { monotonicTime } from './time' 2 | export async function raceAgainstDeadline( 3 | cb: () => Promise, 4 | deadline: number 5 | ): Promise<{ result: T; timedOut: false } | { timedOut: true }> { 6 | let timer: NodeJS.Timeout | undefined 7 | return Promise.race([ 8 | cb().then((result) => { 9 | return { result, timedOut: false } 10 | }), 11 | new Promise<{ timedOut: true }>((resolve) => { 12 | const kMaxDeadline = 2147483647 // 2^31-1 13 | const timeout = (deadline || kMaxDeadline) - monotonicTime() 14 | timer = setTimeout(() => resolve({ timedOut: true }), timeout) 15 | }), 16 | ]).finally(() => { 17 | clearTimeout(timer) 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /src/recorder/utils.ts: -------------------------------------------------------------------------------- 1 | import * as actions from './recorder-types' 2 | 3 | export type KeyboardModifier = 'Alt' | 'Control' | 'Meta' | 'Shift' 4 | export type SmartKeyboardModifier = KeyboardModifier | 'ControlOrMeta' 5 | 6 | export type MouseClickOptions = { 7 | strict?: boolean 8 | force?: boolean 9 | noWaitAfter?: boolean 10 | modifiers?: ('Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift')[] 11 | position?: actions.Point 12 | delay?: number 13 | button?: 'left' | 'right' | 'middle' 14 | clickCount?: number 15 | timeout?: number 16 | trial?: boolean 17 | } 18 | 19 | export function toClickOptions(action: actions.ClickAction): { 20 | method: 'click' | 'dblclick' 21 | options: MouseClickOptions 22 | } { 23 | let method: 'click' | 'dblclick' = 'click' 24 | if (action.clickCount === 2) method = 'dblclick' 25 | const modifiers = toModifiers(action.modifiers) 26 | const options: MouseClickOptions = {} 27 | if (action.button !== 'left') options.button = action.button 28 | if (modifiers.length) options.modifiers = modifiers 29 | if (action.clickCount > 2) options.clickCount = action.clickCount 30 | if (action.position) options.position = action.position 31 | return { method, options } 32 | } 33 | 34 | export function toModifiers(modifiers: number): SmartKeyboardModifier[] { 35 | const result: SmartKeyboardModifier[] = [] 36 | if (modifiers & 1) result.push('Alt') 37 | if (modifiers & 2) result.push('ControlOrMeta') 38 | if (modifiers & 4) result.push('ControlOrMeta') 39 | if (modifiers & 8) result.push('Shift') 40 | return result 41 | } 42 | -------------------------------------------------------------------------------- /src/recorder/utils/isomorphic/css-parser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import * as css from './css-tokenizer' 18 | 19 | export class InvalidSelectorError extends Error {} 20 | 21 | export function isInvalidSelectorError(error: Error) { 22 | return error instanceof InvalidSelectorError 23 | } 24 | 25 | // Note: '>=' is used internally for text engine to preserve backwards compatibility. 26 | type ClauseCombinator = '' | '>' | '+' | '~' | '>=' 27 | // TODO: consider 28 | // - key=value 29 | // - operators like `=`, `|=`, `~=`, `*=`, `/` 30 | // - ~=value 31 | // - argument modes: "parse all", "parse commas", "just a string" 32 | export type CSSFunctionArgument = CSSComplexSelector | number | string 33 | export type CSSFunction = { name: string; args: CSSFunctionArgument[] } 34 | export type CSSSimpleSelector = { css?: string; functions: CSSFunction[] } 35 | export type CSSComplexSelector = { 36 | simples: { selector: CSSSimpleSelector; combinator: ClauseCombinator }[] 37 | } 38 | export type CSSComplexSelectorList = CSSComplexSelector[] 39 | 40 | export function parseCSS( 41 | selector: string, 42 | customNames: Set 43 | ): { selector: CSSComplexSelectorList; names: string[] } { 44 | let tokens: css.CSSTokenInterface[] 45 | try { 46 | tokens = css.tokenize(selector) 47 | if (!(tokens[tokens.length - 1] instanceof css.EOFToken)) 48 | tokens.push(new css.EOFToken()) 49 | } catch (e) { 50 | const newMessage = e.message + ` while parsing selector "${selector}"` 51 | const index = (e.stack || '').indexOf(e.message) 52 | if (index !== -1) 53 | e.stack = 54 | e.stack.substring(0, index) + 55 | newMessage + 56 | e.stack.substring(index + e.message.length) 57 | e.message = newMessage 58 | throw e 59 | } 60 | const unsupportedToken = tokens.find((token) => { 61 | return ( 62 | token instanceof css.AtKeywordToken || 63 | token instanceof css.BadStringToken || 64 | token instanceof css.BadURLToken || 65 | token instanceof css.ColumnToken || 66 | token instanceof css.CDOToken || 67 | token instanceof css.CDCToken || 68 | token instanceof css.SemicolonToken || 69 | // TODO: Consider using these for something, e.g. to escape complex strings. 70 | // For example :xpath{ (//div/bar[@attr="foo"])[2]/baz } 71 | // Or this way :xpath( {complex-xpath-goes-here("hello")} ) 72 | token instanceof css.OpenCurlyToken || 73 | token instanceof css.CloseCurlyToken || 74 | // TODO: Consider treating these as strings? 75 | token instanceof css.URLToken || 76 | token instanceof css.PercentageToken 77 | ) 78 | }) 79 | if (unsupportedToken) 80 | throw new InvalidSelectorError( 81 | `Unsupported token "${unsupportedToken.toSource()}" while parsing selector "${selector}"` 82 | ) 83 | 84 | let pos = 0 85 | const names = new Set() 86 | 87 | function unexpected() { 88 | return new InvalidSelectorError( 89 | `Unexpected token "${tokens[pos].toSource()}" while parsing selector "${selector}"` 90 | ) 91 | } 92 | 93 | function skipWhitespace() { 94 | while (tokens[pos] instanceof css.WhitespaceToken) pos++ 95 | } 96 | 97 | function isIdent(p = pos) { 98 | return tokens[p] instanceof css.IdentToken 99 | } 100 | 101 | function isString(p = pos) { 102 | return tokens[p] instanceof css.StringToken 103 | } 104 | 105 | function isNumber(p = pos) { 106 | return tokens[p] instanceof css.NumberToken 107 | } 108 | 109 | function isComma(p = pos) { 110 | return tokens[p] instanceof css.CommaToken 111 | } 112 | 113 | function isOpenParen(p = pos) { 114 | return tokens[p] instanceof css.OpenParenToken 115 | } 116 | 117 | function isCloseParen(p = pos) { 118 | return tokens[p] instanceof css.CloseParenToken 119 | } 120 | 121 | function isFunction(p = pos) { 122 | return tokens[p] instanceof css.FunctionToken 123 | } 124 | 125 | function isStar(p = pos) { 126 | return tokens[p] instanceof css.DelimToken && tokens[p].value === '*' 127 | } 128 | 129 | function isEOF(p = pos) { 130 | return tokens[p] instanceof css.EOFToken 131 | } 132 | 133 | function isClauseCombinator(p = pos) { 134 | return ( 135 | tokens[p] instanceof css.DelimToken && 136 | ['>', '+', '~'].includes(tokens[p].value as string) 137 | ) 138 | } 139 | 140 | function isSelectorClauseEnd(p = pos) { 141 | return ( 142 | isComma(p) || 143 | isCloseParen(p) || 144 | isEOF(p) || 145 | isClauseCombinator(p) || 146 | tokens[p] instanceof css.WhitespaceToken 147 | ) 148 | } 149 | 150 | function consumeFunctionArguments(): CSSFunctionArgument[] { 151 | const result = [consumeArgument()] 152 | while (true) { 153 | skipWhitespace() 154 | if (!isComma()) break 155 | pos++ 156 | result.push(consumeArgument()) 157 | } 158 | return result 159 | } 160 | 161 | function consumeArgument(): CSSFunctionArgument { 162 | skipWhitespace() 163 | if (isNumber()) return tokens[pos++].value! 164 | if (isString()) return tokens[pos++].value! 165 | return consumeComplexSelector() 166 | } 167 | 168 | function consumeComplexSelector(): CSSComplexSelector { 169 | const result: CSSComplexSelector = { simples: [] } 170 | skipWhitespace() 171 | if (isClauseCombinator()) { 172 | // Put implicit ":scope" at the start. https://drafts.csswg.org/selectors-4/#relative 173 | result.simples.push({ 174 | selector: { functions: [{ name: 'scope', args: [] }] }, 175 | combinator: '', 176 | }) 177 | } else { 178 | result.simples.push({ selector: consumeSimpleSelector(), combinator: '' }) 179 | } 180 | while (true) { 181 | skipWhitespace() 182 | if (isClauseCombinator()) { 183 | result.simples[result.simples.length - 1].combinator = tokens[pos++] 184 | .value as ClauseCombinator 185 | skipWhitespace() 186 | } else if (isSelectorClauseEnd()) { 187 | break 188 | } 189 | result.simples.push({ combinator: '', selector: consumeSimpleSelector() }) 190 | } 191 | return result 192 | } 193 | 194 | function consumeSimpleSelector(): CSSSimpleSelector { 195 | let rawCSSString = '' 196 | const functions: CSSFunction[] = [] 197 | 198 | while (!isSelectorClauseEnd()) { 199 | if (isIdent() || isStar()) { 200 | rawCSSString += tokens[pos++].toSource() 201 | } else if (tokens[pos] instanceof css.HashToken) { 202 | rawCSSString += tokens[pos++].toSource() 203 | } else if ( 204 | tokens[pos] instanceof css.DelimToken && 205 | tokens[pos].value === '.' 206 | ) { 207 | pos++ 208 | if (isIdent()) rawCSSString += '.' + tokens[pos++].toSource() 209 | else throw unexpected() 210 | } else if (tokens[pos] instanceof css.ColonToken) { 211 | pos++ 212 | if (isIdent()) { 213 | if (!customNames.has((tokens[pos].value as string).toLowerCase())) { 214 | rawCSSString += ':' + tokens[pos++].toSource() 215 | } else { 216 | const name = (tokens[pos++].value as string).toLowerCase() 217 | functions.push({ name, args: [] }) 218 | names.add(name) 219 | } 220 | } else if (isFunction()) { 221 | const name = (tokens[pos++].value as string).toLowerCase() 222 | if (!customNames.has(name)) { 223 | rawCSSString += `:${name}(${consumeBuiltinFunctionArguments()})` 224 | } else { 225 | functions.push({ name, args: consumeFunctionArguments() }) 226 | names.add(name) 227 | } 228 | skipWhitespace() 229 | if (!isCloseParen()) throw unexpected() 230 | pos++ 231 | } else { 232 | throw unexpected() 233 | } 234 | } else if (tokens[pos] instanceof css.OpenSquareToken) { 235 | rawCSSString += '[' 236 | pos++ 237 | while (!(tokens[pos] instanceof css.CloseSquareToken) && !isEOF()) 238 | rawCSSString += tokens[pos++].toSource() 239 | if (!(tokens[pos] instanceof css.CloseSquareToken)) throw unexpected() 240 | rawCSSString += ']' 241 | pos++ 242 | } else { 243 | throw unexpected() 244 | } 245 | } 246 | if (!rawCSSString && !functions.length) throw unexpected() 247 | return { css: rawCSSString || undefined, functions } 248 | } 249 | 250 | function consumeBuiltinFunctionArguments(): string { 251 | let s = '' 252 | let balance = 1 // First open paren is a part of a function token. 253 | while (!isEOF()) { 254 | if (isOpenParen() || isFunction()) balance++ 255 | if (isCloseParen()) balance-- 256 | if (!balance) break 257 | s += tokens[pos++].toSource() 258 | } 259 | return s 260 | } 261 | 262 | const result = consumeFunctionArguments() 263 | if (!isEOF()) throw unexpected() 264 | if (result.some((arg) => typeof arg !== 'object' || !('simples' in arg))) 265 | throw new InvalidSelectorError(`Error while parsing selector "${selector}"`) 266 | return { selector: result as CSSComplexSelector[], names: Array.from(names) } 267 | } 268 | 269 | export function serializeSelector(args: CSSFunctionArgument[]) { 270 | return args 271 | .map((arg) => { 272 | if (typeof arg === 'string') return `"${arg}"` 273 | if (typeof arg === 'number') return String(arg) 274 | return arg.simples 275 | .map(({ selector, combinator }) => { 276 | let s = selector.css || '' 277 | s = 278 | s + 279 | selector.functions 280 | .map((func) => `:${func.name}(${serializeSelector(func.args)})`) 281 | .join('') 282 | if (combinator) s += ' ' + combinator 283 | return s 284 | }) 285 | .join(' ') 286 | }) 287 | .join(', ') 288 | } 289 | -------------------------------------------------------------------------------- /src/recorder/utils/isomorphic/css-tokenizer.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable notice/notice */ 2 | 3 | /* 4 | * The code in this file is licensed under the CC0 license. 5 | * http://creativecommons.org/publicdomain/zero/1.0/ 6 | * It is free to use for any purpose. No attribution, permission, or reproduction of this license is required. 7 | */ 8 | 9 | // Original at https://github.com/tabatkins/parse-css 10 | // Changes: 11 | // - JS is replaced with TS. 12 | // - Universal Module Definition wrapper is removed. 13 | // - Everything not related to tokenizing - below the first exports block - is removed. 14 | 15 | export interface CSSTokenInterface { 16 | toSource(): string; 17 | value: string | number | undefined; 18 | } 19 | 20 | const between = function(num: number, first: number, last: number) { return num >= first && num <= last; }; 21 | function digit(code: number) { return between(code, 0x30, 0x39); } 22 | function hexdigit(code: number) { return digit(code) || between(code, 0x41, 0x46) || between(code, 0x61, 0x66); } 23 | function uppercaseletter(code: number) { return between(code, 0x41, 0x5a); } 24 | function lowercaseletter(code: number) { return between(code, 0x61, 0x7a); } 25 | function letter(code: number) { return uppercaseletter(code) || lowercaseletter(code); } 26 | function nonascii(code: number) { return code >= 0x80; } 27 | function namestartchar(code: number) { return letter(code) || nonascii(code) || code === 0x5f; } 28 | function namechar(code: number) { return namestartchar(code) || digit(code) || code === 0x2d; } 29 | function nonprintable(code: number) { return between(code, 0, 8) || code === 0xb || between(code, 0xe, 0x1f) || code === 0x7f; } 30 | function newline(code: number) { return code === 0xa; } 31 | function whitespace(code: number) { return newline(code) || code === 9 || code === 0x20; } 32 | 33 | const maximumallowedcodepoint = 0x10ffff; 34 | 35 | export class InvalidCharacterError extends Error { 36 | constructor(message: string) { 37 | super(message); 38 | this.name = 'InvalidCharacterError'; 39 | } 40 | } 41 | 42 | function preprocess(str: string): number[] { 43 | // Turn a string into an array of code points, 44 | // following the preprocessing cleanup rules. 45 | const codepoints = []; 46 | for (let i = 0; i < str.length; i++) { 47 | let code = str.charCodeAt(i); 48 | if (code === 0xd && str.charCodeAt(i + 1) === 0xa) { 49 | code = 0xa; i++; 50 | } 51 | if (code === 0xd || code === 0xc) 52 | code = 0xa; 53 | if (code === 0x0) 54 | code = 0xfffd; 55 | if (between(code, 0xd800, 0xdbff) && between(str.charCodeAt(i + 1), 0xdc00, 0xdfff)) { 56 | // Decode a surrogate pair into an astral codepoint. 57 | const lead = code - 0xd800; 58 | const trail = str.charCodeAt(i + 1) - 0xdc00; 59 | code = Math.pow(2, 16) + lead * Math.pow(2, 10) + trail; 60 | i++; 61 | } 62 | codepoints.push(code); 63 | } 64 | return codepoints; 65 | } 66 | 67 | function stringFromCode(code: number) { 68 | if (code <= 0xffff) 69 | return String.fromCharCode(code); 70 | // Otherwise, encode astral char as surrogate pair. 71 | code -= Math.pow(2, 16); 72 | const lead = Math.floor(code / Math.pow(2, 10)) + 0xd800; 73 | const trail = code % Math.pow(2, 10) + 0xdc00; 74 | return String.fromCharCode(lead) + String.fromCharCode(trail); 75 | } 76 | 77 | export function tokenize(str1: string): CSSTokenInterface[] { 78 | const str = preprocess(str1); 79 | let i = -1; 80 | const tokens: CSSTokenInterface[] = []; 81 | let code: number; 82 | 83 | // Line number information. 84 | let line = 0; 85 | let column = 0; 86 | // The only use of lastLineLength is in reconsume(). 87 | let lastLineLength = 0; 88 | const incrLineno = function() { 89 | line += 1; 90 | lastLineLength = column; 91 | column = 0; 92 | }; 93 | const locStart = { line: line, column: column }; 94 | 95 | const codepoint = function(i: number): number { 96 | if (i >= str.length) 97 | return -1; 98 | 99 | return str[i]; 100 | }; 101 | const next = function(num?: number) { 102 | if (num === undefined) 103 | num = 1; 104 | if (num > 3) 105 | throw 'Spec Error: no more than three codepoints of lookahead.'; 106 | return codepoint(i + num); 107 | }; 108 | const consume = function(num?: number): boolean { 109 | if (num === undefined) 110 | num = 1; 111 | i += num; 112 | code = codepoint(i); 113 | if (newline(code)) 114 | incrLineno(); 115 | else 116 | column += num; 117 | // console.log('Consume '+i+' '+String.fromCharCode(code) + ' 0x' + code.toString(16)); 118 | return true; 119 | }; 120 | const reconsume = function() { 121 | i -= 1; 122 | if (newline(code)) { 123 | line -= 1; 124 | column = lastLineLength; 125 | } else { 126 | column -= 1; 127 | } 128 | locStart.line = line; 129 | locStart.column = column; 130 | return true; 131 | }; 132 | const eof = function(codepoint?: number): boolean { 133 | if (codepoint === undefined) 134 | codepoint = code; 135 | return codepoint === -1; 136 | }; 137 | const donothing = function() { }; 138 | const parseerror = function() { 139 | // Language bindings don't like writing to stdout! 140 | // console.log('Parse error at index ' + i + ', processing codepoint 0x' + code.toString(16) + '.'); return true; 141 | }; 142 | 143 | const consumeAToken = function(): CSSTokenInterface { 144 | consumeComments(); 145 | consume(); 146 | if (whitespace(code)) { 147 | while (whitespace(next())) 148 | consume(); 149 | return new WhitespaceToken(); 150 | } else if (code === 0x22) {return consumeAStringToken();} else if (code === 0x23) { 151 | if (namechar(next()) || areAValidEscape(next(1), next(2))) { 152 | const token = new HashToken(''); 153 | if (wouldStartAnIdentifier(next(1), next(2), next(3))) 154 | token.type = 'id'; 155 | token.value = consumeAName(); 156 | return token; 157 | } else { 158 | return new DelimToken(code); 159 | } 160 | } else if (code === 0x24) { 161 | if (next() === 0x3d) { 162 | consume(); 163 | return new SuffixMatchToken(); 164 | } else { 165 | return new DelimToken(code); 166 | } 167 | } else if (code === 0x27) {return consumeAStringToken();} else if (code === 0x28) {return new OpenParenToken();} else if (code === 0x29) {return new CloseParenToken();} else if (code === 0x2a) { 168 | if (next() === 0x3d) { 169 | consume(); 170 | return new SubstringMatchToken(); 171 | } else { 172 | return new DelimToken(code); 173 | } 174 | } else if (code === 0x2b) { 175 | if (startsWithANumber()) { 176 | reconsume(); 177 | return consumeANumericToken(); 178 | } else { 179 | return new DelimToken(code); 180 | } 181 | } else if (code === 0x2c) {return new CommaToken();} else if (code === 0x2d) { 182 | if (startsWithANumber()) { 183 | reconsume(); 184 | return consumeANumericToken(); 185 | } else if (next(1) === 0x2d && next(2) === 0x3e) { 186 | consume(2); 187 | return new CDCToken(); 188 | } else if (startsWithAnIdentifier()) { 189 | reconsume(); 190 | return consumeAnIdentlikeToken(); 191 | } else { 192 | return new DelimToken(code); 193 | } 194 | } else if (code === 0x2e) { 195 | if (startsWithANumber()) { 196 | reconsume(); 197 | return consumeANumericToken(); 198 | } else { 199 | return new DelimToken(code); 200 | } 201 | } else if (code === 0x3a) {return new ColonToken();} else if (code === 0x3b) {return new SemicolonToken();} else if (code === 0x3c) { 202 | if (next(1) === 0x21 && next(2) === 0x2d && next(3) === 0x2d) { 203 | consume(3); 204 | return new CDOToken(); 205 | } else { 206 | return new DelimToken(code); 207 | } 208 | } else if (code === 0x40) { 209 | if (wouldStartAnIdentifier(next(1), next(2), next(3))) 210 | return new AtKeywordToken(consumeAName()); 211 | else 212 | return new DelimToken(code); 213 | 214 | } else if (code === 0x5b) {return new OpenSquareToken();} else if (code === 0x5c) { 215 | if (startsWithAValidEscape()) { 216 | reconsume(); 217 | return consumeAnIdentlikeToken(); 218 | } else { 219 | parseerror(); 220 | return new DelimToken(code); 221 | } 222 | } else if (code === 0x5d) {return new CloseSquareToken();} else if (code === 0x5e) { 223 | if (next() === 0x3d) { 224 | consume(); 225 | return new PrefixMatchToken(); 226 | } else { 227 | return new DelimToken(code); 228 | } 229 | } else if (code === 0x7b) {return new OpenCurlyToken();} else if (code === 0x7c) { 230 | if (next() === 0x3d) { 231 | consume(); 232 | return new DashMatchToken(); 233 | } else if (next() === 0x7c) { 234 | consume(); 235 | return new ColumnToken(); 236 | } else { 237 | return new DelimToken(code); 238 | } 239 | } else if (code === 0x7d) {return new CloseCurlyToken();} else if (code === 0x7e) { 240 | if (next() === 0x3d) { 241 | consume(); 242 | return new IncludeMatchToken(); 243 | } else { 244 | return new DelimToken(code); 245 | } 246 | } else if (digit(code)) { 247 | reconsume(); 248 | return consumeANumericToken(); 249 | } else if (namestartchar(code)) { 250 | reconsume(); 251 | return consumeAnIdentlikeToken(); 252 | } else if (eof()) {return new EOFToken();} else {return new DelimToken(code);} 253 | }; 254 | 255 | const consumeComments = function() { 256 | while (next(1) === 0x2f && next(2) === 0x2a) { 257 | consume(2); 258 | while (true) { 259 | consume(); 260 | if (code === 0x2a && next() === 0x2f) { 261 | consume(); 262 | break; 263 | } else if (eof()) { 264 | parseerror(); 265 | return; 266 | } 267 | } 268 | } 269 | }; 270 | 271 | const consumeANumericToken = function() { 272 | const num = consumeANumber(); 273 | if (wouldStartAnIdentifier(next(1), next(2), next(3))) { 274 | const token = new DimensionToken(); 275 | token.value = num.value; 276 | token.repr = num.repr; 277 | token.type = num.type; 278 | token.unit = consumeAName(); 279 | return token; 280 | } else if (next() === 0x25) { 281 | consume(); 282 | const token = new PercentageToken(); 283 | token.value = num.value; 284 | token.repr = num.repr; 285 | return token; 286 | } else { 287 | const token = new NumberToken(); 288 | token.value = num.value; 289 | token.repr = num.repr; 290 | token.type = num.type; 291 | return token; 292 | } 293 | }; 294 | 295 | const consumeAnIdentlikeToken = function(): CSSTokenInterface { 296 | const str = consumeAName(); 297 | if (str.toLowerCase() === 'url' && next() === 0x28) { 298 | consume(); 299 | while (whitespace(next(1)) && whitespace(next(2))) 300 | consume(); 301 | if (next() === 0x22 || next() === 0x27) 302 | return new FunctionToken(str); 303 | else if (whitespace(next()) && (next(2) === 0x22 || next(2) === 0x27)) 304 | return new FunctionToken(str); 305 | else 306 | return consumeAURLToken(); 307 | 308 | } else if (next() === 0x28) { 309 | consume(); 310 | return new FunctionToken(str); 311 | } else { 312 | return new IdentToken(str); 313 | } 314 | }; 315 | 316 | const consumeAStringToken = function(endingCodePoint?: number): CSSParserToken { 317 | if (endingCodePoint === undefined) 318 | endingCodePoint = code; 319 | let string = ''; 320 | while (consume()) { 321 | if (code === endingCodePoint || eof()) { 322 | return new StringToken(string); 323 | } else if (newline(code)) { 324 | parseerror(); 325 | reconsume(); 326 | return new BadStringToken(); 327 | } else if (code === 0x5c) { 328 | if (eof(next())) 329 | donothing(); 330 | else if (newline(next())) 331 | consume(); 332 | else 333 | string += stringFromCode(consumeEscape()); 334 | 335 | } else { 336 | string += stringFromCode(code); 337 | } 338 | } 339 | throw new Error('Internal error'); 340 | }; 341 | 342 | const consumeAURLToken = function(): CSSTokenInterface { 343 | const token = new URLToken(''); 344 | while (whitespace(next())) 345 | consume(); 346 | if (eof(next())) 347 | return token; 348 | while (consume()) { 349 | if (code === 0x29 || eof()) { 350 | return token; 351 | } else if (whitespace(code)) { 352 | while (whitespace(next())) 353 | consume(); 354 | if (next() === 0x29 || eof(next())) { 355 | consume(); 356 | return token; 357 | } else { 358 | consumeTheRemnantsOfABadURL(); 359 | return new BadURLToken(); 360 | } 361 | } else if (code === 0x22 || code === 0x27 || code === 0x28 || nonprintable(code)) { 362 | parseerror(); 363 | consumeTheRemnantsOfABadURL(); 364 | return new BadURLToken(); 365 | } else if (code === 0x5c) { 366 | if (startsWithAValidEscape()) { 367 | token.value += stringFromCode(consumeEscape()); 368 | } else { 369 | parseerror(); 370 | consumeTheRemnantsOfABadURL(); 371 | return new BadURLToken(); 372 | } 373 | } else { 374 | token.value += stringFromCode(code); 375 | } 376 | } 377 | throw new Error('Internal error'); 378 | }; 379 | 380 | const consumeEscape = function() { 381 | // Assume the current character is the \ 382 | // and the next code point is not a newline. 383 | consume(); 384 | if (hexdigit(code)) { 385 | // Consume 1-6 hex digits 386 | const digits = [code]; 387 | for (let total = 0; total < 5; total++) { 388 | if (hexdigit(next())) { 389 | consume(); 390 | digits.push(code); 391 | } else { 392 | break; 393 | } 394 | } 395 | if (whitespace(next())) 396 | consume(); 397 | let value = parseInt(digits.map(function(x) { return String.fromCharCode(x); }).join(''), 16); 398 | if (value > maximumallowedcodepoint) 399 | value = 0xfffd; 400 | return value; 401 | } else if (eof()) { 402 | return 0xfffd; 403 | } else { 404 | return code; 405 | } 406 | }; 407 | 408 | const areAValidEscape = function(c1: number, c2: number) { 409 | if (c1 !== 0x5c) 410 | return false; 411 | if (newline(c2)) 412 | return false; 413 | return true; 414 | }; 415 | const startsWithAValidEscape = function() { 416 | return areAValidEscape(code, next()); 417 | }; 418 | 419 | const wouldStartAnIdentifier = function(c1: number, c2: number, c3: number) { 420 | if (c1 === 0x2d) 421 | return namestartchar(c2) || c2 === 0x2d || areAValidEscape(c2, c3); 422 | else if (namestartchar(c1)) 423 | return true; 424 | else if (c1 === 0x5c) 425 | return areAValidEscape(c1, c2); 426 | else 427 | return false; 428 | 429 | }; 430 | const startsWithAnIdentifier = function() { 431 | return wouldStartAnIdentifier(code, next(1), next(2)); 432 | }; 433 | 434 | const wouldStartANumber = function(c1: number, c2: number, c3: number) { 435 | if (c1 === 0x2b || c1 === 0x2d) { 436 | if (digit(c2)) 437 | return true; 438 | if (c2 === 0x2e && digit(c3)) 439 | return true; 440 | return false; 441 | } else if (c1 === 0x2e) { 442 | if (digit(c2)) 443 | return true; 444 | return false; 445 | } else if (digit(c1)) { 446 | return true; 447 | } else { 448 | return false; 449 | } 450 | }; 451 | const startsWithANumber = function() { 452 | return wouldStartANumber(code, next(1), next(2)); 453 | }; 454 | 455 | const consumeAName = function(): string { 456 | let result = ''; 457 | while (consume()) { 458 | if (namechar(code)) { 459 | result += stringFromCode(code); 460 | } else if (startsWithAValidEscape()) { 461 | result += stringFromCode(consumeEscape()); 462 | } else { 463 | reconsume(); 464 | return result; 465 | } 466 | } 467 | throw new Error('Internal parse error'); 468 | }; 469 | 470 | const consumeANumber = function() { 471 | let repr = ''; 472 | let type = 'integer'; 473 | if (next() === 0x2b || next() === 0x2d) { 474 | consume(); 475 | repr += stringFromCode(code); 476 | } 477 | while (digit(next())) { 478 | consume(); 479 | repr += stringFromCode(code); 480 | } 481 | if (next(1) === 0x2e && digit(next(2))) { 482 | consume(); 483 | repr += stringFromCode(code); 484 | consume(); 485 | repr += stringFromCode(code); 486 | type = 'number'; 487 | while (digit(next())) { 488 | consume(); 489 | repr += stringFromCode(code); 490 | } 491 | } 492 | const c1 = next(1), c2 = next(2), c3 = next(3); 493 | if ((c1 === 0x45 || c1 === 0x65) && digit(c2)) { 494 | consume(); 495 | repr += stringFromCode(code); 496 | consume(); 497 | repr += stringFromCode(code); 498 | type = 'number'; 499 | while (digit(next())) { 500 | consume(); 501 | repr += stringFromCode(code); 502 | } 503 | } else if ((c1 === 0x45 || c1 === 0x65) && (c2 === 0x2b || c2 === 0x2d) && digit(c3)) { 504 | consume(); 505 | repr += stringFromCode(code); 506 | consume(); 507 | repr += stringFromCode(code); 508 | consume(); 509 | repr += stringFromCode(code); 510 | type = 'number'; 511 | while (digit(next())) { 512 | consume(); 513 | repr += stringFromCode(code); 514 | } 515 | } 516 | const value = convertAStringToANumber(repr); 517 | return { type: type, value: value, repr: repr }; 518 | }; 519 | 520 | const convertAStringToANumber = function(string: string): number { 521 | // CSS's number rules are identical to JS, afaik. 522 | return +string; 523 | }; 524 | 525 | const consumeTheRemnantsOfABadURL = function() { 526 | while (consume()) { 527 | if (code === 0x29 || eof()) { 528 | return; 529 | } else if (startsWithAValidEscape()) { 530 | consumeEscape(); 531 | donothing(); 532 | } else { 533 | donothing(); 534 | } 535 | } 536 | }; 537 | 538 | let iterationCount = 0; 539 | while (!eof(next())) { 540 | tokens.push(consumeAToken()); 541 | iterationCount++; 542 | if (iterationCount > str.length * 2) 543 | throw new Error("I'm infinite-looping!"); 544 | } 545 | return tokens; 546 | } 547 | 548 | export class CSSParserToken implements CSSTokenInterface { 549 | tokenType = ''; 550 | value: string | number | undefined; 551 | toJSON(): any { 552 | return { token: this.tokenType }; 553 | } 554 | toString() { return this.tokenType; } 555 | toSource() { return '' + this; } 556 | } 557 | 558 | export class BadStringToken extends CSSParserToken { 559 | override tokenType = 'BADSTRING'; 560 | } 561 | 562 | export class BadURLToken extends CSSParserToken { 563 | override tokenType = 'BADURL'; 564 | } 565 | 566 | export class WhitespaceToken extends CSSParserToken { 567 | override tokenType = 'WHITESPACE'; 568 | override toString() { return 'WS'; } 569 | override toSource() { return ' '; } 570 | } 571 | 572 | export class CDOToken extends CSSParserToken { 573 | override tokenType = 'CDO'; 574 | override toSource() { return ''; } 580 | } 581 | 582 | export class ColonToken extends CSSParserToken { 583 | override tokenType = ':'; 584 | } 585 | 586 | export class SemicolonToken extends CSSParserToken { 587 | override tokenType = ';'; 588 | } 589 | 590 | export class CommaToken extends CSSParserToken { 591 | override tokenType = ','; 592 | } 593 | 594 | export class GroupingToken extends CSSParserToken { 595 | override value = ''; 596 | mirror = ''; 597 | } 598 | 599 | export class OpenCurlyToken extends GroupingToken { 600 | override tokenType = '{'; 601 | constructor() { 602 | super(); 603 | this.value = '{'; 604 | this.mirror = '}'; 605 | } 606 | } 607 | 608 | export class CloseCurlyToken extends GroupingToken { 609 | override tokenType = '}'; 610 | constructor() { 611 | super(); 612 | this.value = '}'; 613 | this.mirror = '{'; 614 | } 615 | } 616 | 617 | export class OpenSquareToken extends GroupingToken { 618 | override tokenType = '['; 619 | constructor() { 620 | super(); 621 | this.value = '['; 622 | this.mirror = ']'; 623 | } 624 | } 625 | 626 | export class CloseSquareToken extends GroupingToken { 627 | override tokenType = ']'; 628 | constructor() { 629 | super(); 630 | this.value = ']'; 631 | this.mirror = '['; 632 | } 633 | } 634 | 635 | export class OpenParenToken extends GroupingToken { 636 | override tokenType = '('; 637 | constructor() { 638 | super(); 639 | this.value = '('; 640 | this.mirror = ')'; 641 | } 642 | } 643 | 644 | export class CloseParenToken extends GroupingToken { 645 | override tokenType = ')'; 646 | constructor() { 647 | super(); 648 | this.value = ')'; 649 | this.mirror = '('; 650 | } 651 | } 652 | 653 | export class IncludeMatchToken extends CSSParserToken { 654 | override tokenType = '~='; 655 | } 656 | 657 | export class DashMatchToken extends CSSParserToken { 658 | override tokenType = '|='; 659 | } 660 | 661 | export class PrefixMatchToken extends CSSParserToken { 662 | override tokenType = '^='; 663 | } 664 | 665 | export class SuffixMatchToken extends CSSParserToken { 666 | override tokenType = '$='; 667 | } 668 | 669 | export class SubstringMatchToken extends CSSParserToken { 670 | override tokenType = '*='; 671 | } 672 | 673 | export class ColumnToken extends CSSParserToken { 674 | override tokenType = '||'; 675 | } 676 | 677 | export class EOFToken extends CSSParserToken { 678 | override tokenType = 'EOF'; 679 | override toSource() { return ''; } 680 | } 681 | 682 | export class DelimToken extends CSSParserToken { 683 | override tokenType = 'DELIM'; 684 | override value: string = ''; 685 | 686 | constructor(code: number) { 687 | super(); 688 | this.value = stringFromCode(code); 689 | } 690 | 691 | override toString() { return 'DELIM(' + this.value + ')'; } 692 | 693 | override toJSON() { 694 | const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); 695 | json.value = this.value; 696 | return json; 697 | } 698 | 699 | override toSource() { 700 | if (this.value === '\\') 701 | return '\\\n'; 702 | else 703 | return this.value; 704 | } 705 | } 706 | 707 | export abstract class StringValuedToken extends CSSParserToken { 708 | override value: string = ''; 709 | ASCIIMatch(str: string) { 710 | return this.value.toLowerCase() === str.toLowerCase(); 711 | } 712 | 713 | override toJSON() { 714 | const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); 715 | json.value = this.value; 716 | return json; 717 | } 718 | } 719 | 720 | export class IdentToken extends StringValuedToken { 721 | constructor(val: string) { 722 | super(); 723 | this.value = val; 724 | } 725 | 726 | override tokenType = 'IDENT'; 727 | override toString() { return 'IDENT(' + this.value + ')'; } 728 | override toSource() { 729 | return escapeIdent(this.value); 730 | } 731 | } 732 | 733 | export class FunctionToken extends StringValuedToken { 734 | override tokenType = 'FUNCTION'; 735 | mirror: string; 736 | constructor(val: string) { 737 | super(); 738 | this.value = val; 739 | this.mirror = ')'; 740 | } 741 | 742 | override toString() { return 'FUNCTION(' + this.value + ')'; } 743 | 744 | override toSource() { 745 | return escapeIdent(this.value) + '('; 746 | } 747 | } 748 | 749 | export class AtKeywordToken extends StringValuedToken { 750 | override tokenType = 'AT-KEYWORD'; 751 | constructor(val: string) { 752 | super(); 753 | this.value = val; 754 | } 755 | override toString() { return 'AT(' + this.value + ')'; } 756 | override toSource() { 757 | return '@' + escapeIdent(this.value); 758 | } 759 | } 760 | 761 | export class HashToken extends StringValuedToken { 762 | override tokenType = 'HASH'; 763 | type: string; 764 | constructor(val: string) { 765 | super(); 766 | this.value = val; 767 | this.type = 'unrestricted'; 768 | } 769 | 770 | override toString() { return 'HASH(' + this.value + ')'; } 771 | 772 | override toJSON() { 773 | const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); 774 | json.value = this.value; 775 | json.type = this.type; 776 | return json; 777 | } 778 | 779 | override toSource() { 780 | if (this.type === 'id') 781 | return '#' + escapeIdent(this.value); 782 | else 783 | return '#' + escapeHash(this.value); 784 | 785 | } 786 | } 787 | 788 | export class StringToken extends StringValuedToken { 789 | override tokenType = 'STRING'; 790 | constructor(val: string) { 791 | super(); 792 | this.value = val; 793 | } 794 | 795 | override toString() { 796 | return '"' + escapeString(this.value) + '"'; 797 | } 798 | } 799 | 800 | export class URLToken extends StringValuedToken { 801 | override tokenType = 'URL'; 802 | constructor(val: string) { 803 | super(); 804 | this.value = val; 805 | } 806 | override toString() { return 'URL(' + this.value + ')'; } 807 | override toSource() { 808 | return 'url("' + escapeString(this.value) + '")'; 809 | } 810 | } 811 | 812 | export class NumberToken extends CSSParserToken { 813 | override tokenType = 'NUMBER'; 814 | type: string; 815 | repr: string; 816 | 817 | constructor() { 818 | super(); 819 | this.type = 'integer'; 820 | this.repr = ''; 821 | } 822 | 823 | override toString() { 824 | if (this.type === 'integer') 825 | return 'INT(' + this.value + ')'; 826 | return 'NUMBER(' + this.value + ')'; 827 | } 828 | override toJSON() { 829 | const json = super.toJSON(); 830 | json.value = this.value; 831 | json.type = this.type; 832 | json.repr = this.repr; 833 | return json; 834 | } 835 | override toSource() { return this.repr; } 836 | } 837 | 838 | 839 | export class PercentageToken extends CSSParserToken { 840 | override tokenType = 'PERCENTAGE'; 841 | repr: string; 842 | constructor() { 843 | super(); 844 | this.repr = ''; 845 | } 846 | override toString() { return 'PERCENTAGE(' + this.value + ')'; } 847 | override toJSON() { 848 | const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); 849 | json.value = this.value; 850 | json.repr = this.repr; 851 | return json; 852 | } 853 | override toSource() { return this.repr + '%'; } 854 | } 855 | 856 | export class DimensionToken extends CSSParserToken { 857 | override tokenType = 'DIMENSION'; 858 | type: string; 859 | repr: string; 860 | unit: string; 861 | 862 | constructor() { 863 | super(); 864 | this.type = 'integer'; 865 | this.repr = ''; 866 | this.unit = ''; 867 | } 868 | 869 | override toString() { return 'DIM(' + this.value + ',' + this.unit + ')'; } 870 | override toJSON() { 871 | const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); 872 | json.value = this.value; 873 | json.type = this.type; 874 | json.repr = this.repr; 875 | json.unit = this.unit; 876 | return json; 877 | } 878 | override toSource() { 879 | const source = this.repr; 880 | let unit = escapeIdent(this.unit); 881 | if (unit[0].toLowerCase() === 'e' && (unit[1] === '-' || between(unit.charCodeAt(1), 0x30, 0x39))) { 882 | // Unit is ambiguous with scinot 883 | // Remove the leading "e", replace with escape. 884 | unit = '\\65 ' + unit.slice(1, unit.length); 885 | } 886 | return source + unit; 887 | } 888 | } 889 | 890 | function escapeIdent(string: string) { 891 | string = '' + string; 892 | let result = ''; 893 | const firstcode = string.charCodeAt(0); 894 | for (let i = 0; i < string.length; i++) { 895 | const code = string.charCodeAt(i); 896 | if (code === 0x0) 897 | throw new InvalidCharacterError('Invalid character: the input contains U+0000.'); 898 | 899 | if ( 900 | between(code, 0x1, 0x1f) || code === 0x7f || 901 | (i === 0 && between(code, 0x30, 0x39)) || 902 | (i === 1 && between(code, 0x30, 0x39) && firstcode === 0x2d) 903 | ) 904 | result += '\\' + code.toString(16) + ' '; 905 | else if ( 906 | code >= 0x80 || 907 | code === 0x2d || 908 | code === 0x5f || 909 | between(code, 0x30, 0x39) || 910 | between(code, 0x41, 0x5a) || 911 | between(code, 0x61, 0x7a) 912 | ) 913 | result += string[i]; 914 | else 915 | result += '\\' + string[i]; 916 | 917 | } 918 | return result; 919 | } 920 | 921 | function escapeHash(string: string) { 922 | // Escapes the contents of "unrestricted"-type hash tokens. 923 | // Won't preserve the ID-ness of "id"-type hash tokens; 924 | // use escapeIdent() for that. 925 | string = '' + string; 926 | let result = ''; 927 | for (let i = 0; i < string.length; i++) { 928 | const code = string.charCodeAt(i); 929 | if (code === 0x0) 930 | throw new InvalidCharacterError('Invalid character: the input contains U+0000.'); 931 | 932 | if ( 933 | code >= 0x80 || 934 | code === 0x2d || 935 | code === 0x5f || 936 | between(code, 0x30, 0x39) || 937 | between(code, 0x41, 0x5a) || 938 | between(code, 0x61, 0x7a) 939 | ) 940 | result += string[i]; 941 | else 942 | result += '\\' + code.toString(16) + ' '; 943 | 944 | } 945 | return result; 946 | } 947 | 948 | function escapeString(string: string) { 949 | string = '' + string; 950 | let result = ''; 951 | for (let i = 0; i < string.length; i++) { 952 | const code = string.charCodeAt(i); 953 | 954 | if (code === 0x0) 955 | throw new InvalidCharacterError('Invalid character: the input contains U+0000.'); 956 | 957 | if (between(code, 0x1, 0x1f) || code === 0x7f) 958 | result += '\\' + code.toString(16) + ' '; 959 | else if (code === 0x22 || code === 0x5c) 960 | result += '\\' + string[i]; 961 | else 962 | result += string[i]; 963 | 964 | } 965 | return result; 966 | } 967 | -------------------------------------------------------------------------------- /src/recorder/utils/isomorphic/locator-generators.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { 18 | escapeWithQuotes, 19 | normalizeEscapedRegexQuotes, 20 | toSnakeCase, 21 | toTitleCase, 22 | } from './string-utils' 23 | import { 24 | type NestedSelectorBody, 25 | parseAttributeSelector, 26 | parseSelector, 27 | stringifySelector, 28 | } from './selector-parser' 29 | import type { ParsedSelector } from './selector-parser' 30 | 31 | export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl' 32 | export type LocatorType = 33 | | 'default' 34 | | 'role' 35 | | 'text' 36 | | 'label' 37 | | 'placeholder' 38 | | 'alt' 39 | | 'title' 40 | | 'test-id' 41 | | 'nth' 42 | | 'first' 43 | | 'last' 44 | | 'has-text' 45 | | 'has-not-text' 46 | | 'has' 47 | | 'hasNot' 48 | | 'frame' 49 | | 'and' 50 | | 'or' 51 | | 'chain' 52 | export type LocatorBase = 'page' | 'locator' | 'frame-locator' 53 | export type Quote = "'" | '"' | '`' 54 | 55 | type LocatorOptions = { 56 | attrs?: { name: string; value: string | boolean | number }[] 57 | exact?: boolean 58 | name?: string | RegExp 59 | hasText?: string | RegExp 60 | hasNotText?: string | RegExp 61 | } 62 | export interface LocatorFactory { 63 | generateLocator( 64 | base: LocatorBase, 65 | kind: LocatorType, 66 | body: string | RegExp, 67 | options?: LocatorOptions 68 | ): string 69 | chainLocators(locators: string[]): string 70 | } 71 | 72 | export function asLocator( 73 | lang: Language, 74 | selector: string, 75 | isFrameLocator: boolean = false 76 | ): string { 77 | return asLocators(lang, selector, isFrameLocator)[0] 78 | } 79 | 80 | export function asLocators( 81 | lang: Language, 82 | selector: string, 83 | isFrameLocator: boolean = false, 84 | maxOutputSize = 20, 85 | preferredQuote?: Quote 86 | ): string[] { 87 | try { 88 | return innerAsLocators( 89 | new generators[lang](preferredQuote), 90 | parseSelector(selector), 91 | isFrameLocator, 92 | maxOutputSize 93 | ) 94 | } catch (e) { 95 | // Tolerate invalid input. 96 | return [selector] 97 | } 98 | } 99 | 100 | function innerAsLocators( 101 | factory: LocatorFactory, 102 | parsed: ParsedSelector, 103 | isFrameLocator: boolean = false, 104 | maxOutputSize = 20 105 | ): string[] { 106 | const parts = [...parsed.parts] 107 | // frameLocator('iframe').first is actually "iframe >> nth=0 >> internal:control=enter-frame" 108 | // To make it easier to parse, we turn it into "iframe >> internal:control=enter-frame >> nth=0" 109 | for (let index = 0; index < parts.length - 1; index++) { 110 | if ( 111 | parts[index].name === 'nth' && 112 | parts[index + 1].name === 'internal:control' && 113 | (parts[index + 1].body as string) === 'enter-frame' 114 | ) { 115 | // Swap nth and enter-frame. 116 | const [nth] = parts.splice(index, 1) 117 | parts.splice(index + 1, 0, nth) 118 | } 119 | } 120 | 121 | const tokens: string[][] = [] 122 | let nextBase: LocatorBase = isFrameLocator ? 'frame-locator' : 'page' 123 | for (let index = 0; index < parts.length; index++) { 124 | const part = parts[index] 125 | const base = nextBase 126 | nextBase = 'locator' 127 | 128 | if (part.name === 'nth') { 129 | if (part.body === '0') 130 | tokens.push([ 131 | factory.generateLocator(base, 'first', ''), 132 | factory.generateLocator(base, 'nth', '0'), 133 | ]) 134 | else if (part.body === '-1') 135 | tokens.push([ 136 | factory.generateLocator(base, 'last', ''), 137 | factory.generateLocator(base, 'nth', '-1'), 138 | ]) 139 | else 140 | tokens.push([factory.generateLocator(base, 'nth', part.body as string)]) 141 | continue 142 | } 143 | if (part.name === 'internal:text') { 144 | const { exact, text } = detectExact(part.body as string) 145 | tokens.push([factory.generateLocator(base, 'text', text, { exact })]) 146 | continue 147 | } 148 | if (part.name === 'internal:has-text') { 149 | const { exact, text } = detectExact(part.body as string) 150 | // There is no locator equivalent for strict has-text, leave it as is. 151 | if (!exact) { 152 | tokens.push([ 153 | factory.generateLocator(base, 'has-text', text, { exact }), 154 | ]) 155 | continue 156 | } 157 | } 158 | if (part.name === 'internal:has-not-text') { 159 | const { exact, text } = detectExact(part.body as string) 160 | // There is no locator equivalent for strict has-not-text, leave it as is. 161 | if (!exact) { 162 | tokens.push([ 163 | factory.generateLocator(base, 'has-not-text', text, { exact }), 164 | ]) 165 | continue 166 | } 167 | } 168 | if (part.name === 'internal:has') { 169 | const inners = innerAsLocators( 170 | factory, 171 | (part.body as NestedSelectorBody).parsed, 172 | false, 173 | maxOutputSize 174 | ) 175 | tokens.push( 176 | inners.map((inner) => factory.generateLocator(base, 'has', inner)) 177 | ) 178 | continue 179 | } 180 | if (part.name === 'internal:has-not') { 181 | const inners = innerAsLocators( 182 | factory, 183 | (part.body as NestedSelectorBody).parsed, 184 | false, 185 | maxOutputSize 186 | ) 187 | tokens.push( 188 | inners.map((inner) => factory.generateLocator(base, 'hasNot', inner)) 189 | ) 190 | continue 191 | } 192 | if (part.name === 'internal:and') { 193 | const inners = innerAsLocators( 194 | factory, 195 | (part.body as NestedSelectorBody).parsed, 196 | false, 197 | maxOutputSize 198 | ) 199 | tokens.push( 200 | inners.map((inner) => factory.generateLocator(base, 'and', inner)) 201 | ) 202 | continue 203 | } 204 | if (part.name === 'internal:or') { 205 | const inners = innerAsLocators( 206 | factory, 207 | (part.body as NestedSelectorBody).parsed, 208 | false, 209 | maxOutputSize 210 | ) 211 | tokens.push( 212 | inners.map((inner) => factory.generateLocator(base, 'or', inner)) 213 | ) 214 | continue 215 | } 216 | if (part.name === 'internal:chain') { 217 | const inners = innerAsLocators( 218 | factory, 219 | (part.body as NestedSelectorBody).parsed, 220 | false, 221 | maxOutputSize 222 | ) 223 | tokens.push( 224 | inners.map((inner) => factory.generateLocator(base, 'chain', inner)) 225 | ) 226 | continue 227 | } 228 | if (part.name === 'internal:label') { 229 | const { exact, text } = detectExact(part.body as string) 230 | tokens.push([factory.generateLocator(base, 'label', text, { exact })]) 231 | continue 232 | } 233 | if (part.name === 'internal:role') { 234 | const attrSelector = parseAttributeSelector(part.body as string, true) 235 | const options: LocatorOptions = { attrs: [] } 236 | for (const attr of attrSelector.attributes) { 237 | if (attr.name === 'name') { 238 | options.exact = attr.caseSensitive 239 | options.name = attr.value 240 | } else { 241 | if (attr.name === 'level' && typeof attr.value === 'string') 242 | attr.value = +attr.value 243 | options.attrs!.push({ 244 | name: attr.name === 'include-hidden' ? 'includeHidden' : attr.name, 245 | value: attr.value, 246 | }) 247 | } 248 | } 249 | tokens.push([ 250 | factory.generateLocator(base, 'role', attrSelector.name, options), 251 | ]) 252 | continue 253 | } 254 | if (part.name === 'internal:testid') { 255 | const attrSelector = parseAttributeSelector(part.body as string, true) 256 | const { value } = attrSelector.attributes[0] 257 | tokens.push([factory.generateLocator(base, 'test-id', value)]) 258 | continue 259 | } 260 | if (part.name === 'internal:attr') { 261 | const attrSelector = parseAttributeSelector(part.body as string, true) 262 | const { name, value, caseSensitive } = attrSelector.attributes[0] 263 | const text = value as string | RegExp 264 | const exact = !!caseSensitive 265 | if (name === 'placeholder') { 266 | tokens.push([ 267 | factory.generateLocator(base, 'placeholder', text, { exact }), 268 | ]) 269 | continue 270 | } 271 | if (name === 'alt') { 272 | tokens.push([factory.generateLocator(base, 'alt', text, { exact })]) 273 | continue 274 | } 275 | if (name === 'title') { 276 | tokens.push([factory.generateLocator(base, 'title', text, { exact })]) 277 | continue 278 | } 279 | } 280 | 281 | let locatorType: LocatorType = 'default' 282 | 283 | const nextPart = parts[index + 1] 284 | if ( 285 | nextPart && 286 | nextPart.name === 'internal:control' && 287 | (nextPart.body as string) === 'enter-frame' 288 | ) { 289 | locatorType = 'frame' 290 | nextBase = 'frame-locator' 291 | index++ 292 | } 293 | 294 | const selectorPart = stringifySelector({ parts: [part] }) 295 | const locatorPart = factory.generateLocator(base, locatorType, selectorPart) 296 | 297 | if ( 298 | locatorType === 'default' && 299 | nextPart && 300 | ['internal:has-text', 'internal:has-not-text'].includes(nextPart.name) 301 | ) { 302 | const { exact, text } = detectExact(nextPart.body as string) 303 | // There is no locator equivalent for strict has-text and has-not-text, leave it as is. 304 | if (!exact) { 305 | const nextLocatorPart = factory.generateLocator( 306 | 'locator', 307 | nextPart.name === 'internal:has-text' ? 'has-text' : 'has-not-text', 308 | text, 309 | { exact } 310 | ) 311 | const options: LocatorOptions = {} 312 | if (nextPart.name === 'internal:has-text') options.hasText = text 313 | else options.hasNotText = text 314 | const combinedPart = factory.generateLocator( 315 | base, 316 | 'default', 317 | selectorPart, 318 | options 319 | ) 320 | // Two options: 321 | // - locator('div').filter({ hasText: 'foo' }) 322 | // - locator('div', { hasText: 'foo' }) 323 | tokens.push([ 324 | factory.chainLocators([locatorPart, nextLocatorPart]), 325 | combinedPart, 326 | ]) 327 | index++ 328 | continue 329 | } 330 | } 331 | 332 | // Selectors can be prefixed with engine name, e.g. xpath=//foo 333 | let locatorPartWithEngine: string | undefined 334 | if (['xpath', 'css'].includes(part.name)) { 335 | const selectorPart = stringifySelector( 336 | { parts: [part] }, 337 | /* forceEngineName */ true 338 | ) 339 | locatorPartWithEngine = factory.generateLocator( 340 | base, 341 | locatorType, 342 | selectorPart 343 | ) 344 | } 345 | 346 | tokens.push( 347 | [locatorPart, locatorPartWithEngine].filter(Boolean) as string[] 348 | ) 349 | } 350 | 351 | return combineTokens(factory, tokens, maxOutputSize) 352 | } 353 | 354 | function combineTokens( 355 | factory: LocatorFactory, 356 | tokens: string[][], 357 | maxOutputSize: number 358 | ): string[] { 359 | const currentTokens = tokens.map(() => '') 360 | const result: string[] = [] 361 | 362 | const visit = (index: number) => { 363 | if (index === tokens.length) { 364 | result.push(factory.chainLocators(currentTokens)) 365 | return currentTokens.length < maxOutputSize 366 | } 367 | for (const taken of tokens[index]) { 368 | currentTokens[index] = taken 369 | if (!visit(index + 1)) return false 370 | } 371 | return true 372 | } 373 | 374 | visit(0) 375 | return result 376 | } 377 | 378 | function detectExact(text: string): { exact?: boolean; text: string | RegExp } { 379 | let exact = false 380 | const match = text.match(/^\/(.*)\/([igm]*)$/) 381 | if (match) return { text: new RegExp(match[1], match[2]) } 382 | if (text.endsWith('"')) { 383 | text = JSON.parse(text) 384 | exact = true 385 | } else if (text.endsWith('"s')) { 386 | text = JSON.parse(text.substring(0, text.length - 1)) 387 | exact = true 388 | } else if (text.endsWith('"i')) { 389 | text = JSON.parse(text.substring(0, text.length - 1)) 390 | exact = false 391 | } 392 | return { exact, text } 393 | } 394 | 395 | export class JavaScriptLocatorFactory implements LocatorFactory { 396 | constructor(private preferredQuote?: Quote) {} 397 | 398 | generateLocator( 399 | base: LocatorBase, 400 | kind: LocatorType, 401 | body: string | RegExp, 402 | options: LocatorOptions = {} 403 | ): string { 404 | switch (kind) { 405 | case 'default': 406 | if (options.hasText !== undefined) 407 | return `locator(${this.quote(body as string)}, { hasText: ${this.toHasText(options.hasText)} })` 408 | if (options.hasNotText !== undefined) 409 | return `locator(${this.quote(body as string)}, { hasNotText: ${this.toHasText(options.hasNotText)} })` 410 | return `locator(${this.quote(body as string)})` 411 | case 'frame': 412 | return `frameLocator(${this.quote(body as string)})` 413 | case 'nth': 414 | return `nth(${body})` 415 | case 'first': 416 | return `first()` 417 | case 'last': 418 | return `last()` 419 | case 'role': 420 | const attrs: string[] = [] 421 | if (isRegExp(options.name)) { 422 | attrs.push(`name: ${this.regexToSourceString(options.name)}`) 423 | } else if (typeof options.name === 'string') { 424 | attrs.push(`name: ${this.quote(options.name)}`) 425 | if (options.exact) attrs.push(`exact: true`) 426 | } 427 | for (const { name, value } of options.attrs!) 428 | attrs.push( 429 | `${name}: ${typeof value === 'string' ? this.quote(value) : value}` 430 | ) 431 | const attrString = attrs.length ? `, { ${attrs.join(', ')} }` : '' 432 | return `getByRole(${this.quote(body as string)}${attrString})` 433 | case 'has-text': 434 | return `filter({ hasText: ${this.toHasText(body)} })` 435 | case 'has-not-text': 436 | return `filter({ hasNotText: ${this.toHasText(body)} })` 437 | case 'has': 438 | return `filter({ has: ${body} })` 439 | case 'hasNot': 440 | return `filter({ hasNot: ${body} })` 441 | case 'and': 442 | return `and(${body})` 443 | case 'or': 444 | return `or(${body})` 445 | case 'chain': 446 | return `locator(${body})` 447 | case 'test-id': 448 | return `getByTestId(${this.toTestIdValue(body)})` 449 | case 'text': 450 | return this.toCallWithExact('getByText', body, !!options.exact) 451 | case 'alt': 452 | return this.toCallWithExact('getByAltText', body, !!options.exact) 453 | case 'placeholder': 454 | return this.toCallWithExact('getByPlaceholder', body, !!options.exact) 455 | case 'label': 456 | return this.toCallWithExact('getByLabel', body, !!options.exact) 457 | case 'title': 458 | return this.toCallWithExact('getByTitle', body, !!options.exact) 459 | default: 460 | throw new Error('Unknown selector kind ' + kind) 461 | } 462 | } 463 | 464 | chainLocators(locators: string[]): string { 465 | return locators.join('.') 466 | } 467 | 468 | private regexToSourceString(re: RegExp) { 469 | return normalizeEscapedRegexQuotes(String(re)) 470 | } 471 | 472 | private toCallWithExact( 473 | method: string, 474 | body: string | RegExp, 475 | exact?: boolean 476 | ) { 477 | if (isRegExp(body)) return `${method}(${this.regexToSourceString(body)})` 478 | return exact 479 | ? `${method}(${this.quote(body)}, { exact: true })` 480 | : `${method}(${this.quote(body)})` 481 | } 482 | 483 | private toHasText(body: string | RegExp) { 484 | if (isRegExp(body)) return this.regexToSourceString(body) 485 | return this.quote(body) 486 | } 487 | 488 | private toTestIdValue(value: string | RegExp): string { 489 | if (isRegExp(value)) return this.regexToSourceString(value) 490 | return this.quote(value) 491 | } 492 | 493 | private quote(text: string) { 494 | return escapeWithQuotes(text, this.preferredQuote ?? "'") 495 | } 496 | } 497 | 498 | export class PythonLocatorFactory implements LocatorFactory { 499 | generateLocator( 500 | base: LocatorBase, 501 | kind: LocatorType, 502 | body: string | RegExp, 503 | options: LocatorOptions = {} 504 | ): string { 505 | switch (kind) { 506 | case 'default': 507 | if (options.hasText !== undefined) 508 | return `locator(${this.quote(body as string)}, has_text=${this.toHasText(options.hasText)})` 509 | if (options.hasNotText !== undefined) 510 | return `locator(${this.quote(body as string)}, has_not_text=${this.toHasText(options.hasNotText)})` 511 | return `locator(${this.quote(body as string)})` 512 | case 'frame': 513 | return `frame_locator(${this.quote(body as string)})` 514 | case 'nth': 515 | return `nth(${body})` 516 | case 'first': 517 | return `first` 518 | case 'last': 519 | return `last` 520 | case 'role': 521 | const attrs: string[] = [] 522 | if (isRegExp(options.name)) { 523 | attrs.push(`name=${this.regexToString(options.name)}`) 524 | } else if (typeof options.name === 'string') { 525 | attrs.push(`name=${this.quote(options.name)}`) 526 | if (options.exact) attrs.push(`exact=True`) 527 | } 528 | for (const { name, value } of options.attrs!) { 529 | let valueString = 530 | typeof value === 'string' ? this.quote(value) : value 531 | if (typeof value === 'boolean') valueString = value ? 'True' : 'False' 532 | attrs.push(`${toSnakeCase(name)}=${valueString}`) 533 | } 534 | const attrString = attrs.length ? `, ${attrs.join(', ')}` : '' 535 | return `get_by_role(${this.quote(body as string)}${attrString})` 536 | case 'has-text': 537 | return `filter(has_text=${this.toHasText(body)})` 538 | case 'has-not-text': 539 | return `filter(has_not_text=${this.toHasText(body)})` 540 | case 'has': 541 | return `filter(has=${body})` 542 | case 'hasNot': 543 | return `filter(has_not=${body})` 544 | case 'and': 545 | return `and_(${body})` 546 | case 'or': 547 | return `or_(${body})` 548 | case 'chain': 549 | return `locator(${body})` 550 | case 'test-id': 551 | return `get_by_test_id(${this.toTestIdValue(body)})` 552 | case 'text': 553 | return this.toCallWithExact('get_by_text', body, !!options.exact) 554 | case 'alt': 555 | return this.toCallWithExact('get_by_alt_text', body, !!options.exact) 556 | case 'placeholder': 557 | return this.toCallWithExact('get_by_placeholder', body, !!options.exact) 558 | case 'label': 559 | return this.toCallWithExact('get_by_label', body, !!options.exact) 560 | case 'title': 561 | return this.toCallWithExact('get_by_title', body, !!options.exact) 562 | default: 563 | throw new Error('Unknown selector kind ' + kind) 564 | } 565 | } 566 | 567 | chainLocators(locators: string[]): string { 568 | return locators.join('.') 569 | } 570 | 571 | private regexToString(body: RegExp) { 572 | const suffix = body.flags.includes('i') ? ', re.IGNORECASE' : '' 573 | return `re.compile(r"${normalizeEscapedRegexQuotes(body.source).replace(/\\\//, '/').replace(/"/g, '\\"')}"${suffix})` 574 | } 575 | 576 | private toCallWithExact( 577 | method: string, 578 | body: string | RegExp, 579 | exact: boolean 580 | ) { 581 | if (isRegExp(body)) return `${method}(${this.regexToString(body)})` 582 | if (exact) return `${method}(${this.quote(body)}, exact=True)` 583 | return `${method}(${this.quote(body)})` 584 | } 585 | 586 | private toHasText(body: string | RegExp) { 587 | if (isRegExp(body)) return this.regexToString(body) 588 | return `${this.quote(body)}` 589 | } 590 | 591 | private toTestIdValue(value: string | RegExp) { 592 | if (isRegExp(value)) return this.regexToString(value) 593 | return this.quote(value) 594 | } 595 | 596 | private quote(text: string) { 597 | return escapeWithQuotes(text, '"') 598 | } 599 | } 600 | 601 | export class JavaLocatorFactory implements LocatorFactory { 602 | generateLocator( 603 | base: LocatorBase, 604 | kind: LocatorType, 605 | body: string | RegExp, 606 | options: LocatorOptions = {} 607 | ): string { 608 | let clazz: string 609 | switch (base) { 610 | case 'page': 611 | clazz = 'Page' 612 | break 613 | case 'frame-locator': 614 | clazz = 'FrameLocator' 615 | break 616 | case 'locator': 617 | clazz = 'Locator' 618 | break 619 | } 620 | switch (kind) { 621 | case 'default': 622 | if (options.hasText !== undefined) 623 | return `locator(${this.quote(body as string)}, new ${clazz}.LocatorOptions().setHasText(${this.toHasText(options.hasText)}))` 624 | if (options.hasNotText !== undefined) 625 | return `locator(${this.quote(body as string)}, new ${clazz}.LocatorOptions().setHasNotText(${this.toHasText(options.hasNotText)}))` 626 | return `locator(${this.quote(body as string)})` 627 | case 'frame': 628 | return `frameLocator(${this.quote(body as string)})` 629 | case 'nth': 630 | return `nth(${body})` 631 | case 'first': 632 | return `first()` 633 | case 'last': 634 | return `last()` 635 | case 'role': 636 | const attrs: string[] = [] 637 | if (isRegExp(options.name)) { 638 | attrs.push(`.setName(${this.regexToString(options.name)})`) 639 | } else if (typeof options.name === 'string') { 640 | attrs.push(`.setName(${this.quote(options.name)})`) 641 | if (options.exact) attrs.push(`.setExact(true)`) 642 | } 643 | for (const { name, value } of options.attrs!) 644 | attrs.push( 645 | `.set${toTitleCase(name)}(${typeof value === 'string' ? this.quote(value) : value})` 646 | ) 647 | const attrString = attrs.length 648 | ? `, new ${clazz}.GetByRoleOptions()${attrs.join('')}` 649 | : '' 650 | return `getByRole(AriaRole.${toSnakeCase(body as string).toUpperCase()}${attrString})` 651 | case 'has-text': 652 | return `filter(new ${clazz}.FilterOptions().setHasText(${this.toHasText(body)}))` 653 | case 'has-not-text': 654 | return `filter(new ${clazz}.FilterOptions().setHasNotText(${this.toHasText(body)}))` 655 | case 'has': 656 | return `filter(new ${clazz}.FilterOptions().setHas(${body}))` 657 | case 'hasNot': 658 | return `filter(new ${clazz}.FilterOptions().setHasNot(${body}))` 659 | case 'and': 660 | return `and(${body})` 661 | case 'or': 662 | return `or(${body})` 663 | case 'chain': 664 | return `locator(${body})` 665 | case 'test-id': 666 | return `getByTestId(${this.toTestIdValue(body)})` 667 | case 'text': 668 | return this.toCallWithExact(clazz, 'getByText', body, !!options.exact) 669 | case 'alt': 670 | return this.toCallWithExact( 671 | clazz, 672 | 'getByAltText', 673 | body, 674 | !!options.exact 675 | ) 676 | case 'placeholder': 677 | return this.toCallWithExact( 678 | clazz, 679 | 'getByPlaceholder', 680 | body, 681 | !!options.exact 682 | ) 683 | case 'label': 684 | return this.toCallWithExact(clazz, 'getByLabel', body, !!options.exact) 685 | case 'title': 686 | return this.toCallWithExact(clazz, 'getByTitle', body, !!options.exact) 687 | default: 688 | throw new Error('Unknown selector kind ' + kind) 689 | } 690 | } 691 | 692 | chainLocators(locators: string[]): string { 693 | return locators.join('.') 694 | } 695 | 696 | private regexToString(body: RegExp) { 697 | const suffix = body.flags.includes('i') ? ', Pattern.CASE_INSENSITIVE' : '' 698 | return `Pattern.compile(${this.quote(normalizeEscapedRegexQuotes(body.source))}${suffix})` 699 | } 700 | 701 | private toCallWithExact( 702 | clazz: string, 703 | method: string, 704 | body: string | RegExp, 705 | exact: boolean 706 | ) { 707 | if (isRegExp(body)) return `${method}(${this.regexToString(body)})` 708 | if (exact) 709 | return `${method}(${this.quote(body)}, new ${clazz}.${toTitleCase(method)}Options().setExact(true))` 710 | return `${method}(${this.quote(body)})` 711 | } 712 | 713 | private toHasText(body: string | RegExp) { 714 | if (isRegExp(body)) return this.regexToString(body) 715 | return this.quote(body) 716 | } 717 | 718 | private toTestIdValue(value: string | RegExp) { 719 | if (isRegExp(value)) return this.regexToString(value) 720 | return this.quote(value) 721 | } 722 | 723 | private quote(text: string) { 724 | return escapeWithQuotes(text, '"') 725 | } 726 | } 727 | 728 | export class CSharpLocatorFactory implements LocatorFactory { 729 | generateLocator( 730 | base: LocatorBase, 731 | kind: LocatorType, 732 | body: string | RegExp, 733 | options: LocatorOptions = {} 734 | ): string { 735 | switch (kind) { 736 | case 'default': 737 | if (options.hasText !== undefined) 738 | return `Locator(${this.quote(body as string)}, new() { ${this.toHasText(options.hasText)} })` 739 | if (options.hasNotText !== undefined) 740 | return `Locator(${this.quote(body as string)}, new() { ${this.toHasNotText(options.hasNotText)} })` 741 | return `Locator(${this.quote(body as string)})` 742 | case 'frame': 743 | return `FrameLocator(${this.quote(body as string)})` 744 | case 'nth': 745 | return `Nth(${body})` 746 | case 'first': 747 | return `First` 748 | case 'last': 749 | return `Last` 750 | case 'role': 751 | const attrs: string[] = [] 752 | if (isRegExp(options.name)) { 753 | attrs.push(`NameRegex = ${this.regexToString(options.name)}`) 754 | } else if (typeof options.name === 'string') { 755 | attrs.push(`Name = ${this.quote(options.name)}`) 756 | if (options.exact) attrs.push(`Exact = true`) 757 | } 758 | for (const { name, value } of options.attrs!) 759 | attrs.push( 760 | `${toTitleCase(name)} = ${typeof value === 'string' ? this.quote(value) : value}` 761 | ) 762 | const attrString = attrs.length ? `, new() { ${attrs.join(', ')} }` : '' 763 | return `GetByRole(AriaRole.${toTitleCase(body as string)}${attrString})` 764 | case 'has-text': 765 | return `Filter(new() { ${this.toHasText(body)} })` 766 | case 'has-not-text': 767 | return `Filter(new() { ${this.toHasNotText(body)} })` 768 | case 'has': 769 | return `Filter(new() { Has = ${body} })` 770 | case 'hasNot': 771 | return `Filter(new() { HasNot = ${body} })` 772 | case 'and': 773 | return `And(${body})` 774 | case 'or': 775 | return `Or(${body})` 776 | case 'chain': 777 | return `Locator(${body})` 778 | case 'test-id': 779 | return `GetByTestId(${this.toTestIdValue(body)})` 780 | case 'text': 781 | return this.toCallWithExact('GetByText', body, !!options.exact) 782 | case 'alt': 783 | return this.toCallWithExact('GetByAltText', body, !!options.exact) 784 | case 'placeholder': 785 | return this.toCallWithExact('GetByPlaceholder', body, !!options.exact) 786 | case 'label': 787 | return this.toCallWithExact('GetByLabel', body, !!options.exact) 788 | case 'title': 789 | return this.toCallWithExact('GetByTitle', body, !!options.exact) 790 | default: 791 | throw new Error('Unknown selector kind ' + kind) 792 | } 793 | } 794 | 795 | chainLocators(locators: string[]): string { 796 | return locators.join('.') 797 | } 798 | 799 | private regexToString(body: RegExp): string { 800 | const suffix = body.flags.includes('i') ? ', RegexOptions.IgnoreCase' : '' 801 | return `new Regex(${this.quote(normalizeEscapedRegexQuotes(body.source))}${suffix})` 802 | } 803 | 804 | private toCallWithExact( 805 | method: string, 806 | body: string | RegExp, 807 | exact: boolean 808 | ) { 809 | if (isRegExp(body)) return `${method}(${this.regexToString(body)})` 810 | if (exact) return `${method}(${this.quote(body)}, new() { Exact = true })` 811 | return `${method}(${this.quote(body)})` 812 | } 813 | 814 | private toHasText(body: string | RegExp) { 815 | if (isRegExp(body)) return `HasTextRegex = ${this.regexToString(body)}` 816 | return `HasText = ${this.quote(body)}` 817 | } 818 | 819 | private toTestIdValue(value: string | RegExp) { 820 | if (isRegExp(value)) return this.regexToString(value) 821 | return this.quote(value) 822 | } 823 | 824 | private toHasNotText(body: string | RegExp) { 825 | if (isRegExp(body)) return `HasNotTextRegex = ${this.regexToString(body)}` 826 | return `HasNotText = ${this.quote(body)}` 827 | } 828 | 829 | private quote(text: string) { 830 | return escapeWithQuotes(text, '"') 831 | } 832 | } 833 | 834 | export class JsonlLocatorFactory implements LocatorFactory { 835 | generateLocator( 836 | base: LocatorBase, 837 | kind: LocatorType, 838 | body: string | RegExp, 839 | options: LocatorOptions = {} 840 | ): string { 841 | return JSON.stringify({ 842 | kind, 843 | body, 844 | options, 845 | }) 846 | } 847 | 848 | chainLocators(locators: string[]): string { 849 | const objects = locators.map((l) => JSON.parse(l)) 850 | for (let i = 0; i < objects.length - 1; ++i) 851 | objects[i].next = objects[i + 1] 852 | return JSON.stringify(objects[0]) 853 | } 854 | } 855 | 856 | const generators: Record< 857 | Language, 858 | new (preferredQuote?: Quote) => LocatorFactory 859 | > = { 860 | javascript: JavaScriptLocatorFactory, 861 | python: PythonLocatorFactory, 862 | java: JavaLocatorFactory, 863 | csharp: CSharpLocatorFactory, 864 | jsonl: JsonlLocatorFactory, 865 | } 866 | 867 | function isRegExp(obj: any): obj is RegExp { 868 | return obj instanceof RegExp 869 | } 870 | -------------------------------------------------------------------------------- /src/recorder/utils/isomorphic/locator-parser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { 18 | escapeForAttributeSelector, 19 | escapeForTextSelector, 20 | } from './string-utils' 21 | import { asLocators } from './locator-generators' 22 | import type { Language, Quote } from './locator-generators' 23 | import { parseSelector } from './selector-parser' 24 | 25 | type TemplateParams = { quote: string; text: string }[] 26 | function parseLocator( 27 | locator: string, 28 | testIdAttributeName: string 29 | ): { selector: string; preferredQuote: Quote | undefined } { 30 | locator = locator 31 | .replace(/AriaRole\s*\.\s*([\w]+)/g, (_, group) => group.toLowerCase()) 32 | .replace( 33 | /(get_by_role|getByRole)\s*\(\s*(?:["'`])([^'"`]+)['"`]/g, 34 | (_, group1, group2) => `${group1}(${group2.toLowerCase()}` 35 | ) 36 | const params: TemplateParams = [] 37 | let template = '' 38 | for (let i = 0; i < locator.length; ++i) { 39 | const quote = locator[i] 40 | if (quote !== '"' && quote !== "'" && quote !== '`' && quote !== '/') { 41 | template += quote 42 | continue 43 | } 44 | const isRegexEscaping = locator[i - 1] === 'r' || locator[i] === '/' 45 | ++i 46 | let text = '' 47 | while (i < locator.length) { 48 | if (locator[i] === '\\') { 49 | if (isRegexEscaping) { 50 | if (locator[i + 1] !== quote) text += locator[i] 51 | ++i 52 | text += locator[i] 53 | } else { 54 | ++i 55 | if (locator[i] === 'n') text += '\n' 56 | else if (locator[i] === 'r') text += '\r' 57 | else if (locator[i] === 't') text += '\t' 58 | else text += locator[i] 59 | } 60 | ++i 61 | continue 62 | } 63 | if (locator[i] !== quote) { 64 | text += locator[i++] 65 | continue 66 | } 67 | break 68 | } 69 | params.push({ quote, text }) 70 | template += (quote === '/' ? 'r' : '') + '$' + params.length 71 | } 72 | 73 | // Equalize languages. 74 | template = template 75 | .toLowerCase() 76 | .replace(/get_by_alt_text/g, 'getbyalttext') 77 | .replace(/get_by_test_id/g, 'getbytestid') 78 | .replace(/get_by_([\w]+)/g, 'getby$1') 79 | .replace(/has_not_text/g, 'hasnottext') 80 | .replace(/has_text/g, 'hastext') 81 | .replace(/has_not/g, 'hasnot') 82 | .replace(/frame_locator/g, 'framelocator') 83 | .replace(/[{}\s]/g, '') 84 | .replace(/new\(\)/g, '') 85 | .replace(/new[\w]+\.[\w]+options\(\)/g, '') 86 | .replace(/\.set/g, ',set') 87 | .replace(/\.or_\(/g, 'or(') // Python has "or_" instead of "or". 88 | .replace(/\.and_\(/g, 'and(') // Python has "and_" instead of "and". 89 | .replace(/:/g, '=') 90 | .replace(/,re\.ignorecase/g, 'i') 91 | .replace(/,pattern.case_insensitive/g, 'i') 92 | .replace(/,regexoptions.ignorecase/g, 'i') 93 | .replace(/re.compile\(([^)]+)\)/g, '$1') // Python has regex strings as r"foo" 94 | .replace(/pattern.compile\(([^)]+)\)/g, 'r$1') 95 | .replace(/newregex\(([^)]+)\)/g, 'r$1') 96 | .replace(/string=/g, '=') 97 | .replace(/regex=/g, '=') 98 | .replace(/,,/g, ',') 99 | 100 | const preferredQuote = params 101 | .map((p) => p.quote) 102 | .filter((quote) => '\'"`'.includes(quote))[0] as Quote | undefined 103 | return { 104 | selector: transform(template, params, testIdAttributeName), 105 | preferredQuote, 106 | } 107 | } 108 | 109 | function countParams(template: string) { 110 | return [...template.matchAll(/\$\d+/g)].length 111 | } 112 | 113 | function shiftParams(template: string, sub: number) { 114 | return template.replace(/\$(\d+)/g, (_, ordinal) => `$${ordinal - sub}`) 115 | } 116 | 117 | function transform( 118 | template: string, 119 | params: TemplateParams, 120 | testIdAttributeName: string 121 | ): string { 122 | // Recursively handle filter(has=, hasnot=, sethas(), sethasnot()). 123 | // TODO: handle and(locator), or(locator), locator(locator), locator(has=, hasnot=, sethas(), sethasnot()). 124 | while (true) { 125 | const hasMatch = template.match( 126 | /filter\(,?(has=|hasnot=|sethas\(|sethasnot\()/ 127 | ) 128 | if (!hasMatch) break 129 | 130 | // Extract inner locator based on balanced parens. 131 | const start = hasMatch.index! + hasMatch[0].length 132 | let balance = 0 133 | let end = start 134 | for (; end < template.length; end++) { 135 | if (template[end] === '(') balance++ 136 | else if (template[end] === ')') balance-- 137 | if (balance < 0) break 138 | } 139 | 140 | // Replace Java sethas(...) and sethasnot(...) with has=... and hasnot=... 141 | let prefix = template.substring(0, start) 142 | let extraSymbol = 0 143 | if (['sethas(', 'sethasnot('].includes(hasMatch[1])) { 144 | // Eat extra ) symbol at the end of sethas(...) 145 | extraSymbol = 1 146 | prefix = prefix 147 | .replace(/sethas\($/, 'has=') 148 | .replace(/sethasnot\($/, 'hasnot=') 149 | } 150 | 151 | const paramsCountBeforeHas = countParams(template.substring(0, start)) 152 | const hasTemplate = shiftParams( 153 | template.substring(start, end), 154 | paramsCountBeforeHas 155 | ) 156 | const paramsCountInHas = countParams(hasTemplate) 157 | const hasParams = params.slice( 158 | paramsCountBeforeHas, 159 | paramsCountBeforeHas + paramsCountInHas 160 | ) 161 | const hasSelector = JSON.stringify( 162 | transform(hasTemplate, hasParams, testIdAttributeName) 163 | ) 164 | 165 | // Replace filter(has=...) with filter(has2=$5). Use has2 to avoid matching the same filter again. 166 | // Replace filter(hasnot=...) with filter(hasnot2=$5). Use hasnot2 to avoid matching the same filter again. 167 | template = 168 | prefix.replace(/=$/, '2=') + 169 | `$${paramsCountBeforeHas + 1}` + 170 | shiftParams(template.substring(end + extraSymbol), paramsCountInHas - 1) 171 | 172 | // Replace inner params with $5 value. 173 | const paramsBeforeHas = params.slice(0, paramsCountBeforeHas) 174 | const paramsAfterHas = params.slice(paramsCountBeforeHas + paramsCountInHas) 175 | params = paramsBeforeHas 176 | .concat([{ quote: '"', text: hasSelector }]) 177 | .concat(paramsAfterHas) 178 | } 179 | 180 | // Transform to selector engines. 181 | template = template 182 | .replace( 183 | /\,set([\w]+)\(([^)]+)\)/g, 184 | (_, group1, group2) => 185 | ',' + group1.toLowerCase() + '=' + group2.toLowerCase() 186 | ) 187 | .replace(/framelocator\(([^)]+)\)/g, '$1.internal:control=enter-frame') 188 | .replace( 189 | /locator\(([^)]+),hastext=([^),]+)\)/g, 190 | 'locator($1).internal:has-text=$2' 191 | ) 192 | .replace( 193 | /locator\(([^)]+),hasnottext=([^),]+)\)/g, 194 | 'locator($1).internal:has-not-text=$2' 195 | ) 196 | .replace( 197 | /locator\(([^)]+),hastext=([^),]+)\)/g, 198 | 'locator($1).internal:has-text=$2' 199 | ) 200 | .replace(/locator\(([^)]+)\)/g, '$1') 201 | .replace(/getbyrole\(([^)]+)\)/g, 'internal:role=$1') 202 | .replace(/getbytext\(([^)]+)\)/g, 'internal:text=$1') 203 | .replace(/getbylabel\(([^)]+)\)/g, 'internal:label=$1') 204 | .replace( 205 | /getbytestid\(([^)]+)\)/g, 206 | `internal:testid=[${testIdAttributeName}=$1]` 207 | ) 208 | .replace( 209 | /getby(placeholder|alt|title)(?:text)?\(([^)]+)\)/g, 210 | 'internal:attr=[$1=$2]' 211 | ) 212 | .replace(/first(\(\))?/g, 'nth=0') 213 | .replace(/last(\(\))?/g, 'nth=-1') 214 | .replace(/nth\(([^)]+)\)/g, 'nth=$1') 215 | .replace(/filter\(,?hastext=([^)]+)\)/g, 'internal:has-text=$1') 216 | .replace(/filter\(,?hasnottext=([^)]+)\)/g, 'internal:has-not-text=$1') 217 | .replace(/filter\(,?has2=([^)]+)\)/g, 'internal:has=$1') 218 | .replace(/filter\(,?hasnot2=([^)]+)\)/g, 'internal:has-not=$1') 219 | .replace(/,exact=false/g, '') 220 | .replace(/,exact=true/g, 's') 221 | .replace(/\,/g, '][') 222 | 223 | const parts = template.split('.') 224 | // Turn "internal:control=enter-frame >> nth=0" into "nth=0 >> internal:control=enter-frame" 225 | // because these are swapped in locators vs selectors. 226 | for (let index = 0; index < parts.length - 1; index++) { 227 | if ( 228 | parts[index] === 'internal:control=enter-frame' && 229 | parts[index + 1].startsWith('nth=') 230 | ) { 231 | // Swap nth and enter-frame. 232 | const [nth] = parts.splice(index, 1) 233 | parts.splice(index + 1, 0, nth) 234 | } 235 | } 236 | 237 | // Substitute params. 238 | return parts 239 | .map((t) => { 240 | if (!t.startsWith('internal:') || t === 'internal:control') 241 | return t.replace(/\$(\d+)/g, (_, ordinal) => { 242 | const param = params[+ordinal - 1] 243 | return param.text 244 | }) 245 | t = t.includes('[') ? t.replace(/\]/, '') + ']' : t 246 | t = t 247 | .replace(/(?:r)\$(\d+)(i)?/g, (_, ordinal, suffix) => { 248 | const param = params[+ordinal - 1] 249 | if ( 250 | t.startsWith('internal:attr') || 251 | t.startsWith('internal:testid') || 252 | t.startsWith('internal:role') 253 | ) 254 | return ( 255 | escapeForAttributeSelector(new RegExp(param.text), false) + 256 | (suffix || '') 257 | ) 258 | return escapeForTextSelector(new RegExp(param.text, suffix), false) 259 | }) 260 | .replace(/\$(\d+)(i|s)?/g, (_, ordinal, suffix) => { 261 | const param = params[+ordinal - 1] 262 | if ( 263 | t.startsWith('internal:has=') || 264 | t.startsWith('internal:has-not=') 265 | ) 266 | return param.text 267 | if (t.startsWith('internal:testid')) 268 | return escapeForAttributeSelector(param.text, true) 269 | if (t.startsWith('internal:attr') || t.startsWith('internal:role')) 270 | return escapeForAttributeSelector(param.text, suffix === 's') 271 | return escapeForTextSelector(param.text, suffix === 's') 272 | }) 273 | return t 274 | }) 275 | .join(' >> ') 276 | } 277 | 278 | export function locatorOrSelectorAsSelector( 279 | language: Language, 280 | locator: string, 281 | testIdAttributeName: string 282 | ): string { 283 | try { 284 | parseSelector(locator) 285 | return locator 286 | } catch (e) {} 287 | try { 288 | const { selector, preferredQuote } = parseLocator( 289 | locator, 290 | testIdAttributeName 291 | ) 292 | const locators = asLocators( 293 | language, 294 | selector, 295 | undefined, 296 | undefined, 297 | preferredQuote 298 | ) 299 | const digest = digestForComparison(language, locator) 300 | if ( 301 | locators.some( 302 | (candidate) => digestForComparison(language, candidate) === digest 303 | ) 304 | ) 305 | return selector 306 | } catch (e) {} 307 | return '' 308 | } 309 | 310 | function digestForComparison(language: Language, locator: string) { 311 | locator = locator.replace(/\s/g, '') 312 | if (language === 'javascript') locator = locator.replace(/\\?["`]/g, "'") 313 | return locator 314 | } 315 | -------------------------------------------------------------------------------- /src/recorder/utils/isomorphic/locator-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { 18 | escapeForAttributeSelector, 19 | escapeForTextSelector, 20 | } from './string-utils' 21 | 22 | export type ByRoleOptions = { 23 | checked?: boolean 24 | disabled?: boolean 25 | exact?: boolean 26 | expanded?: boolean 27 | includeHidden?: boolean 28 | level?: number 29 | name?: string | RegExp 30 | pressed?: boolean 31 | selected?: boolean 32 | } 33 | 34 | function getByAttributeTextSelector( 35 | attrName: string, 36 | text: string | RegExp, 37 | options?: { exact?: boolean } 38 | ): string { 39 | return `internal:attr=[${attrName}=${escapeForAttributeSelector(text, options?.exact || false)}]` 40 | } 41 | 42 | export function getByTestIdSelector( 43 | testIdAttributeName: string, 44 | testId: string | RegExp 45 | ): string { 46 | return `internal:testid=[${testIdAttributeName}=${escapeForAttributeSelector(testId, true)}]` 47 | } 48 | 49 | export function getByLabelSelector( 50 | text: string | RegExp, 51 | options?: { exact?: boolean } 52 | ): string { 53 | return 'internal:label=' + escapeForTextSelector(text, !!options?.exact) 54 | } 55 | 56 | export function getByAltTextSelector( 57 | text: string | RegExp, 58 | options?: { exact?: boolean } 59 | ): string { 60 | return getByAttributeTextSelector('alt', text, options) 61 | } 62 | 63 | export function getByTitleSelector( 64 | text: string | RegExp, 65 | options?: { exact?: boolean } 66 | ): string { 67 | return getByAttributeTextSelector('title', text, options) 68 | } 69 | 70 | export function getByPlaceholderSelector( 71 | text: string | RegExp, 72 | options?: { exact?: boolean } 73 | ): string { 74 | return getByAttributeTextSelector('placeholder', text, options) 75 | } 76 | 77 | export function getByTextSelector( 78 | text: string | RegExp, 79 | options?: { exact?: boolean } 80 | ): string { 81 | return 'internal:text=' + escapeForTextSelector(text, !!options?.exact) 82 | } 83 | 84 | export function getByRoleSelector( 85 | role: string, 86 | options: ByRoleOptions = {} 87 | ): string { 88 | const props: string[][] = [] 89 | if (options.checked !== undefined) 90 | props.push(['checked', String(options.checked)]) 91 | if (options.disabled !== undefined) 92 | props.push(['disabled', String(options.disabled)]) 93 | if (options.selected !== undefined) 94 | props.push(['selected', String(options.selected)]) 95 | if (options.expanded !== undefined) 96 | props.push(['expanded', String(options.expanded)]) 97 | if (options.includeHidden !== undefined) 98 | props.push(['include-hidden', String(options.includeHidden)]) 99 | if (options.level !== undefined) props.push(['level', String(options.level)]) 100 | if (options.name !== undefined) 101 | props.push([ 102 | 'name', 103 | escapeForAttributeSelector(options.name, !!options.exact), 104 | ]) 105 | if (options.pressed !== undefined) 106 | props.push(['pressed', String(options.pressed)]) 107 | return `internal:role=${role}${props.map(([n, v]) => `[${n}=${v}]`).join('')}` 108 | } 109 | -------------------------------------------------------------------------------- /src/recorder/utils/isomorphic/selector-parser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import type { CSSComplexSelectorList } from './css-parser' 18 | import { InvalidSelectorError, parseCSS } from './css-parser' 19 | export { InvalidSelectorError, isInvalidSelectorError } from './css-parser' 20 | 21 | export type NestedSelectorBody = { parsed: ParsedSelector; distance?: number } 22 | const kNestedSelectorNames = new Set([ 23 | 'internal:has', 24 | 'internal:has-not', 25 | 'internal:and', 26 | 'internal:or', 27 | 'internal:chain', 28 | 'left-of', 29 | 'right-of', 30 | 'above', 31 | 'below', 32 | 'near', 33 | ]) 34 | const kNestedSelectorNamesWithDistance = new Set([ 35 | 'left-of', 36 | 'right-of', 37 | 'above', 38 | 'below', 39 | 'near', 40 | ]) 41 | 42 | export type ParsedSelectorPart = { 43 | name: string 44 | body: string | CSSComplexSelectorList | NestedSelectorBody 45 | source: string 46 | } 47 | 48 | export type ParsedSelector = { 49 | parts: ParsedSelectorPart[] 50 | capture?: number 51 | } 52 | 53 | type ParsedSelectorStrings = { 54 | parts: { name: string; body: string }[] 55 | capture?: number 56 | } 57 | 58 | export const customCSSNames = new Set([ 59 | 'not', 60 | 'is', 61 | 'where', 62 | 'has', 63 | 'scope', 64 | 'light', 65 | 'visible', 66 | 'text', 67 | 'text-matches', 68 | 'text-is', 69 | 'has-text', 70 | 'above', 71 | 'below', 72 | 'right-of', 73 | 'left-of', 74 | 'near', 75 | 'nth-match', 76 | ]) 77 | 78 | export function parseSelector(selector: string): ParsedSelector { 79 | const parsedStrings = parseSelectorString(selector) 80 | const parts: ParsedSelectorPart[] = [] 81 | for (const part of parsedStrings.parts) { 82 | if (part.name === 'css' || part.name === 'css:light') { 83 | if (part.name === 'css:light') part.body = ':light(' + part.body + ')' 84 | const parsedCSS = parseCSS(part.body, customCSSNames) 85 | parts.push({ 86 | name: 'css', 87 | body: parsedCSS.selector, 88 | source: part.body, 89 | }) 90 | continue 91 | } 92 | if (kNestedSelectorNames.has(part.name)) { 93 | let innerSelector: string 94 | let distance: number | undefined 95 | try { 96 | const unescaped = JSON.parse('[' + part.body + ']') 97 | if ( 98 | !Array.isArray(unescaped) || 99 | unescaped.length < 1 || 100 | unescaped.length > 2 || 101 | typeof unescaped[0] !== 'string' 102 | ) 103 | throw new InvalidSelectorError( 104 | `Malformed selector: ${part.name}=` + part.body 105 | ) 106 | innerSelector = unescaped[0] 107 | if (unescaped.length === 2) { 108 | if ( 109 | typeof unescaped[1] !== 'number' || 110 | !kNestedSelectorNamesWithDistance.has(part.name) 111 | ) 112 | throw new InvalidSelectorError( 113 | `Malformed selector: ${part.name}=` + part.body 114 | ) 115 | distance = unescaped[1] 116 | } 117 | } catch (e) { 118 | throw new InvalidSelectorError( 119 | `Malformed selector: ${part.name}=` + part.body 120 | ) 121 | } 122 | const nested = { 123 | name: part.name, 124 | source: part.body, 125 | body: { parsed: parseSelector(innerSelector), distance }, 126 | } 127 | const lastFrame = [...nested.body.parsed.parts] 128 | .reverse() 129 | .find( 130 | (part) => 131 | part.name === 'internal:control' && part.body === 'enter-frame' 132 | ) 133 | const lastFrameIndex = lastFrame 134 | ? nested.body.parsed.parts.indexOf(lastFrame) 135 | : -1 136 | // Allow nested selectors to start with the same frame selector. 137 | if ( 138 | lastFrameIndex !== -1 && 139 | selectorPartsEqual( 140 | nested.body.parsed.parts.slice(0, lastFrameIndex + 1), 141 | parts.slice(0, lastFrameIndex + 1) 142 | ) 143 | ) 144 | nested.body.parsed.parts.splice(0, lastFrameIndex + 1) 145 | parts.push(nested) 146 | continue 147 | } 148 | parts.push({ ...part, source: part.body }) 149 | } 150 | if (kNestedSelectorNames.has(parts[0].name)) 151 | throw new InvalidSelectorError( 152 | `"${parts[0].name}" selector cannot be first` 153 | ) 154 | return { 155 | capture: parsedStrings.capture, 156 | parts, 157 | } 158 | } 159 | 160 | export function splitSelectorByFrame(selectorText: string): ParsedSelector[] { 161 | const selector = parseSelector(selectorText) 162 | const result: ParsedSelector[] = [] 163 | let chunk: ParsedSelector = { 164 | parts: [], 165 | } 166 | let chunkStartIndex = 0 167 | for (let i = 0; i < selector.parts.length; ++i) { 168 | const part = selector.parts[i] 169 | if (part.name === 'internal:control' && part.body === 'enter-frame') { 170 | if (!chunk.parts.length) 171 | throw new InvalidSelectorError( 172 | 'Selector cannot start with entering frame, select the iframe first' 173 | ) 174 | result.push(chunk) 175 | chunk = { parts: [] } 176 | chunkStartIndex = i + 1 177 | continue 178 | } 179 | if (selector.capture === i) chunk.capture = i - chunkStartIndex 180 | chunk.parts.push(part) 181 | } 182 | if (!chunk.parts.length) 183 | throw new InvalidSelectorError( 184 | `Selector cannot end with entering frame, while parsing selector ${selectorText}` 185 | ) 186 | result.push(chunk) 187 | if ( 188 | typeof selector.capture === 'number' && 189 | typeof result[result.length - 1].capture !== 'number' 190 | ) 191 | throw new InvalidSelectorError( 192 | `Can not capture the selector before diving into the frame. Only use * after the last frame has been selected` 193 | ) 194 | return result 195 | } 196 | 197 | function selectorPartsEqual( 198 | list1: ParsedSelectorPart[], 199 | list2: ParsedSelectorPart[] 200 | ) { 201 | return ( 202 | stringifySelector({ parts: list1 }) === stringifySelector({ parts: list2 }) 203 | ) 204 | } 205 | 206 | export function stringifySelector( 207 | selector: string | ParsedSelector, 208 | forceEngineName?: boolean 209 | ): string { 210 | if (typeof selector === 'string') return selector 211 | return selector.parts 212 | .map((p, i) => { 213 | let includeEngine = true 214 | if (!forceEngineName && i !== selector.capture) { 215 | if (p.name === 'css') includeEngine = false 216 | else if ( 217 | (p.name === 'xpath' && p.source.startsWith('//')) || 218 | p.source.startsWith('..') 219 | ) 220 | includeEngine = false 221 | } 222 | const prefix = includeEngine ? p.name + '=' : '' 223 | return `${i === selector.capture ? '*' : ''}${prefix}${p.source}` 224 | }) 225 | .join(' >> ') 226 | } 227 | 228 | export function visitAllSelectorParts( 229 | selector: ParsedSelector, 230 | visitor: (part: ParsedSelectorPart, nested: boolean) => void 231 | ) { 232 | const visit = (selector: ParsedSelector, nested: boolean) => { 233 | for (const part of selector.parts) { 234 | visitor(part, nested) 235 | if (kNestedSelectorNames.has(part.name)) 236 | visit((part.body as NestedSelectorBody).parsed, true) 237 | } 238 | } 239 | visit(selector, false) 240 | } 241 | 242 | function parseSelectorString(selector: string): ParsedSelectorStrings { 243 | let index = 0 244 | let quote: string | undefined 245 | let start = 0 246 | const result: ParsedSelectorStrings = { parts: [] } 247 | const append = () => { 248 | const part = selector.substring(start, index).trim() 249 | const eqIndex = part.indexOf('=') 250 | let name: string 251 | let body: string 252 | if ( 253 | eqIndex !== -1 && 254 | part 255 | .substring(0, eqIndex) 256 | .trim() 257 | .match(/^[a-zA-Z_0-9-+:*]+$/) 258 | ) { 259 | name = part.substring(0, eqIndex).trim() 260 | body = part.substring(eqIndex + 1) 261 | } else if ( 262 | part.length > 1 && 263 | part[0] === '"' && 264 | part[part.length - 1] === '"' 265 | ) { 266 | name = 'text' 267 | body = part 268 | } else if ( 269 | part.length > 1 && 270 | part[0] === "'" && 271 | part[part.length - 1] === "'" 272 | ) { 273 | name = 'text' 274 | body = part 275 | } else if (/^\(*\/\//.test(part) || part.startsWith('..')) { 276 | // If selector starts with '//' or '//' prefixed with multiple opening 277 | // parenthesis, consider xpath. @see https://github.com/microsoft/playwright/issues/817 278 | // If selector starts with '..', consider xpath as well. 279 | name = 'xpath' 280 | body = part 281 | } else { 282 | name = 'css' 283 | body = part 284 | } 285 | let capture = false 286 | if (name[0] === '*') { 287 | capture = true 288 | name = name.substring(1) 289 | } 290 | result.parts.push({ name, body }) 291 | if (capture) { 292 | if (result.capture !== undefined) 293 | throw new InvalidSelectorError( 294 | `Only one of the selectors can capture using * modifier` 295 | ) 296 | result.capture = result.parts.length - 1 297 | } 298 | } 299 | 300 | if (!selector.includes('>>')) { 301 | index = selector.length 302 | append() 303 | return result 304 | } 305 | 306 | const shouldIgnoreTextSelectorQuote = () => { 307 | const prefix = selector.substring(start, index) 308 | const match = prefix.match(/^\s*text\s*=(.*)$/) 309 | // Must be a text selector with some text before the quote. 310 | return !!match && !!match[1] 311 | } 312 | 313 | while (index < selector.length) { 314 | const c = selector[index] 315 | if (c === '\\' && index + 1 < selector.length) { 316 | index += 2 317 | } else if (c === quote) { 318 | quote = undefined 319 | index++ 320 | } else if ( 321 | !quote && 322 | (c === '"' || c === "'" || c === '`') && 323 | !shouldIgnoreTextSelectorQuote() 324 | ) { 325 | quote = c 326 | index++ 327 | } else if (!quote && c === '>' && selector[index + 1] === '>') { 328 | append() 329 | index += 2 330 | start = index 331 | } else { 332 | index++ 333 | } 334 | } 335 | append() 336 | return result 337 | } 338 | 339 | export type AttributeSelectorOperator = 340 | | '' 341 | | '=' 342 | | '*=' 343 | | '|=' 344 | | '^=' 345 | | '$=' 346 | | '~=' 347 | export type AttributeSelectorPart = { 348 | name: string 349 | jsonPath: string[] 350 | op: AttributeSelectorOperator 351 | value: any 352 | caseSensitive: boolean 353 | } 354 | 355 | export type AttributeSelector = { 356 | name: string 357 | attributes: AttributeSelectorPart[] 358 | } 359 | 360 | export function parseAttributeSelector( 361 | selector: string, 362 | allowUnquotedStrings: boolean 363 | ): AttributeSelector { 364 | let wp = 0 365 | let EOL = selector.length === 0 366 | 367 | const next = () => selector[wp] || '' 368 | const eat1 = () => { 369 | const result = next() 370 | ++wp 371 | EOL = wp >= selector.length 372 | return result 373 | } 374 | 375 | const syntaxError = (stage: string | undefined) => { 376 | if (EOL) 377 | throw new InvalidSelectorError( 378 | `Unexpected end of selector while parsing selector \`${selector}\`` 379 | ) 380 | throw new InvalidSelectorError( 381 | `Error while parsing selector \`${selector}\` - unexpected symbol "${next()}" at position ${wp}` + 382 | (stage ? ' during ' + stage : '') 383 | ) 384 | } 385 | 386 | function skipSpaces() { 387 | while (!EOL && /\s/.test(next())) eat1() 388 | } 389 | 390 | function isCSSNameChar(char: string) { 391 | // https://www.w3.org/TR/css-syntax-3/#ident-token-diagram 392 | return ( 393 | char >= '\u0080' || // non-ascii 394 | (char >= '\u0030' && char <= '\u0039') || // digit 395 | (char >= '\u0041' && char <= '\u005a') || // uppercase letter 396 | (char >= '\u0061' && char <= '\u007a') || // lowercase letter 397 | (char >= '\u0030' && char <= '\u0039') || // digit 398 | char === '\u005f' || // "_" 399 | char === '\u002d' 400 | ) // "-" 401 | } 402 | 403 | function readIdentifier() { 404 | let result = '' 405 | skipSpaces() 406 | while (!EOL && isCSSNameChar(next())) result += eat1() 407 | return result 408 | } 409 | 410 | function readQuotedString(quote: string) { 411 | let result = eat1() 412 | if (result !== quote) syntaxError('parsing quoted string') 413 | while (!EOL && next() !== quote) { 414 | if (next() === '\\') eat1() 415 | result += eat1() 416 | } 417 | if (next() !== quote) syntaxError('parsing quoted string') 418 | result += eat1() 419 | return result 420 | } 421 | 422 | function readRegularExpression() { 423 | if (eat1() !== '/') syntaxError('parsing regular expression') 424 | let source = '' 425 | let inClass = false 426 | // https://262.ecma-international.org/11.0/#sec-literals-regular-expression-literals 427 | while (!EOL) { 428 | if (next() === '\\') { 429 | source += eat1() 430 | if (EOL) syntaxError('parsing regular expression') 431 | } else if (inClass && next() === ']') { 432 | inClass = false 433 | } else if (!inClass && next() === '[') { 434 | inClass = true 435 | } else if (!inClass && next() === '/') { 436 | break 437 | } 438 | source += eat1() 439 | } 440 | if (eat1() !== '/') syntaxError('parsing regular expression') 441 | let flags = '' 442 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions 443 | while (!EOL && next().match(/[dgimsuy]/)) flags += eat1() 444 | try { 445 | return new RegExp(source, flags) 446 | } catch (e) { 447 | throw new InvalidSelectorError( 448 | `Error while parsing selector \`${selector}\`: ${e.message}` 449 | ) 450 | } 451 | } 452 | 453 | function readAttributeToken() { 454 | let token = '' 455 | skipSpaces() 456 | if (next() === `'` || next() === `"`) 457 | token = readQuotedString(next()).slice(1, -1) 458 | else token = readIdentifier() 459 | if (!token) syntaxError('parsing property path') 460 | return token 461 | } 462 | 463 | function readOperator(): AttributeSelectorOperator { 464 | skipSpaces() 465 | let op = '' 466 | if (!EOL) op += eat1() 467 | if (!EOL && op !== '=') op += eat1() 468 | if (!['=', '*=', '^=', '$=', '|=', '~='].includes(op)) 469 | syntaxError('parsing operator') 470 | return op as AttributeSelectorOperator 471 | } 472 | 473 | function readAttribute(): AttributeSelectorPart { 474 | // skip leading [ 475 | eat1() 476 | 477 | // read attribute name: 478 | // foo.bar 479 | // 'foo' . "ba zz" 480 | const jsonPath = [] 481 | jsonPath.push(readAttributeToken()) 482 | skipSpaces() 483 | while (next() === '.') { 484 | eat1() 485 | jsonPath.push(readAttributeToken()) 486 | skipSpaces() 487 | } 488 | // check property is truthy: [enabled] 489 | if (next() === ']') { 490 | eat1() 491 | return { 492 | name: jsonPath.join('.'), 493 | jsonPath, 494 | op: '', 495 | value: null, 496 | caseSensitive: false, 497 | } 498 | } 499 | 500 | const operator = readOperator() 501 | 502 | let value = undefined 503 | let caseSensitive = true 504 | skipSpaces() 505 | if (next() === '/') { 506 | if (operator !== '=') 507 | throw new InvalidSelectorError( 508 | `Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with regular expression` 509 | ) 510 | value = readRegularExpression() 511 | } else if (next() === `'` || next() === `"`) { 512 | value = readQuotedString(next()).slice(1, -1) 513 | skipSpaces() 514 | if (next() === 'i' || next() === 'I') { 515 | caseSensitive = false 516 | eat1() 517 | } else if (next() === 's' || next() === 'S') { 518 | caseSensitive = true 519 | eat1() 520 | } 521 | } else { 522 | value = '' 523 | while ( 524 | !EOL && 525 | (isCSSNameChar(next()) || next() === '+' || next() === '.') 526 | ) 527 | value += eat1() 528 | if (value === 'true') { 529 | value = true 530 | } else if (value === 'false') { 531 | value = false 532 | } else { 533 | if (!allowUnquotedStrings) { 534 | value = +value 535 | if (Number.isNaN(value)) syntaxError('parsing attribute value') 536 | } 537 | } 538 | } 539 | skipSpaces() 540 | if (next() !== ']') syntaxError('parsing attribute value') 541 | 542 | eat1() 543 | if (operator !== '=' && typeof value !== 'string') 544 | throw new InvalidSelectorError( 545 | `Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with non-string matching value - ${value}` 546 | ) 547 | return { 548 | name: jsonPath.join('.'), 549 | jsonPath, 550 | op: operator, 551 | value, 552 | caseSensitive, 553 | } 554 | } 555 | 556 | const result: AttributeSelector = { 557 | name: '', 558 | attributes: [], 559 | } 560 | result.name = readIdentifier() 561 | skipSpaces() 562 | while (next() === '[') { 563 | result.attributes.push(readAttribute()) 564 | skipSpaces() 565 | } 566 | if (!EOL) syntaxError(undefined) 567 | if (!result.name && !result.attributes.length) 568 | throw new InvalidSelectorError( 569 | `Error while parsing selector \`${selector}\` - selector cannot be empty` 570 | ) 571 | return result 572 | } 573 | -------------------------------------------------------------------------------- /src/recorder/utils/isomorphic/string-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | // NOTE: this function should not be used to escape any selectors. 18 | export function escapeWithQuotes(text: string, char: string = '\'') { 19 | const stringified = JSON.stringify(text); 20 | const escapedText = stringified.substring(1, stringified.length - 1).replace(/\\"/g, '"'); 21 | if (char === '\'') 22 | return char + escapedText.replace(/[']/g, '\\\'') + char; 23 | if (char === '"') 24 | return char + escapedText.replace(/["]/g, '\\"') + char; 25 | if (char === '`') 26 | return char + escapedText.replace(/[`]/g, '`') + char; 27 | throw new Error('Invalid escape char'); 28 | } 29 | 30 | export function isString(obj: any): obj is string { 31 | return typeof obj === 'string' || obj instanceof String; 32 | } 33 | 34 | export function toTitleCase(name: string) { 35 | return name.charAt(0).toUpperCase() + name.substring(1); 36 | } 37 | 38 | export function toSnakeCase(name: string): string { 39 | // E.g. ignoreHTTPSErrors => ignore_https_errors. 40 | return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/([A-Z])([A-Z][a-z])/g, '$1_$2').toLowerCase(); 41 | } 42 | 43 | export function cssEscape(s: string): string { 44 | let result = ''; 45 | for (let i = 0; i < s.length; i++) 46 | result += cssEscapeOne(s, i); 47 | return result; 48 | } 49 | 50 | export function quoteCSSAttributeValue(text: string): string { 51 | return `"${cssEscape(text).replace(/\\ /g, ' ')}"`; 52 | } 53 | 54 | function cssEscapeOne(s: string, i: number): string { 55 | // https://drafts.csswg.org/cssom/#serialize-an-identifier 56 | const c = s.charCodeAt(i); 57 | if (c === 0x0000) 58 | return '\uFFFD'; 59 | if ((c >= 0x0001 && c <= 0x001f) || 60 | (c >= 0x0030 && c <= 0x0039 && (i === 0 || (i === 1 && s.charCodeAt(0) === 0x002d)))) 61 | return '\\' + c.toString(16) + ' '; 62 | if (i === 0 && c === 0x002d && s.length === 1) 63 | return '\\' + s.charAt(i); 64 | if (c >= 0x0080 || c === 0x002d || c === 0x005f || (c >= 0x0030 && c <= 0x0039) || 65 | (c >= 0x0041 && c <= 0x005a) || (c >= 0x0061 && c <= 0x007a)) 66 | return s.charAt(i); 67 | return '\\' + s.charAt(i); 68 | } 69 | 70 | export function normalizeWhiteSpace(text: string): string { 71 | return text.replace(/\u200b/g, '').trim().replace(/\s+/g, ' '); 72 | } 73 | 74 | export function normalizeEscapedRegexQuotes(source: string) { 75 | // This function reverses the effect of escapeRegexForSelector below. 76 | // Odd number of backslashes followed by the quote -> remove unneeded backslash. 77 | return source.replace(/(^|[^\\])(\\\\)*\\(['"`])/g, '$1$2$3'); 78 | } 79 | 80 | function escapeRegexForSelector(re: RegExp): string { 81 | // Unicode mode does not allow "identity character escapes", so we do not escape and 82 | // hope that it does not contain quotes and/or >> signs. 83 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Character_escape 84 | // TODO: rework RE usages in internal selectors away from literal representation to json, e.g. {source,flags}. 85 | if (re.unicode || (re as any).unicodeSets) 86 | return String(re); 87 | // Even number of backslashes followed by the quote -> insert a backslash. 88 | return String(re).replace(/(^|[^\\])(\\\\)*(["'`])/g, '$1$2\\$3').replace(/>>/g, '\\>\\>'); 89 | } 90 | 91 | export function escapeForTextSelector(text: string | RegExp, exact: boolean): string { 92 | if (typeof text !== 'string') 93 | return escapeRegexForSelector(text); 94 | return `${JSON.stringify(text)}${exact ? 's' : 'i'}`; 95 | } 96 | 97 | export function escapeForAttributeSelector(value: string | RegExp, exact: boolean): string { 98 | if (typeof value !== 'string') 99 | return escapeRegexForSelector(value); 100 | // TODO: this should actually be 101 | // cssEscape(value).replace(/\\ /g, ' ') 102 | // However, our attribute selectors do not conform to CSS parsing spec, 103 | // so we escape them differently. 104 | return `"${value.replace(/\\/g, '\\\\').replace(/["]/g, '\\"')}"${exact ? 's' : 'i'}`; 105 | } 106 | 107 | export function trimString(input: string, cap: number, suffix: string = ''): string { 108 | if (input.length <= cap) 109 | return input; 110 | const chars = [...input]; 111 | if (chars.length > cap) 112 | return chars.slice(0, cap - suffix.length).join('') + suffix; 113 | return chars.join(''); 114 | } 115 | 116 | export function trimStringWithEllipsis(input: string, cap: number): string { 117 | return trimString(input, cap, '\u2026'); 118 | } 119 | 120 | export function escapeRegExp(s: string) { 121 | // From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping 122 | return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string 123 | } 124 | -------------------------------------------------------------------------------- /test/lib.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, beforeEach } from 'node:test' 2 | import { expect } from 'chai' 3 | import { Browserbase, BrowserbaseAISDK, CreateSessionOptions } from '../src' 4 | 5 | describe('Browserbase', () => { 6 | let browserbase: Browserbase 7 | 8 | beforeEach(() => { 9 | browserbase = new Browserbase() 10 | }) 11 | 12 | it('shoud list sessions', async () => { 13 | const session = await browserbase.listSessions() 14 | expect(session[0].status).to.equal('COMPLETED') 15 | }) 16 | 17 | it('shoud create and retrieve session', async () => { 18 | const { id } = await browserbase.createSession() 19 | const session = await browserbase.getSession(id) 20 | 21 | expect(session.status).to.equal('RUNNING') 22 | expect(session.id).to.equal(id) 23 | }) 24 | 25 | it('shoud create a session and retrieve a recording', async () => { 26 | const { id } = await browserbase.createSession() 27 | const recording = await browserbase.getSessionRecording(id) 28 | 29 | expect(recording.length).to.equal(0) 30 | }) 31 | 32 | it('shoud create a session and get debug url', async () => { 33 | const { id } = await browserbase.createSession() 34 | const debug = await browserbase.getDebugConnectionURLs(id) 35 | }) 36 | 37 | it('shoud create a session and get session logs', async () => { 38 | const { id } = await browserbase.createSession() 39 | const logs = await browserbase.getSessionLogs(id) 40 | 41 | expect(logs.length).to.equal(0) 42 | }) 43 | 44 | it('should load a webpage', async () => { 45 | const result = await browserbase.load('https://example.com') 46 | expect(result).contain('Example Domain') 47 | }) 48 | 49 | it('should load a webpage and return text content', async () => { 50 | const result = await browserbase.load('https://example.com/', { 51 | textContent: true, 52 | }) 53 | 54 | expect(result).contain('Example Domain') 55 | }) 56 | 57 | it('should load URLs', async () => { 58 | const result = await browserbase.loadURLs(['https://example.com']) 59 | const page = await result.next() 60 | 61 | // finish iterator 62 | result.next() 63 | expect(page.value).contain('Example Domain') 64 | }) 65 | 66 | it('should take a screenshot', { timeout: 10000 }, async () => { 67 | const result = await browserbase.screenshot('https://example.com') 68 | expect(result.length).to.equal(27040) 69 | }) 70 | 71 | it('should work with AI SDK', async () => { 72 | const ai = BrowserbaseAISDK(browserbase, { textContent: true }) 73 | const { page } = await ai.execute({ url: 'https://example.com' }) 74 | expect(page).contain('Example Domain') 75 | }) 76 | 77 | it('should create a session with dynamically created context', async () => { 78 | const createContextResponse = await browserbase.createContext(); 79 | const contextId = createContextResponse.id; 80 | 81 | // Use the created context ID in session creation 82 | const sessionOptions: CreateSessionOptions = { 83 | browserSettings: { 84 | context: { 85 | id: contextId, 86 | persist: true 87 | } 88 | } 89 | }; 90 | const { id } = await browserbase.createSession(sessionOptions); 91 | const session = await browserbase.getSession(id); 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /tsconfig.browser.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist/browser/tsc", 4 | "declaration": true, 5 | "lib": ["ESNext", "DOM"], 6 | "module": "NodeNext", 7 | "target": "es2016", 8 | "moduleResolution": "NodeNext" 9 | }, 10 | "include": ["src/recorder/recorder.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "declaration": true, 5 | "lib": ["ESNext", "DOM"], 6 | "module": "NodeNext", 7 | "target": "ESNext", 8 | "moduleResolution": "NodeNext" 9 | }, 10 | "include": ["src/**/*.ts"] 11 | } 12 | --------------------------------------------------------------------------------