├── 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 |
82 | This is dynamic content.
83 |
84 |
85 |
93 |
94 |
95 |
96 |
97 | You have reached the bottom of the page!
98 |
99 |