├── src ├── config.ts ├── index.ts ├── getSnapshot.ts ├── errors.ts ├── types.ts ├── auto.ts ├── sanitizeHtml.ts ├── completeTask.ts ├── prompt.ts └── createActions.ts ├── .gitignore ├── knip.json ├── tsconfig.build.json ├── tests ├── pages │ ├── default.html │ ├── deep-nesting.html │ └── all-attributes.html ├── bin │ └── startServer.ts ├── auto.spec.ts └── actions.spec.ts ├── playwright.config.ts ├── tsconfig.json ├── .github └── workflows │ ├── branch.yaml │ └── release.yaml ├── LICENSE ├── package.json └── README.md /src/config.ts: -------------------------------------------------------------------------------- 1 | export const MAX_TASK_CHARS = 3000; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | test-results 4 | .env 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { auto } from "./auto"; 2 | export { AutoPlaywrightError } from "./errors"; 3 | -------------------------------------------------------------------------------- /knip.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/knip@2/schema.json", 3 | "entry": ["src/index.ts!", "tests/**/*"], 4 | "project": ["src/**/*.ts", "tests/**/*"] 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "outDir": "dist" 6 | }, 7 | "include": ["src"] 8 | } 9 | -------------------------------------------------------------------------------- /src/getSnapshot.ts: -------------------------------------------------------------------------------- 1 | import { sanitizeHtml } from "./sanitizeHtml"; 2 | import { Page } from "./types"; 3 | 4 | export const getSnapshot = async (page: Page) => { 5 | return { 6 | dom: sanitizeHtml(await page.content()), 7 | }; 8 | }; 9 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | export abstract class AutoPlaywrightError extends Error { 2 | public constructor(message?: string) { 3 | super(message); 4 | this.name = new.target.name; 5 | } 6 | } 7 | 8 | export class UnimplementedError extends AutoPlaywrightError { 9 | public constructor(message?: string) { 10 | super(message); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/pages/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Default Test Page 6 | 7 | 8 |

Hello, Rayrun!

