├── .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 |
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 |
18 |
--------------------------------------------------------------------------------
/logo/light.svg:
--------------------------------------------------------------------------------
1 |
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 |
--------------------------------------------------------------------------------