From c36b8c5d5911f2ed84453138a94b8eb67c1ddbab Mon Sep 17 00:00:00 2001 From: Asaf Korem <55082339+asafkorem@users.noreply.github.com> Date: Sun, 26 Jan 2025 10:35:00 +0200 Subject: [PATCH] examples: add playwright demo. (#65) * .git: ignore all temp files * examples: add playwright driver. * feat: improve playwright driver. * package: add playwright test script. --- .gitignore | 2 +- examples/playwright/jest.config.js | 14 + examples/playwright/package.json | 19 ++ examples/playwright/tests/example.test.ts | 41 +++ examples/playwright/tsconfig.json | 14 + package-lock.json | 65 ++++ package.json | 9 +- src/actions/CopilotStepPerformer.ts | 11 + src/drivers/playwright/getCleanDOM.ts | 73 +++++ src/drivers/playwright/index.ts | 344 ++++++++++++++++++++++ 10 files changed, 588 insertions(+), 4 deletions(-) create mode 100644 examples/playwright/jest.config.js create mode 100644 examples/playwright/package.json create mode 100644 examples/playwright/tests/example.test.ts create mode 100644 examples/playwright/tsconfig.json create mode 100644 src/drivers/playwright/getCleanDOM.ts create mode 100644 src/drivers/playwright/index.ts diff --git a/.gitignore b/.gitignore index 2b70b7e..a4b9f01 100644 --- a/.gitignore +++ b/.gitignore @@ -133,6 +133,6 @@ dist .dist # Examples -examples/puppeteer/temp/ +**/*/temp/ **/*/detox_copilot_cache.json diff --git a/examples/playwright/jest.config.js b/examples/playwright/jest.config.js new file mode 100644 index 0000000..01acf97 --- /dev/null +++ b/examples/playwright/jest.config.js @@ -0,0 +1,14 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/tests/**/*.test.ts'], + moduleFileExtensions: ['ts', 'js', 'json', 'node'], + moduleDirectories: ['node_modules', '/node_modules'], + transformIgnorePatterns: ['node_modules/(?!examples)'], + moduleNameMapper: { + '^@copilot$': '/../../src', + '^@copilot/(.*)$': '/../../src/$1', + '^@/(.*)$': '/../../src/$1' + } +}; diff --git a/examples/playwright/package.json b/examples/playwright/package.json new file mode 100644 index 0000000..c5d5b6d --- /dev/null +++ b/examples/playwright/package.json @@ -0,0 +1,19 @@ +{ + "name": "detox-copilot-playwright-example", + "version": "1.0.0", + "private": true, + "scripts": { + "test": "jest" + }, + "dependencies": { + "detox-copilot": "file:../.." + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "ts-jest": "^29.2.4", + "typescript": "^5.3.3", + "playwright": "^1.42.1", + "@playwright/test": "^1.42.1" + } +} diff --git a/examples/playwright/tests/example.test.ts b/examples/playwright/tests/example.test.ts new file mode 100644 index 0000000..d1ed2fb --- /dev/null +++ b/examples/playwright/tests/example.test.ts @@ -0,0 +1,41 @@ +import copilot from "@copilot"; +import { PromptHandler } from "../../utils/promptHandler"; +import { PlaywrightFrameworkDriver } from "@copilot/drivers/playwright"; + +describe("Example Test Suite", () => { + jest.setTimeout(300000); + + let frameworkDriver: PlaywrightFrameworkDriver; + + beforeAll(async () => { + const promptHandler: PromptHandler = new PromptHandler(); + + frameworkDriver = new PlaywrightFrameworkDriver(); + + copilot.init({ + frameworkDriver, + promptHandler, + }); + }); + + afterAll(async () => { + const page = frameworkDriver.getCurrentPage(); + if (page) { + await page.context().browser()?.close(); + } + }); + + beforeEach(async () => { + copilot.start(); + }); + + afterEach(async () => { + copilot.end(); + }); + + it("perform test with pilot", async () => { + await copilot.pilot( + "Open https://www.wix.com/domains and search for the domain Shraga.com, is it available?", + ); + }); +}); diff --git a/examples/playwright/tsconfig.json b/examples/playwright/tsconfig.json new file mode 100644 index 0000000..b57926e --- /dev/null +++ b/examples/playwright/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": "../../src", + "rootDir": ".", + "paths": { + "@/*": ["./*"], + "@copilot": ["./index"], + "@copilot/*": ["./*"] + } + }, + "include": ["tests/**/*"], + "exclude": ["node_modules"] +} diff --git a/package-lock.json b/package-lock.json index d9d6926..c29ec8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "winston": "^3.17.0" }, "devDependencies": { + "@playwright/test": "^1.50.0", "@types/jest": "^29.5.12", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", @@ -26,6 +27,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "jest": "^29.7.0", + "playwright": "^1.50.0", "prettier": "^3.2.5", "puppeteer": "^20.8.0", "ts-jest": "^29.2.4", @@ -1495,6 +1497,22 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@playwright/test": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.50.0.tgz", + "integrity": "sha512-ZGNXbt+d65EGjBORQHuYKj+XhCewlwpnSd/EDuLPZGSiEWmgOJB5RmMCCYGy5aMfTs9wx61RivfDKi8H/hcMvw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.50.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@puppeteer/browsers": { "version": "1.4.6", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-1.4.6.tgz", @@ -5884,6 +5902,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.50.0.tgz", + "integrity": "sha512-+GinGfGTrd2IfX1TA4N2gNmeIksSb+IAe589ZH+FlmpV3MYTx6+buChGIuDLQwrGNCw2lWibqV50fU510N7S+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.50.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.50.0.tgz", + "integrity": "sha512-CXkSSlr4JaZs2tZHI40DsZUN/NIwgaUPsyLuOAaIZp2CyF2sN5MM5NJsyB188lFSSozFxQ5fPT4qM+f0tH/6wQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plimit-lit": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz", diff --git a/package.json b/package.json index 8058d98..8e741fa 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "build": "tsc && tsc-alias", "test": "jest", "test:examples:puppeteer": "cd examples/puppeteer && jest", + "test:examples:playwright": "cd examples/playwright && jest", "bump-version": "npm version patch && git push && git push --tags", "release": "npm run test && npm run bump-version && npm run build && npm publish", "lint": "eslint . --ext .ts", @@ -36,19 +37,21 @@ "url": "https://github.com/wix-incubator/detox-copilot/issues" }, "devDependencies": { + "@playwright/test": "^1.50.0", "@types/jest": "^29.5.12", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", + "axios": "^1.7.9", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "jest": "^29.7.0", + "playwright": "^1.50.0", "prettier": "^3.2.5", + "puppeteer": "^20.8.0", "ts-jest": "^29.2.4", "tsc-alias": "^1.8.10", - "typescript": "^5.3.3", - "puppeteer": "^20.8.0", - "axios": "^1.7.9" + "typescript": "^5.3.3" }, "dependencies": { "blockhash-core": "^0.1.0", diff --git a/src/actions/CopilotStepPerformer.ts b/src/actions/CopilotStepPerformer.ts index cb31315..87dc229 100644 --- a/src/actions/CopilotStepPerformer.ts +++ b/src/actions/CopilotStepPerformer.ts @@ -256,6 +256,17 @@ export class CopilotStepPerformer { color: "greenBright", }); + if (attempt > 1) { + logger.info( + `🔄 Attempt ${attempt}/${maxAttempts} succeeded for step "${step}", generated code:\n`, + { + message: `\n\`\`\`javascript\n${code}\n\`\`\``, + isBold: false, + color: "gray", + }, + ); + } + return result; } catch (error) { lastError = error; diff --git a/src/drivers/playwright/getCleanDOM.ts b/src/drivers/playwright/getCleanDOM.ts new file mode 100644 index 0000000..3c6bfe3 --- /dev/null +++ b/src/drivers/playwright/getCleanDOM.ts @@ -0,0 +1,73 @@ +import * as playwright from "playwright"; + +/** + * Get clean DOM from the page content + * - Removes hidden elements + * - Removes ads, analytics, tracking elements + * - Removes unnecessary attributes + * - Removes empty elements + * @param page + */ +export default async function getCleanDOM(page: playwright.Page) { + await page.waitForSelector("body"); + + return await page.evaluate(() => { + const copiedDocument = document.cloneNode(true) as Document; + + copiedDocument + .querySelectorAll('[hidden], [aria-hidden="true"]') + .forEach((el) => el.remove()); + + const removeSelectors = [ + "script", + "style", + "link", + "meta", + "noscript", + "iframe", + '[class*="ads"]', + '[id*="ads"]', + '[class*="analytics"]', + '[class*="tracking"]', + "footer", + "header", + "nav", + "path", + "aside", + ]; + + const allowedAttributes = [ + "src", + "href", + "alt", + "title", + "aria-label", + "aria-labelledby", + "aria-describedby", + "aria-hidden", + "role", + "class", + "id", + "data-*", + ]; + + copiedDocument.querySelectorAll("*").forEach((el) => { + Array.from(el.attributes).forEach((attr) => { + if (!allowedAttributes.includes(attr.name)) { + el.removeAttribute(attr.name); + } + }); + + if (!el.innerHTML.trim()) { + el.remove(); + } + }); + + removeSelectors.forEach((selector) => { + copiedDocument.querySelectorAll(selector).forEach((el) => el.remove()); + }); + + const mainContent = copiedDocument.body.innerHTML; + return mainContent.replace(/\s+/g, " ").trim(); + }); +} diff --git a/src/drivers/playwright/index.ts b/src/drivers/playwright/index.ts new file mode 100644 index 0000000..c5c2fa3 --- /dev/null +++ b/src/drivers/playwright/index.ts @@ -0,0 +1,344 @@ +import { TestingFrameworkAPICatalog, TestingFrameworkDriver } from "@/types"; +import * as playwright from "playwright"; +import { expect as playwrightExpect } from "@playwright/test"; +import path from "path"; +import fs from "fs"; +import getCleanDOM from "./getCleanDOM"; + +export class PlaywrightFrameworkDriver implements TestingFrameworkDriver { + private currentPage?: playwright.Page; + + constructor() { + this.getCurrentPage = this.getCurrentPage.bind(this); + this.setCurrentPage = this.setCurrentPage.bind(this); + } + + /** + * Gets the current page identifier + */ + getCurrentPage(): playwright.Page | undefined { + return this.currentPage; + } + + /** + * Sets the current page identifier, must be set if the driver needs to interact with a specific page + */ + setCurrentPage(page: playwright.Page): void { + this.currentPage = page; + } + + /** + * @inheritdoc + */ + async captureSnapshotImage(): Promise { + if (!this.currentPage) { + return undefined; + } + + const fileName = `temp/snapshot_playwright_${Date.now()}.png`; + + // create temp directory if it doesn't exist + if (!fs.existsSync("temp")) { + fs.mkdirSync("temp"); + } + + await this.currentPage.screenshot({ + path: fileName, + fullPage: false, + }); + + return path.resolve(fileName); + } + + /** + * @inheritdoc + */ + async captureViewHierarchyString(): Promise { + if (!this.currentPage) { + return ( + "CANNOT SEE ANY ACTIVE PAGE, " + + "START A NEW ONE BASED ON THE ACTION NEED OR RAISE AN ERROR" + ); + } + + try { + return await getCleanDOM(this.currentPage); + } catch { + return "NO INNER VIEW HIERARCHY FOUND, PAGE IS EMPTY OR NOT LOADED"; + } + } + + /** + * @inheritdoc + */ + get apiCatalog(): TestingFrameworkAPICatalog { + return { + name: "Playwright", + description: + "Playwright is a Node library which provides a high-level API to control browsers over the DevTools Protocol.\nYou can assume that playwright and playwrightExpect are already imported.", + context: { + getCurrentPage: this.getCurrentPage, + setCurrentPage: this.setCurrentPage, + playwright, + expect: playwrightExpect, + }, + categories: [ + { + title: "Page Management", + items: [ + { + signature: "getCurrentPage(): playwright.Page | undefined", + description: "Gets the current active page instance.", + example: "const page = getCurrentPage();", + guidelines: [ + "Always check if page exists before operations.", + "Returns undefined if no page is set.", + "Use before any page interactions.", + ], + }, + { + signature: "setCurrentPage(page: playwright.Page): void", + description: "Sets the current active page for interactions.", + example: + "const page = await context.newPage(); setCurrentPage(page);", + guidelines: [ + "Must be called after creating a new page.", + "Required before any page interactions.", + "Only one page can be active at a time.", + ], + }, + ], + }, + { + title: "Browser and Context Setup", + items: [ + { + signature: + "const browser = await playwright.chromium.launch([options])", + description: "Launches a new browser instance.", + example: `const browser = await playwright.chromium.launch({ + headless: false, + timeout: 30000 // Default timeout for all operations +}); +const context = await browser.newContext(); +const page = await context.newPage(); +setCurrentPage(page);`, + guidelines: [ + "Set longer timeouts (30s or more) to handle slow operations.", + "Can use chromium, firefox, or webkit browsers.", + "Remember to call setCurrentPage after creating a page.", + ], + }, + { + signature: "const context = await browser.newContext([options])", + description: + "Creates a new browser context (like an incognito window).", + example: `const context = await browser.newContext({ + viewport: { width: 1280, height: 720 }, + navigationTimeout: 30000, // Navigation specific timeout + actionTimeout: 15000 // Action specific timeout +}); +const page = await context.newPage(); +setCurrentPage(page);`, + guidelines: [ + "Each context is isolated with separate cookies/localStorage.", + "Set specific timeouts for different operation types.", + "Configure viewport and other browser settings here.", + ], + }, + ], + }, + { + title: "Navigation", + items: [ + { + signature: "await page.goto(url[, options])", + description: "Navigates to a URL.", + example: `const page = getCurrentPage(); +if (page) { + await page.goto('https://example.com'); + // Verify navigation success using assertions + await expect(page.getByRole('heading')).toBeVisible(); +}`, + guidelines: [ + "Always verify navigation success with assertions.", + "Avoid using waitUntil options - use assertions instead.", + "Set proper timeouts at browser/context level.", + ], + }, + { + signature: "await page.reload()", + description: "Reloads the current page.", + example: `const page = getCurrentPage(); +if (page) { + await page.reload(); + // Verify reload success using assertions + await expect(page.getByRole('main')).toBeVisible(); +}`, + guidelines: [ + "Use assertions to verify page state after reload.", + "Avoid explicit waits - let assertions handle timing.", + "Good for refreshing stale content.", + ], + }, + ], + }, + { + title: "Locators", + items: [ + { + signature: "page.getByRole(role[, options])", + description: "Locates elements by ARIA role or HTML tag.", + example: `const page = getCurrentPage(); +if (page) { + await page.getByRole('button', { name: 'Submit' }).click(); +}`, + guidelines: [ + "Always check if page exists first.", + "Preferred way to locate interactive elements.", + "Improves test accessibility coverage.", + ], + }, + { + signature: "page.getByText(text[, options])", + description: "Locates elements by their text content.", + example: `const page = getCurrentPage(); +if (page) { + await page.getByText('Welcome').isVisible(); +}`, + guidelines: [ + "Always check if page exists first.", + "Good for finding visible text on page.", + "Can use exact or fuzzy matching.", + ], + }, + { + signature: "page.getByLabel(text)", + description: "Locates form control by associated label.", + example: `const page = getCurrentPage(); +if (page) { + await page.getByLabel('Username').fill('john'); +}`, + guidelines: [ + "Always check if page exists first.", + "Best practice for form inputs.", + "More reliable than selectors.", + ], + }, + ], + }, + { + title: "Actions", + items: [ + { + signature: "await locator.click([options])", + description: "Clicks on the element.", + example: `const page = getCurrentPage(); +if (page) { + await page.getByRole('button').click(); +}`, + guidelines: [ + "Always check if page exists first.", + "Automatically waits for element.", + "Handles scrolling automatically.", + ], + }, + { + signature: "await locator.fill(value)", + description: "Fills form field with value.", + example: `const page = getCurrentPage(); +if (page) { + await page.getByLabel('Password').fill('secret'); +}`, + guidelines: [ + "Always check if page exists first.", + "Preferred over type() for forms.", + "Clears existing value first.", + ], + }, + ], + }, + { + title: "Assertions", + items: [ + { + signature: "await expect(locator).toBeVisible()", + description: + "Asserts element is visible using Playwright assertions.", + example: `const page = getCurrentPage(); +if (page) { + await expect(page.getByText('Success')).toBeVisible(); +}`, + guidelines: [ + "Uses Playwright's built-in assertions with auto-retry.", + "More reliable than isVisible() for assertions.", + "Has built-in timeout and retry logic.", + ], + }, + { + signature: "await expect(locator).toHaveText(text)", + description: + "Asserts element's text content using Playwright assertions.", + example: `const page = getCurrentPage(); +if (page) { + await expect(page.getByRole('heading')).toHaveText('Welcome'); +}`, + guidelines: [ + "Uses Playwright's built-in assertions with auto-retry.", + "Can check exact or partial text.", + "More reliable than textContent() for assertions.", + ], + }, + { + signature: "await expect(locator).toHaveCount(number)", + description: "Asserts number of matching elements.", + example: `const page = getCurrentPage(); +if (page) { + await expect(page.getByRole('listitem')).toHaveCount(3); +}`, + guidelines: [ + "Uses Playwright's built-in assertions with auto-retry.", + "Good for checking element counts.", + "More reliable than count() for assertions.", + ], + }, + ], + }, + { + title: "State Checks", + items: [ + { + signature: "await locator.isVisible()", + description: "Checks if element is visible.", + example: `const page = getCurrentPage(); +if (page) { + if (await page.getByText('Error').isVisible()) { + // handle error state + } +}`, + guidelines: [ + "Returns immediately - good for conditionals.", + "Use expect().toBeVisible() for assertions.", + "Good for flow control.", + ], + }, + { + signature: "await page.title()", + description: "Gets the page title.", + example: `const page = getCurrentPage(); +if (page) { + const title = await page.title(); + await expect(title).toBe('Expected Title'); +}`, + guidelines: [ + "Returns current page title.", + "Use with expect for assertions.", + "Auto-waits for title to be available.", + ], + }, + ], + }, + ], + }; + } +}