9 |
10 | Some text here 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/pages/deep-nesting.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Deep Nesting Test Page 6 | 7 | 8 |
9 |
10 |
11 |
12 | 13 |
14 |
15 |
16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@playwright/test"; 2 | 3 | export default defineConfig({ 4 | timeout: 90000, 5 | webServer: { 6 | command: "npm run start", 7 | url: "http://127.0.0.1:3000", 8 | reuseExistingServer: !process.env.CI, 9 | stdout: "ignore", 10 | stderr: "pipe", 11 | }, 12 | use: { 13 | headless: true, 14 | baseURL: "http://127.0.0.1:3000", 15 | ignoreHTTPSErrors: true, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "declaration": true, 5 | "moduleResolution": "Node", 6 | "module": "CommonJS", 7 | "noEmit": true, 8 | "noImplicitReturns": true, 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": false, 11 | "strict": true, 12 | "target": "ES2022", 13 | "useUnknownInCatchVariables": false 14 | }, 15 | "include": ["src", "tests"] 16 | } 17 | -------------------------------------------------------------------------------- /tests/pages/all-attributes.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | All Attributes Test Page 6 | 7 | 8 |
14 | 15 | Some span 16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /.github/workflows/branch.yaml: -------------------------------------------------------------------------------- 1 | jobs: 2 | release: 3 | runs-on: ubuntu-latest 4 | environment: release 5 | name: Test 6 | steps: 7 | - name: setup repository 8 | uses: actions/checkout@v3 9 | with: 10 | fetch-depth: 0 11 | - name: setup node.js 12 | uses: actions/setup-node@v3 13 | with: 14 | node-version: "21" 15 | - run: npm ci 16 | - run: npm run lint 17 | - run: npm run build 18 | name: test 19 | on: 20 | pull_request: 21 | branches: 22 | - main 23 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { type TestType } from "@playwright/test"; 2 | 3 | export { type Page } from "@playwright/test"; 4 | 5 | export type Test = TestType; 6 | 7 | export type StepOptions = { 8 | debug?: boolean; 9 | model?: string; 10 | openaiApiKey?: string; 11 | openaiBaseUrl?: string; 12 | openaiDefaultQuery?: {}; 13 | openaiDefaultHeaders?: {}; 14 | }; 15 | 16 | export type TaskMessage = { 17 | task: string; 18 | snapshot: { 19 | dom: string; 20 | }; 21 | options?: StepOptions; 22 | }; 23 | 24 | export type TaskResult = { 25 | assertion?: boolean; 26 | query?: string; 27 | errorMessage?: string; 28 | }; 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | jobs: 2 | release: 3 | runs-on: ubuntu-latest 4 | environment: release 5 | name: Release 6 | steps: 7 | - name: setup repository 8 | uses: actions/checkout@v3 9 | with: 10 | fetch-depth: 0 11 | - name: setup node.js 12 | uses: actions/setup-node@v3 13 | with: 14 | node-version: "21" 15 | - run: npm ci 16 | - run: npm run lint 17 | - run: npm run build 18 | - run: npx semantic-release 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 22 | name: release 23 | on: 24 | push: 25 | branches: 26 | - main 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Luc Gagan (https://ray.run/) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@hono/node-server": "1.14.1", 4 | "hono": "4.7.7", 5 | "openai": "4.96.0", 6 | "playwright": "1.52.0", 7 | "sanitize-html": "2.16.0", 8 | "zod": "3.24.3" 9 | }, 10 | "author": { 11 | "name": "Luc Gagan", 12 | "email": "luc@ray.run", 13 | "url": "https://ray.run" 14 | }, 15 | "license": "MIT", 16 | "files": [ 17 | "dist" 18 | ], 19 | "homepage": "https://ray.run", 20 | "keywords": [ 21 | "openai", 22 | "playwright", 23 | "test" 24 | ], 25 | "peerDependencies": { 26 | "@playwright/test": "^1.39.0" 27 | }, 28 | "release": { 29 | "branches": [ 30 | "main" 31 | ] 32 | }, 33 | "name": "auto-playwright", 34 | "main": "./dist/index.js", 35 | "types": "./dist/index.d.ts", 36 | "exports": { 37 | ".": { 38 | "types": "./dist/index.d.ts", 39 | "default": "./dist/index.js" 40 | } 41 | }, 42 | "sideEffects": false, 43 | "description": "Automate Playwright tests using ChatGPT.", 44 | "repository": { 45 | "type": "git", 46 | "url": "https://github.com/lucgagan/auto-playwright" 47 | }, 48 | "devDependencies": { 49 | "@playwright/test": "1.52.0", 50 | "@types/node": "20.17.30", 51 | "@types/sanitize-html": "2.15.0", 52 | "knip": "5.50.5", 53 | "prettier": "3.5.3", 54 | "semantic-release": "24.2.3", 55 | "tsx": "4.19.3", 56 | "typescript": "5.8.3" 57 | }, 58 | "scripts": { 59 | "start": "tsx ./tests/bin/startServer.ts", 60 | "build": "tsc --project tsconfig.build.json", 61 | "format": "prettier --write ./src", 62 | "lint": "prettier --check ./src && knip", 63 | "semantic-release": "semantic-release", 64 | "test": "playwright test" 65 | }, 66 | "version": "0.0.0-development" 67 | } 68 | -------------------------------------------------------------------------------- /src/auto.ts: -------------------------------------------------------------------------------- 1 | import { MAX_TASK_CHARS } from "./config"; 2 | import { type Page, type Test, StepOptions } from "./types"; 3 | import { completeTask } from "./completeTask"; 4 | import { UnimplementedError } from "./errors"; 5 | import { getSnapshot } from "./getSnapshot"; 6 | 7 | export const auto = async ( 8 | task: string, 9 | config: { page: Page; test?: Test }, 10 | options?: StepOptions, 11 | ): Promise => { 12 | if (!config || !config.page) { 13 | throw Error( 14 | "The auto() function is missing the required `{ page }` argument.", 15 | ); 16 | } 17 | 18 | const { test, page } = config as { page: Page; test?: Test }; 19 | 20 | if (!test) { 21 | return await runTask(task, page, options); 22 | } 23 | 24 | return test.step(`auto-playwright.ai '${task}'`, async () => { 25 | const result = await runTask(task, page, options); 26 | 27 | if (result.errorMessage) { 28 | throw new UnimplementedError(result.errorMessage); 29 | } 30 | 31 | if (result.assertion !== undefined) { 32 | return result.assertion; 33 | } 34 | 35 | if (result.query !== undefined) { 36 | return result.query; 37 | } 38 | 39 | return undefined; 40 | }); 41 | }; 42 | 43 | async function runTask( 44 | task: string, 45 | page: Page, 46 | options: StepOptions | undefined, 47 | ) { 48 | if (task.length > MAX_TASK_CHARS) { 49 | throw new Error( 50 | `Provided task string is too long, max length is ${MAX_TASK_CHARS} chars.`, 51 | ); 52 | } 53 | 54 | const result = await completeTask(page, { 55 | task, 56 | snapshot: await getSnapshot(page), 57 | options: options 58 | ? { 59 | model: options.model ?? "gpt-4o", 60 | debug: options.debug ?? false, 61 | openaiApiKey: options.openaiApiKey, 62 | openaiBaseUrl: options.openaiBaseUrl, 63 | openaiDefaultQuery: options.openaiDefaultQuery, 64 | openaiDefaultHeaders: options.openaiDefaultHeaders, 65 | } 66 | : undefined, 67 | }); 68 | return result; 69 | } 70 | -------------------------------------------------------------------------------- /src/sanitizeHtml.ts: -------------------------------------------------------------------------------- 1 | import sanitizeHtmlLibrary = require("sanitize-html"); 2 | 3 | type SanitizeStylesType = 4 | | { [index: string]: { [index: string]: RegExp[] } } 5 | | undefined; 6 | 7 | type SanitizeClassListType = 8 | | { [index: string]: boolean | Array } 9 | | undefined; 10 | 11 | const DEFAULT_SANITIZE_TAGS = sanitizeHtmlLibrary.defaults.allowedTags.concat([ 12 | "body", 13 | "button", 14 | "form", 15 | "img", 16 | "input", 17 | "select", 18 | "textarea", 19 | "option", 20 | ]); 21 | 22 | const DEFAULT_SANITIZE_STYLES: SanitizeStylesType = undefined; 23 | 24 | const DEFAULT_SANITIZE_CLASS_LIST: SanitizeClassListType = undefined; 25 | 26 | export function getSanitizeOptions(): sanitizeHtmlLibrary.IOptions { 27 | return { 28 | // The default allowedTags list already includes _a lot_ of commonly used tags. 29 | // https://www.npmjs.com/package/sanitize-html#default-options 30 | // 31 | // I don't see a need for this to be configurable at the moment, 32 | // as it already covers all the layout tags, but we can revisit this if necessary. 33 | allowedTags: DEFAULT_SANITIZE_TAGS, 34 | // Setting allowedAttributes to false will allow all attributes. 35 | allowedAttributes: false, 36 | allowedClasses: DEFAULT_SANITIZE_CLASS_LIST, 37 | allowedStyles: DEFAULT_SANITIZE_STYLES, 38 | }; 39 | } 40 | 41 | /** 42 | * The reason for sanitization is because OpenAI does not need all of the HTML tags 43 | * to know how to interpret the website, e.g. it will not make a difference to AI if 44 | * we include or exclude 44 | 45 |
46 | 47 | 54 |

Selected fruit: None

55 | 62 |
63 |
64 | 65 | 71 |

Selected colors: None

72 | 80 |
81 | 84 | 85 | 93 | 94 |
95 | 96 |
97 | You have reached the bottom of the page! 98 |
99 | 100 | `), 101 | ); 102 | 103 | return new Promise((resolve) => { 104 | const server = serve( 105 | { 106 | fetch: app.fetch, 107 | port, 108 | }, 109 | (info) => { 110 | resolve({ 111 | close: () => { 112 | server.close(); 113 | }, 114 | port: info.port, 115 | }); 116 | }, 117 | ); 118 | }); 119 | }; 120 | 121 | startServer(3000); 122 | -------------------------------------------------------------------------------- /tests/auto.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | import { auto } from "../src/auto"; 3 | 4 | const options = undefined; 5 | 6 | test("executes query", async ({ page }) => { 7 | await page.goto("/"); 8 | 9 | const headerText = await auto("get the header text", { page, test }, options); 10 | 11 | expect(headerText).toBe("Hello, Rayrun!"); 12 | }); 13 | 14 | test("executes query using locator_evaluate", async ({ page }) => { 15 | await page.goto("/"); 16 | 17 | const headerText = await auto( 18 | "get the first letter of the header text", 19 | { 20 | page, 21 | test, 22 | }, 23 | options, 24 | ); 25 | 26 | // TODO assert that we are using locator_evaluate to get the first letter 27 | expect(headerText).toBe("H"); 28 | }); 29 | 30 | test("executes action", async ({ page }) => { 31 | await page.goto("/"); 32 | 33 | await auto(`Type "foo" in the search box`, { page, test }, options); 34 | 35 | await page.pause(); 36 | 37 | await expect(page.getByTestId("search-input")).toHaveValue("foo"); 38 | }); 39 | 40 | test("executes click", async ({ page }) => { 41 | await page.goto("/"); 42 | 43 | await auto( 44 | "Click the button until the counter value is equal to 2", 45 | { 46 | page, 47 | test, 48 | }, 49 | options, 50 | ); 51 | 52 | await expect(page.getByTestId("current-count")).toHaveText("2"); 53 | }); 54 | 55 | test("asserts (toBe)", async ({ page }) => { 56 | await page.goto("/"); 57 | 58 | const searchInputHasHeaderText = await auto( 59 | `Is the contents of the header equal to "Hello, Rayrun!"?`, 60 | { page, test }, 61 | options, 62 | ); 63 | 64 | expect(searchInputHasHeaderText).toBe(true); 65 | }); 66 | 67 | test("asserts (not.toBe)", async ({ page }) => { 68 | await page.goto("/"); 69 | 70 | const searchInputHasHeaderText = await auto( 71 | `Is the contents of the header equal to "Flying Donkeys"?`, 72 | { page, test }, 73 | options, 74 | ); 75 | 76 | expect(searchInputHasHeaderText).toBe(false); 77 | }); 78 | 79 | test("executes query, action and assertion", async ({ page }) => { 80 | await page.goto("/"); 81 | 82 | const headerText = await auto("get the header text", { page, test }, options); 83 | 84 | await auto(`type "${headerText}" in the search box`, { page, test }, options); 85 | 86 | const searchInputHasHeaderText = await auto( 87 | `is the contents of the search box equal to "${headerText}"?`, 88 | { page, test }, 89 | options, 90 | ); 91 | 92 | expect(searchInputHasHeaderText).toBe(true); 93 | }); 94 | 95 | test("runs without test parameter", async ({ page }) => { 96 | await page.goto("/"); 97 | 98 | const headerText = await auto("get the header text", { page }, options); 99 | 100 | expect(headerText.query).toBe("Hello, Rayrun!"); 101 | }); 102 | 103 | test("selects an option from dropdown using auto", async ({ page }) => { 104 | await page.goto("/"); 105 | 106 | await auto( 107 | "Select the 'Banana' option from the fruit dropdown", 108 | { page, test }, 109 | options, 110 | ); 111 | 112 | await expect(page.getByTestId("selected-fruit")).toHaveText("Banana"); 113 | }); 114 | 115 | test("selects an option from dropdown by value using auto", async ({ 116 | page, 117 | }) => { 118 | await page.goto("/"); 119 | 120 | await auto( 121 | "Select the option with value 'cherry' from the fruit dropdown", 122 | { page, test }, 123 | options, 124 | ); 125 | 126 | await expect(page.getByTestId("selected-fruit")).toHaveText("Cherry"); 127 | }); 128 | 129 | test("selects multiple options from multi-select using auto", async ({ 130 | page, 131 | }) => { 132 | await page.goto("/"); 133 | 134 | await auto( 135 | "Select the 'Red' and 'Blue' options from the colors multi-select", 136 | { page, test }, 137 | options, 138 | ); 139 | 140 | await expect(page.getByTestId("selected-colors")).toHaveText("Red, Blue"); 141 | }); 142 | 143 | test("extracts visible structure of the page using auto", async ({ page }) => { 144 | test.setTimeout(3 * 60 * 1000); 145 | 146 | await page.goto("/"); 147 | 148 | const structure = await auto( 149 | "Get the visible structure of the page", 150 | { page, test }, 151 | options, 152 | ); 153 | 154 | expect(typeof structure).toBe("string"); 155 | expect(structure.length).toBeGreaterThan(0); 156 | }); 157 | 158 | test("locates elements by ARIA role using auto", async ({ page }) => { 159 | await page.goto("/"); 160 | 161 | await auto( 162 | "Find and click the 'Click me' button using its role", 163 | { page, test }, 164 | options, 165 | ); 166 | 167 | const countText = await page 168 | .locator("[data-testid='current-count']") 169 | .innerText(); 170 | expect(countText).toBe("1"); 171 | }); 172 | 173 | test("locates elements by visible text using auto", async ({ page }) => { 174 | await page.goto("/"); 175 | 176 | await auto( 177 | "Find the fruit dropdown and select the option 'Banana' by visible text", 178 | { page, test }, 179 | options, 180 | ); 181 | 182 | const selectedFruit = await page 183 | .locator("[data-testid='selected-fruit']") 184 | .innerText(); 185 | expect(selectedFruit).toBe("Banana"); 186 | }); 187 | 188 | test("waits for dynamic content to load using auto", async ({ page }) => { 189 | await page.goto("/"); 190 | 191 | await auto( 192 | "Wait for the dynamic content to appear on the page", 193 | { page, test }, 194 | options, 195 | ); 196 | 197 | const dynamicContent = await page.locator("[data-testid='dynamic-content']"); 198 | await expect(dynamicContent).toBeVisible(); 199 | }); 200 | 201 | test("extracts only visible text from a specific element using auto", async ({ 202 | page, 203 | }) => { 204 | await page.goto("/"); 205 | 206 | const extractedText = await auto( 207 | "Extract the visible text from the 'Selected fruit' area", 208 | { page, test }, 209 | options, 210 | ); 211 | 212 | expect(typeof extractedText).toBe("string"); 213 | expect(extractedText.length).toBeGreaterThan(0); 214 | }); 215 | 216 | test("scrolls element into view using auto", async ({ page }) => { 217 | await page.goto("/"); 218 | 219 | await auto( 220 | "Scroll to the bottom of the page where it says 'You have reached the bottom of the page!'", 221 | { 222 | page, 223 | test, 224 | }, 225 | options, 226 | ); 227 | 228 | const isVisible = await page.locator("#bottom-of-page").isVisible(); 229 | expect(isVisible).toBeTruthy(); 230 | }); 231 | 232 | test("waits for network idle state using auto", async ({ page }) => { 233 | await page.goto("/"); 234 | 235 | await auto("Wait until network is idle", { page, test }, options); 236 | 237 | expect(true).toBe(true); 238 | }); 239 | -------------------------------------------------------------------------------- /tests/actions.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | import { createActions } from "../src/createActions"; 3 | import { ChatCompletionRunner } from "openai/lib/ChatCompletionRunner"; 4 | import { getSanitizeOptions } from "../src/sanitizeHtml"; 5 | 6 | const runner = {} as ChatCompletionRunner; 7 | 8 | test("finds element using a CSS locator and returns elementId", async ({ 9 | page, 10 | }) => { 11 | await page.goto("/"); 12 | 13 | const actions = createActions(page); 14 | 15 | const result = await actions.locateElement.function( 16 | { 17 | cssSelector: "h1", 18 | }, 19 | runner, 20 | ); 21 | 22 | expect(result).toStrictEqual({ 23 | elementId: expect.any(String), 24 | }); 25 | }); 26 | 27 | test("selects option by value in a select element using elementId", async ({ 28 | page, 29 | }) => { 30 | await page.goto("/"); 31 | 32 | const actions = createActions(page); 33 | 34 | const locateResult = (await actions.locateElement.function( 35 | { 36 | cssSelector: "#fruit-select", 37 | }, 38 | runner, 39 | )) as { elementId: string }; 40 | 41 | const selectResult = await actions.locator_selectOption.function( 42 | { 43 | elementId: locateResult.elementId, 44 | value: "banana", 45 | }, 46 | runner, 47 | ); 48 | 49 | expect(selectResult).toStrictEqual({ 50 | success: true, 51 | }); 52 | 53 | await expect(page.locator("#selected-fruit")).toHaveText("Banana"); 54 | }); 55 | 56 | test("selects option by value in a select element using CSS selector", async ({ 57 | page, 58 | }) => { 59 | await page.goto("/"); 60 | 61 | const actions = createActions(page); 62 | 63 | const selectResult = await actions.locator_selectOption.function( 64 | { 65 | cssSelector: "#fruit-select", 66 | value: "cherry", 67 | }, 68 | runner, 69 | ); 70 | 71 | expect(selectResult).toStrictEqual({ 72 | success: true, 73 | }); 74 | 75 | await expect(page.locator("#selected-fruit")).toHaveText("Cherry"); 76 | }); 77 | 78 | test("selects option by label in a select element using CSS selector", async ({ 79 | page, 80 | }) => { 81 | await page.goto("/"); 82 | 83 | const actions = createActions(page); 84 | 85 | const selectResult = await actions.locator_selectOption.function( 86 | { 87 | cssSelector: "#fruit-select", 88 | label: "Orange", 89 | }, 90 | runner, 91 | ); 92 | 93 | expect(selectResult).toStrictEqual({ 94 | success: true, 95 | }); 96 | 97 | await expect(page.locator("#selected-fruit")).toHaveText("Orange"); 98 | }); 99 | 100 | test("selects option by index in a select element using CSS selector", async ({ 101 | page, 102 | }) => { 103 | await page.goto("/"); 104 | 105 | const actions = createActions(page); 106 | 107 | const selectResult = await actions.locator_selectOption.function( 108 | { 109 | cssSelector: "#fruit-select", 110 | index: 1, 111 | }, 112 | runner, 113 | ); 114 | 115 | expect(selectResult).toStrictEqual({ 116 | success: true, 117 | }); 118 | 119 | await expect(page.locator("#selected-fruit")).toHaveText("Apple"); 120 | }); 121 | 122 | test("selects multiple options in a multiple select element using CSS selector", async ({ 123 | page, 124 | }) => { 125 | await page.goto("/"); 126 | 127 | const actions = createActions(page); 128 | 129 | const selectResult = await actions.locator_selectOption.function( 130 | { 131 | cssSelector: "#colors-select", 132 | value: ["red", "blue"], 133 | }, 134 | runner, 135 | ); 136 | 137 | expect(selectResult).toStrictEqual({ 138 | success: true, 139 | }); 140 | 141 | await expect(page.locator("#selected-colors")).toHaveText("Red, Blue"); 142 | }); 143 | 144 | function createValidationFunction( 145 | allowedTags: string[], 146 | allowedAttributes: any, 147 | maxDepth = 3, 148 | ) { 149 | return function validateNode(node: any, depth = 0) { 150 | expect(node.tag).toBeDefined(); 151 | expect(allowedTags).toContain(node.tag); 152 | 153 | if (allowedAttributes !== false) { 154 | const allowedForAll = allowedAttributes?.["*"]; 155 | const allowedForTag = allowedAttributes?.[node.tag]; 156 | 157 | const isAllowAllForTag = allowedForTag === true; 158 | const isAllowAllGlobal = allowedForAll === true; 159 | 160 | for (const attrName of Object.keys(node.attributes || {})) { 161 | if (!(isAllowAllForTag || isAllowAllGlobal)) { 162 | if (Array.isArray(allowedForTag)) { 163 | expect(allowedForTag).toContain(attrName); 164 | } else if (Array.isArray(allowedForAll)) { 165 | expect(allowedForAll).toContain(attrName); 166 | } else { 167 | throw new Error( 168 | `Attribute ${attrName} is not allowed for tag ${node.tag}`, 169 | ); 170 | } 171 | } 172 | } 173 | } 174 | 175 | if (depth > maxDepth) { 176 | throw new Error(`Depth exceeded maxDepth: ${depth}`); 177 | } 178 | 179 | if (Array.isArray(node.children)) { 180 | for (const child of node.children) { 181 | validateNode(child, depth + 1); 182 | } 183 | } 184 | }; 185 | } 186 | 187 | test("getVisibleStructure on default page", async ({ page }) => { 188 | await page.goto("http://localhost:3000/tests/pages/default.html"); 189 | 190 | const actions = createActions(page); 191 | 192 | const { structure } = (await actions.getVisibleStructure.function( 193 | {}, 194 | runner, 195 | )) as { structure: any }; 196 | 197 | expect(typeof structure).toBe("object"); 198 | expect(structure).not.toBeNull(); 199 | 200 | const sanitizeOptions = getSanitizeOptions(); 201 | const validateNode = createValidationFunction( 202 | sanitizeOptions.allowedTags || [], 203 | sanitizeOptions.allowedAttributes, 204 | ); 205 | 206 | validateNode(structure); 207 | }); 208 | 209 | test("getVisibleStructure on all attributes page", async ({ page }) => { 210 | await page.goto("http://localhost:3000/tests/pages/all-attributes.html"); 211 | 212 | const actions = createActions(page); 213 | 214 | const { structure } = (await actions.getVisibleStructure.function( 215 | {}, 216 | runner, 217 | )) as { structure: any }; 218 | 219 | expect(typeof structure).toBe("object"); 220 | expect(structure).not.toBeNull(); 221 | 222 | const sanitizeOptions = getSanitizeOptions(); 223 | const validateNode = createValidationFunction( 224 | sanitizeOptions.allowedTags || [], 225 | sanitizeOptions.allowedAttributes, 226 | ); 227 | 228 | validateNode(structure); 229 | }); 230 | 231 | test("getVisibleStructure respects max depth", async ({ page }) => { 232 | await page.goto("http://localhost:3000/tests/pages/deep-nesting.html"); 233 | 234 | const actions = createActions(page); 235 | 236 | const { structure } = (await actions.getVisibleStructure.function( 237 | {}, 238 | runner, 239 | )) as { structure: any }; 240 | 241 | expect(typeof structure).toBe("object"); 242 | expect(structure).not.toBeNull(); 243 | 244 | const sanitizeOptions = getSanitizeOptions(); 245 | const validateNode = createValidationFunction( 246 | sanitizeOptions.allowedTags || [], 247 | sanitizeOptions.allowedAttributes, 248 | 5, 249 | ); 250 | 251 | validateNode(structure); 252 | }); 253 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Auto Playwright 2 | 3 | Run Playwright tests using AI. 4 | 5 | ## Setup 6 | 7 | 1. Install `auto-playwright` dependency: 8 | 9 | ```bash 10 | npm install auto-playwright -D 11 | ``` 12 | 13 | 2. This package relies on talking with OpenAI (https://openai.com/). You must export the API token as an enviroment variable or add it to your `.env` file: 14 | 15 | ```bash 16 | export OPENAI_API_KEY='sk-..." 17 | ``` 18 | 19 | 3. Import and use the `auto` function: 20 | 21 | ```ts 22 | import { test, expect } from "@playwright/test"; 23 | import { auto } from "auto-playwright"; 24 | 25 | test("auto Playwright example", async ({ page }) => { 26 | await page.goto("/"); 27 | 28 | // `auto` can query data 29 | // In this case, the result is plain-text contents of the header 30 | const headerText = await auto("get the header text", { page, test }); 31 | 32 | // `auto` can perform actions 33 | // In this case, auto will find and fill in the search text input 34 | await auto(`Type "${headerText}" in the search box`, { page, test }); 35 | 36 | // `auto` can assert the state of the website 37 | // In this case, the result is a boolean outcome 38 | const searchInputHasHeaderText = await auto( 39 | `Is the contents of the search box equal to "${headerText}"?`, 40 | { page, test }, 41 | ); 42 | 43 | expect(searchInputHasHeaderText).toBe(true); 44 | }); 45 | ``` 46 | 47 | ### Setup with Azure OpenAI 48 | 49 | Include the StepOptions type with the values needed for connecting to Azure OpenAI. 50 | 51 | ```ts 52 | import { test, expect } from "@playwright/test"; 53 | import { auto } from "auto-playwright"; 54 | import { StepOptions } from "../src/types"; 55 | 56 | const apiKey = "apikey"; 57 | const resource = "azure-resource-name"; 58 | const model = "model-deployment-name"; 59 | 60 | const options: StepOptions = { 61 | model: model, 62 | openaiApiKey: apiKey, 63 | openaiBaseUrl: `https://${resource}.openai.azure.com/openai/deployments/${model}`, 64 | openaiDefaultQuery: { "api-version": "2023-07-01-preview" }, 65 | openaiDefaultHeaders: { "api-key": apiKey }, 66 | }; 67 | 68 | test("auto Playwright example", async ({ page }) => { 69 | await page.goto("/"); 70 | 71 | // `auto` can query data 72 | // In this case, the result is plain-text contents of the header 73 | const headerText = await auto("get the header text", { page, test }, options); 74 | 75 | // `auto` can perform actions 76 | // In this case, auto will find and fill in the search text input 77 | await auto(`Type "${headerText}" in the search box`, { page, test }, options); 78 | 79 | // `auto` can assert the state of the website 80 | // In this case, the result is a boolean outcome 81 | const searchInputHasHeaderText = await auto( 82 | `Is the contents of the search box equal to "${headerText}"?`, 83 | { page, test }, 84 | options, 85 | ); 86 | 87 | expect(searchInputHasHeaderText).toBe(true); 88 | }); 89 | ``` 90 | 91 | ## Usage 92 | 93 | At minimum, the `auto` function requires a _plain text prompt_ and an _argument_ that contains your `page` and `test` (optional) objects. 94 | 95 | ```ts 96 | auto("", { page, test }); 97 | ``` 98 | 99 | ### Browser automation 100 | 101 | Running without the `test` parameter: 102 | 103 | ```ts 104 | import { chromium } from "playwright"; 105 | import { auto } from "auto-playwright"; 106 | 107 | (async () => { 108 | const browser = await chromium.launch({ headless: true }); 109 | const context = await browser.newContext(); 110 | const page = await context.newPage(); 111 | // Navigate to a website 112 | await page.goto("https://www.example.com"); 113 | 114 | // `auto` can query data 115 | // In this case, the result is plain-text contents of the header 116 | const res = await auto("get the header text", { page }); 117 | 118 | // use res.query to get a query result. 119 | console.log(res); 120 | await page.close(); 121 | })(); 122 | ``` 123 | 124 | ### Debug 125 | 126 | You may pass a `debug` attribute as the third parameter to the `auto` function. This will print the prompt and the commands executed by OpenAI. 127 | 128 | ```ts 129 | await auto("get the header text", { page, test }, { debug: true }); 130 | ``` 131 | 132 | You may also set environment variable `AUTO_PLAYWRIGHT_DEBUG=true`, which will enable debugging for all `auto` calls. 133 | 134 | ```bash 135 | export AUTO_PLAYWRIGHT_DEBUG=true 136 | ``` 137 | 138 | ## Supported Browsers 139 | 140 | Every browser that Playwright supports. 141 | 142 | ## Additional Options 143 | 144 | There are additional options you can pass as a third argument: 145 | 146 | ```ts 147 | const options = { 148 | // If true, debugging information is printed in the console. 149 | debug: boolean, 150 | // The OpenAI model (https://platform.openai.com/docs/models/overview) 151 | model: "gpt-4-1106-preview", 152 | // The OpenAI API key 153 | openaiApiKey: "sk-...", 154 | }; 155 | 156 | auto("", { page, test }, options); 157 | ``` 158 | 159 | ## Supported Actions & Return Values 160 | 161 | Depending on the `type` of action (inferred by the `auto` function), there are different behaviors and return types. 162 | 163 | ### Action 164 | 165 | An action (e.g. "click") is some simulated user interaction with the page, e.g. a click on a link. Actions will return `undefined`` if they were successful and will throw an error if they failed, e.g. 166 | 167 | ```ts 168 | try { 169 | await auto("click the link", { page, test }); 170 | } catch (e) { 171 | console.error("failed to click the link"); 172 | } 173 | ``` 174 | 175 | ### Query 176 | 177 | A query will return requested data from the page as a string, e.g. 178 | 179 | ```ts 180 | const linkText = await auto("Get the text of the first link", { page, test }); 181 | 182 | console.log("The link text is", linkText); 183 | ``` 184 | 185 | ### Assert 186 | 187 | An assertion is a question that will return `true` or `false`, e.g. 188 | 189 | ```ts 190 | const thereAreThreeLinks = await auto("Are there 3 links on the page?", { 191 | page, 192 | test, 193 | }); 194 | 195 | console.log(`"There are 3 links" is a ${thereAreThreeLinks} statement`); 196 | ``` 197 | 198 | ## Why use Auto Playwright? 199 | 200 | | Aspect | Conventional Approach | Testing with Auto Playwright | 201 | | ------------------------------ | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | 202 | | **Coupling with Markup** | Strongly linked to the application's markup. | Eliminates the use of selectors; actions are determined by the AI assistant at runtime. | 203 | | **Speed of Implementation** | Slower implementation due to the need for precise code translation for each action. | Rapid test creation using simple, plain text instructions for actions and assertions. | 204 | | **Handling Complex Scenarios** | Automating complex scenarios is challenging and prone to frequent failures. | Facilitates testing of complex scenarios by focusing on the intended test outcomes. | 205 | | **Test Writing Timing** | Can only write tests after the complete development of the functionality. | Enables a Test-Driven Development (TDD) approach, allowing test writing concurrent with or before functionality development. | 206 | 207 | ## Supported Playwright Actions 208 | 209 | - `locator.blur` 210 | - `locator.boundingBox` 211 | - `locator.check` 212 | - `locator.clear` 213 | - `locator.click` 214 | - `locator.count` 215 | - `locator.fill` 216 | - `locator.getAttribute` 217 | - `locator.innerHTML` 218 | - `locator.innerText` 219 | - `locator.inputValue` 220 | - `locator.isChecked` 221 | - `locator.isEditable` 222 | - `locator.isEnabled` 223 | - `locator.isVisible` 224 | - `locator.pressKey` 225 | - `locator.selectOption` 226 | - `locator.textContent` 227 | - `locator.uncheck` 228 | - `page.goto` 229 | - `page.keyboard.press` 230 | 231 | Adding new actions is easy: just update the `functions` in [`src/completeTask.ts`](src/completeTask.ts). 232 | 233 | ## Pricing 234 | 235 | This library is free. However, there are costs associated with using OpenAI. You can find more information about pricing here: https://openai.com/pricing/. 236 | 237 |
238 | Example 239 | 240 | Using https://ray.run/ as an example, the cost of running a test step is approximately $0.01 using GPT-4 Turbo (and $0.001 using GPT-3.5 Turbo). 241 | 242 | The low cost is in part because `auto-playwright` uses HTML sanitization to reduce the payload size, e.g. What follows is the payload that would be submitted for https://ray.run/. 243 | 244 | Naturally, the price will vary dramatically depending on the payload. 245 | 246 | ```html 247 | 335 | ``` 336 | 337 |
338 | 339 | ## Implementation 340 | 341 | ### HTML Sanitization 342 | 343 | The `auto` function uses [sanitize-html](https://www.npmjs.com/package/sanitize-html) to sanitize the HTML of the page before sending it to OpenAI. This is done to reduce cost and improve the quality of the generated text. 344 | 345 | ## ZeroStep 346 | 347 | This project draws its inspiration from [ZeroStep](https://zerostep.com/). ZeroStep offers a similar API but with a more robust implementation through its proprietary backend. Auto Playwright was created with the aim of exploring the underlying technology of ZeroStep and establishing a basis for an open-source version of their software. For production environments, I suggest opting for ZeroStep. 348 | 349 | Here's a side-by-side comparison of Auto Playwright and ZeroStep: 350 | 351 | | Criteria | Auto Playwright | ZeroStep | 352 | | ------------------------------------------------------------------------------------- | --------------- | -------- | 353 | | Uses OpenAI API | Yes | No[^3] | 354 | | Uses plain-text prompts | Yes | No | 355 | | Uses [`functions`](https://www.npmjs.com/package/openai#automated-function-calls) SDK | Yes | No | 356 | | Uses HTML sanitization | Yes | No | 357 | | Uses Playwright API | Yes | No[^4] | 358 | | Uses screenshots | No | Yes | 359 | | Uses queue | No | Yes | 360 | | Uses WebSockets | No | Yes | 361 | | Snapshots | HTML | DOM | 362 | | Implements parallelism | No | Yes | 363 | | Allows scrolling | No | Yes | 364 | | Provides fixtures | No | Yes | 365 | | License | MIT | MIT | 366 | 367 | [^3]: Uses ZeroStep proprietary API. 368 | 369 | [^4]: Uses _some_ Playwright API, but predominantly relies on Chrome DevTools Protocol (CDP). 370 | 371 |
372 | Zero Step License 373 | 374 | ``` 375 | MIT License 376 | 377 | Copyright (c) 2023 Reflect Software Inc 378 | 379 | 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: 380 | 381 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 382 | 383 | 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. 384 | ``` 385 | 386 |
387 | -------------------------------------------------------------------------------- /src/createActions.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "@playwright/test"; 2 | import { randomUUID } from "crypto"; 3 | import { RunnableFunctionWithParse } from "openai/lib/RunnableFunction"; 4 | import { z } from "zod"; 5 | import { getSanitizeOptions } from "./sanitizeHtml"; 6 | 7 | export const createActions = ( 8 | page: Page, 9 | ): Record> => { 10 | const getLocator = (elementId: string) => { 11 | return page.locator(`[data-element-id="${elementId}"]`); 12 | }; 13 | 14 | return { 15 | locator_pressKey: { 16 | function: async (args: { elementId: string; key: string }) => { 17 | const { elementId, key } = args; 18 | await getLocator(elementId).press(key); 19 | return { success: true }; 20 | }, 21 | name: "locator_pressKey", 22 | description: "Presses a key while focused on the specified element.", 23 | parse: (args: string) => { 24 | return z 25 | .object({ 26 | elementId: z.string(), 27 | key: z.string(), 28 | }) 29 | .parse(JSON.parse(args)); 30 | }, 31 | parameters: { 32 | type: "object", 33 | properties: { 34 | elementId: { type: "string" }, 35 | key: { 36 | type: "string", 37 | description: 38 | "The name of the key to press, e.g., 'Enter', 'ArrowUp', 'a'.", 39 | }, 40 | }, 41 | }, 42 | }, 43 | page_pressKey: { 44 | function: async (args: { elementId: string; key: string }) => { 45 | const { key } = args; 46 | await page.keyboard.press(key); 47 | return { success: true }; 48 | }, 49 | name: "page_pressKey", 50 | description: "Presses a key globally on the page.", 51 | parse: (args: string) => { 52 | return z 53 | .object({ 54 | key: z.string(), 55 | }) 56 | .parse(JSON.parse(args)); 57 | }, 58 | parameters: { 59 | type: "object", 60 | properties: { 61 | key: { 62 | type: "string", 63 | description: 64 | "The name of the key to press, e.g., 'Enter', 'ArrowDown', 'b'.", 65 | }, 66 | }, 67 | }, 68 | }, 69 | locateElement: { 70 | function: async (args: { cssSelector: string }) => { 71 | const locator = page.locator(args.cssSelector); 72 | const elementId = randomUUID(); 73 | await locator 74 | .first() 75 | .evaluate( 76 | (node, id) => node.setAttribute("data-element-id", id), 77 | elementId, 78 | ); 79 | return { elementId }; 80 | }, 81 | name: "locateElement", 82 | description: 83 | "Locates element using a CSS selector and returns elementId. This element ID can be used with other functions to perform actions on the element.", 84 | parse: (args: string) => { 85 | return z 86 | .object({ 87 | cssSelector: z.string(), 88 | }) 89 | .parse(JSON.parse(args)); 90 | }, 91 | parameters: { 92 | type: "object", 93 | properties: { 94 | cssSelector: { 95 | type: "string", 96 | }, 97 | }, 98 | }, 99 | }, 100 | locator_evaluate: { 101 | function: async (args: { pageFunction: string; elementId: string }) => { 102 | return { 103 | result: await getLocator(args.elementId).evaluate(args.pageFunction), 104 | }; 105 | }, 106 | description: 107 | "Execute JavaScript code in the page, taking the matching element as an argument.", 108 | name: "locator_evaluate", 109 | parameters: { 110 | type: "object", 111 | properties: { 112 | elementId: { 113 | type: "string", 114 | }, 115 | pageFunction: { 116 | type: "string", 117 | description: 118 | "Function to be evaluated in the page context, e.g. node => node.innerText", 119 | }, 120 | }, 121 | }, 122 | parse: (args: string) => { 123 | return z 124 | .object({ 125 | elementId: z.string(), 126 | pageFunction: z.string(), 127 | }) 128 | .parse(JSON.parse(args)); 129 | }, 130 | }, 131 | locator_getAttribute: { 132 | function: async (args: { attributeName: string; elementId: string }) => { 133 | return { 134 | attributeValue: await getLocator(args.elementId).getAttribute( 135 | args.attributeName, 136 | ), 137 | }; 138 | }, 139 | name: "locator_getAttribute", 140 | description: "Returns the matching element's attribute value.", 141 | parse: (args: string) => { 142 | return z 143 | .object({ 144 | elementId: z.string(), 145 | attributeName: z.string(), 146 | }) 147 | .parse(JSON.parse(args)); 148 | }, 149 | parameters: { 150 | type: "object", 151 | properties: { 152 | attributeName: { 153 | type: "string", 154 | }, 155 | elementId: { 156 | type: "string", 157 | }, 158 | }, 159 | }, 160 | }, 161 | locator_innerHTML: { 162 | function: async (args: { elementId: string }) => { 163 | return { innerHTML: await getLocator(args.elementId).innerHTML() }; 164 | }, 165 | name: "locator_innerHTML", 166 | description: "Returns the element.innerHTML.", 167 | parse: (args: string) => { 168 | return z 169 | .object({ 170 | elementId: z.string(), 171 | }) 172 | .parse(JSON.parse(args)); 173 | }, 174 | parameters: { 175 | type: "object", 176 | properties: { 177 | elementId: { 178 | type: "string", 179 | }, 180 | }, 181 | }, 182 | }, 183 | locator_innerText: { 184 | function: async (args: { elementId: string }) => { 185 | return { innerText: await getLocator(args.elementId).innerText() }; 186 | }, 187 | name: "locator_innerText", 188 | description: "Returns the element.innerText.", 189 | parse: (args: string) => { 190 | return z 191 | .object({ 192 | elementId: z.string(), 193 | }) 194 | .parse(JSON.parse(args)); 195 | }, 196 | parameters: { 197 | type: "object", 198 | properties: { 199 | elementId: { 200 | type: "string", 201 | }, 202 | }, 203 | }, 204 | }, 205 | locator_textContent: { 206 | function: async (args: { elementId: string }) => { 207 | return { 208 | textContent: await getLocator(args.elementId).textContent(), 209 | }; 210 | }, 211 | name: "locator_textContent", 212 | description: "Returns the node.textContent.", 213 | parse: (args: string) => { 214 | return z 215 | .object({ 216 | elementId: z.string(), 217 | }) 218 | .parse(JSON.parse(args)); 219 | }, 220 | parameters: { 221 | type: "object", 222 | properties: { 223 | elementId: { 224 | type: "string", 225 | }, 226 | }, 227 | }, 228 | }, 229 | locator_inputValue: { 230 | function: async (args: { elementId: string }) => { 231 | return { 232 | inputValue: await getLocator(args.elementId).inputValue(), 233 | }; 234 | }, 235 | name: "locator_inputValue", 236 | description: 237 | "Returns input.value for the selected or