diff --git a/package-lock.json b/package-lock.json index 7618221..0e4be2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10724,4 +10724,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/drivers/appium/.eslintrc.js b/packages/drivers/appium/.eslintrc.js new file mode 100644 index 0000000..3db17ec --- /dev/null +++ b/packages/drivers/appium/.eslintrc.js @@ -0,0 +1,25 @@ +module.exports = { + parser: '@typescript-eslint/parser', + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ], + plugins: ['@typescript-eslint'], + env: { + node: true, + jest: true, + es6: true, + }, + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, + rules: { + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + 'no-console': 'off', + }, + ignorePatterns: ['dist/', 'node_modules/', '*.js'], +}; diff --git a/packages/drivers/appium/examples/example.test.ts b/packages/drivers/appium/examples/example.test.ts new file mode 100644 index 0000000..03d3ee6 --- /dev/null +++ b/packages/drivers/appium/examples/example.test.ts @@ -0,0 +1,28 @@ +import pilot from "@wix-pilot/core"; +import { PromptHandler } from "../utils/promptHandler"; +import { WebdriverIOAppiumFrameworkDriver } from "../index"; + +describe("Example Test Suite", () => { + let frameworkDriver: WebdriverIOAppiumFrameworkDriver; + + before(async () => { + const promptHandler: PromptHandler = new PromptHandler(); + frameworkDriver = new WebdriverIOAppiumFrameworkDriver(); + pilot.init({ + frameworkDriver, + promptHandler, + }); + }); + + beforeEach(async () => { + pilot.start(); + }); + + afterEach(async () => { + pilot.end(); + }); + + it("perform test with pilot", async () => { + await pilot.autopilot("earn 2 points in the game"); + }); +}); diff --git a/packages/drivers/appium/examples/wdio.conf.ts b/packages/drivers/appium/examples/wdio.conf.ts new file mode 100644 index 0000000..87b73e1 --- /dev/null +++ b/packages/drivers/appium/examples/wdio.conf.ts @@ -0,0 +1,38 @@ +import path from "path"; + +export const config = { + runner: "local", + specs: ["./**/*.test.ts"], + maxInstances: 1, + + capabilities: [ + { + platformName: "iOS", + "appium:deviceName": "iPhone 15 Pro", + "appium:automationName": "XCUITest", + "appium:app": path.resolve( + __dirname, + "../../detox/ExampleApp/ios/build/Build/Products/Release-iphonesimulator/ExampleApp.app", + ), + }, + ], + + logLevel: "info", + bail: 0, + baseUrl: "http://localhost", + waitforTimeout: 10000, + connectionRetryTimeout: 900000, + connectionRetryCount: 3, + + services: ["appium"], + appium: { + command: "appium", + }, + + framework: "mocha", + reporters: ["spec"], + mochaOpts: { + ui: "bdd", + timeout: 600000, + }, +}; diff --git a/packages/drivers/appium/index.ts b/packages/drivers/appium/index.ts new file mode 100644 index 0000000..70d47fb --- /dev/null +++ b/packages/drivers/appium/index.ts @@ -0,0 +1,376 @@ +import { + TestingFrameworkAPICatalog, + TestingFrameworkDriver, +} from "@wix-pilot/core"; +import * as fs from "fs"; +import * as path from "path"; +export class WebdriverIOAppiumFrameworkDriver + implements TestingFrameworkDriver +{ + constructor() {} + /** + * Attempts to capture the current view hierarchy (source) of the mobile app as XML. + * If there's no active session or the app isn't running, returns an error message. + */ + async captureViewHierarchyString(): Promise { + try { + // In WebdriverIO + Appium, you can retrieve the current page source (UI hierarchy) via: + // https://webdriver.io/docs/api/browser/getPageSource (driver is an alias for browser) + const pageSource = await driver.getPageSource(); + return pageSource; + } catch (_error) { + return "NO ACTIVE APP FOUND, LAUNCH THE APP TO SEE THE VIEW HIERARCHY"; + } + } + + /** + * Captures a screenshot of the current device screen and saves it to a temp directory. + * Returns the path to the saved screenshot if successful, or undefined otherwise. + */ + async captureSnapshotImage(): Promise { + const tempDir = "temp"; + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir); + } + + const fileName = `snapshot_wdio_${Date.now()}.png`; + const filePath = path.join(tempDir, fileName); + + try { + // In WebdriverIO + Appium, driver.takeScreenshot() returns a base64-encoded PNG + // https://webdriver.io/docs/api/browser/takeScreenshot + const base64Image = await driver.takeScreenshot(); + const buffer = Buffer.from(base64Image, "base64"); + fs.writeFileSync(filePath, buffer); + return filePath; + } catch (_error) { + console.log(_error); + return undefined; + } + } + + /** + * Returns the API catalog describing the testing capabilities + * (matchers, actions, assertions, device/system APIs, etc.) + */ + get apiCatalog(): TestingFrameworkAPICatalog { + return { + name: "WebdriverIO + Appium", + description: + "WebdriverIO is a browser and mobile automation library; Appium is a cross-platform automation framework for native, hybrid, and mobile web apps.", + context: { + $$: $$, + $: $, + driver: driver, + expect: expect, + }, + categories: [ + { + title: "Native Matchers", + items: [ + { + signature: `$('~accessibilityId')`, + description: + "Locate an element by its accessibility ID (commonly used in Appium).", + example: `const loginButton = await $('~loginButton'); // Accessibility ID`, + }, + { + signature: `$('android=uiSelector')`, + description: + "Locate an element using an Android UIAutomator selector.", + example: `const el = await $('android=new UiSelector().text("Login")');`, + }, + { + signature: `$('ios=predicateString')`, + description: "Locate an element using an iOS NSPredicate string.", + example: `const el = await $('ios=predicate string:type == "XCUIElementTypeButton" AND name == "Login"');`, + }, + { + signature: `$$('#elementSelector')`, + description: "Locate all elements with a given selector", + example: `const firstSite = await $$('#Site')[index];`, + }, + { + signature: `$('//*[@text="Login"]')`, + description: "Locate an element using an XPath expression.", + example: `const el = await $('//*[@text="Login"]');`, + }, + { + signature: `$('#elementId'), $('elementTag'), $('.className')`, + description: + "Web-like selectors (useful if your app is a hybrid or has a web context).", + example: `const el = await $('.someNativeClass');`, + }, + ], + }, + { + title: "Native Actions", + items: [ + { + signature: `.click()`, + description: "Clicks (taps) an element.", + example: ` + await (await $('~loginButton')).waitForEnabled(); + await (await $('~loginButton')).click();`, + }, + { + signature: `.setValue(value: string)`, + description: + "Sets the value of an input/field (replaces existing text).", + example: `await (await $('~usernameInput')).setValue('myusername');`, + }, + { + signature: `.addValue(value: string)`, + description: "Adds text to the existing text in the input/field.", + example: `await (await $('~commentsField')).addValue(' - Additional note');`, + }, + { + signature: `.clearValue()`, + description: "Clears the current value of an input/field.", + example: `await (await $('~usernameInput')).clearValue();`, + }, + { + signature: `.touchAction(actions)`, + description: + "Performs a series of touch actions (tap, press, moveTo, release, etc.).", + example: ` +await (await $('~dragHandle')).touchAction([ + { action: 'press', x: 10, y: 10 }, + { action: 'moveTo', x: 10, y: 100 }, + 'release' +]); + `, + }, + { + signature: `.scrollIntoView() (web/hybrid context only)`, + description: + "Scrolls the element into view (if in a web context).", + example: `await (await $('#someElement')).scrollIntoView();`, + }, + { + signature: `.dragAndDrop(target, duration?)`, + description: + "Drags the element to the target location (native or web context).", + example: ` +await (await $('~draggable')).dragAndDrop( + await $('~dropzone'), + 1000 +); + `, + }, + ], + }, + { + title: "Assertions", + items: [ + { + signature: `toBeDisplayed()`, + description: "Asserts that the element is displayed (visible).", + example: `await expect(await $('~loginButton')).toBeDisplayed();`, + }, + { + signature: `toExist()`, + description: + "Asserts that the element exists in the DOM/hierarchy.", + example: `await expect(await $('~usernameInput')).toExist();`, + }, + { + signature: `toHaveText(text: string)`, + description: + "Asserts that the element's text matches the given string.", + example: `await expect(await $('~welcomeMessage')).toHaveText('Welcome, user!');`, + }, + { + signature: `toHaveValue(value: string)`, + description: + "Asserts that the element's value matches the given string (for inputs, etc.).", + example: `await expect(await $('~usernameInput')).toHaveValue('myusername');`, + }, + { + signature: `toBeEnabled() / toBeDisabled()`, + description: + "Asserts that an element is enabled/disabled (if applicable).", + example: `await expect(await $('~submitButton')).toBeEnabled();`, + }, + { + signature: `not`, + description: "Negates the expectation.", + example: `await expect(await $('~spinner')).not.toBeDisplayed();`, + }, + ], + }, + { + title: "Device APIs", + items: [ + { + signature: `driver.launchApp()`, + description: + "Launches the mobile app (if supported by your WebdriverIO config).", + example: `await driver.launchApp();`, + }, + { + signature: `driver.terminateApp(bundleId: string)`, + description: + "Terminates the specified app by its bundle/package identifier.", + example: `await driver.terminateApp('com.example.myapp');`, + guidelines: [ + "For iOS, use the iOS bundle identifier (e.g. com.mycompany.myapp).", + "For Android, use the app package name (e.g. com.example.myapp).", + ], + }, + { + signature: `driver.activateApp(bundleId: string)`, + description: "Brings the specified app to the foreground.", + example: `await driver.activateApp('com.example.myapp');`, + }, + { + signature: `driver.installApp(path: string)`, + description: "Installs an app from a local path on the device.", + example: `await driver.installApp('/path/to/app.apk');`, + }, + { + signature: `driver.removeApp(bundleId: string)`, + description: + "Uninstalls an app by its bundle identifier or package name.", + example: `await driver.removeApp('com.example.myapp');`, + }, + { + signature: `driver.background(seconds: number)`, + description: + "Sends the app to the background for a given number of seconds.", + example: `await driver.background(5); // 5 seconds`, + }, + { + signature: `driver.lock(seconds?: number)`, + description: + "Locks the device screen for the specified number of seconds (Android only).", + example: `await driver.lock(10); // lock for 10 seconds`, + }, + { + signature: `driver.unlock()`, + description: "Unlocks the device (Android).", + example: `await driver.unlock();`, + }, + { + signature: `driver.setGeoLocation({ latitude, longitude, altitude })`, + description: "Sets the device's geolocation.", + example: ` +await driver.setGeoLocation({ + latitude: 37.7749, + longitude: -122.4194, + altitude: 10 +}); + `, + }, + { + signature: `driver.getContext() / driver.switchContext(name: string)`, + description: + "Gets or switches the current context (NATIVE_APP or WEBVIEW_xxx).", + example: ` +const contexts = await driver.getContexts(); +await driver.switchContext(contexts[1]); // Switch to webview +await driver.switchContext('NATIVE_APP'); // Switch back + `, + }, + { + signature: `driver.rotate(params: { x: number; y: number; duration: number; radius: number; rotation: number; touchCount: number })`, + description: + "Simulates a rotation gesture on the device (iOS only).", + example: ` +await driver.rotate({ + x: 100, + y: 100, + duration: 2, + radius: 50, + rotation: 180, + touchCount: 2 +}); + `, + }, + ], + }, + { + title: "System APIs (iOS / Android)", + items: [ + { + signature: `driver.sendSms(phoneNumber: string, message: string) (Android)`, + description: "Sends an SMS message (Android only).", + example: `await driver.sendSms('555-1234', 'Test message');`, + }, + { + signature: `driver.performTouchAction(action: TouchAction) (Android / iOS)`, + description: + "Performs a chain of touch actions (similar to `.touchAction()`, but more low-level).", + example: ` +await driver.performTouchAction({ + actions: [ + { action: 'press', x: 200, y: 200 }, + { action: 'moveTo', x: 200, y: 500 }, + { action: 'release' } + ] +}); + `, + }, + { + signature: `driver.openNotifications() (Android)`, + description: "Opens the notification shade on Android.", + example: `await driver.openNotifications();`, + }, + { + signature: `driver.toggleAirplaneMode() (Android)`, + description: "Toggles the Airplane mode on an Android device.", + example: `await driver.toggleAirplaneMode();`, + }, + ], + }, + { + title: "Web APIs (Hybrid / Mobile Web)", + items: [ + { + signature: `driver.switchContext('WEBVIEW')`, + description: + "Switches the context from native to web view (if a webview is present).", + example: ` +const contexts = await driver.getContexts(); +await driver.switchContext(contexts.find(c => c.includes('WEBVIEW'))); + `, + guidelines: [ + "Use this when your app has a webview or is a hybrid app.", + ], + }, + { + signature: `$('css or xpath').click()`, + description: + "In a webview context, click (tap) a web element using CSS or XPath.", + example: `await (await $('button#login')).click();`, + }, + { + signature: `$('selector').setValue('text')`, + description: + "In a webview context, sets text in a web input field.", + example: `await (await $('input#username')).setValue('myusername');`, + }, + { + signature: `.getText()`, + description: + "Retrieves the visible text of a web element (hybrid/web context).", + example: ` +const text = await (await $('h1.main-title')).getText(); +expect(text).toBe('Welcome to My App'); + `, + }, + { + signature: `driver.executeScript(script: string, args?: any[])`, + description: "Executes JavaScript in the context of the webview.", + example: ` +await driver.executeScript("document.getElementById('hidden-button').click()"); +const title = await driver.executeScript('return document.title'); +expect(title).toBe('My Page Title'); + `, + }, + ], + }, + ], + }; + } +} diff --git a/packages/drivers/appium/package.json b/packages/drivers/appium/package.json new file mode 100644 index 0000000..83c1595 --- /dev/null +++ b/packages/drivers/appium/package.json @@ -0,0 +1,55 @@ +{ + "name": "@wix-pilot/webdriverio-appium", + "version": "1.0.0", + "description": "WebdriverIO and Appium driver for Wix Pilot usage", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "publishConfig": { + "registry": "https://registry.npmjs.org" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/wix-incubator/pilot.git" + }, + "scripts": { + "build": "tsc && tsc-alias", + "test": "echo No tests available for this package", + "test:example": "wdio run examples/wdio.conf.ts", + "build:app": "cd ../detox/ExampleApp && npm run build:ios", + "bump-version:patch": "npm version patch && git commit -am 'chore: bump patch version' && git push", + "release:patch": "npm run test && npm run bump-version:patch && npm run build && npm publish --access public", + "bump-version:minor": "npm version minor && git commit -am 'chore: bump minor version' && git push", + "release:minor": "npm run test && npm run bump-version:minor && npm run build && npm publish --access public", + "bump-version:major": "npm version major && git commit -am 'chore: bump major version' && git push", + "release:major": "npm run test && npm run bump-version:major && npm run build && npm publish --access public", + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", + "type-check": "tsc --noEmit" + }, + "bugs": { + "url": "https://github.com/wix-incubator/pilot/issues" + }, + "dependencies": { + "@wix-pilot/core": "^2.0.0" + }, + "peerDependencies": { + "@wdio/globals": ">=8.0.0" + }, + "devDependencies": { + "@wdio/globals": "^9.9.1", + "@wdio/appium-service": "^8.29.0", + "@wdio/cli": "^9.9.1", + "@wdio/local-runner": "^9.9.1", + "@wdio/mocha-framework": "^9.9.0", + "@wdio/spec-reporter": "^9.9.0", + "@wdio/types": "^9.9.0", + "appium": "^2.15.0", + "appium-xcuitest-driver": "^8.3.1", + "axios": "^1.7.9", + "chai": "^5.2.0", + "webdriverio": "^9.9.1" + } +} diff --git a/packages/drivers/appium/tsconfig.json b/packages/drivers/appium/tsconfig.json new file mode 100644 index 0000000..2252494 --- /dev/null +++ b/packages/drivers/appium/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "commonjs", + "declaration": true, + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "rootDir": "./", + "types": ["node", "mocha", "@wdio/globals/types"], + "baseUrl": "./", + "resolveJsonModule": true + }, + "include": ["./"], + "exclude": [ + "node_modules", + "dist", + ] +} diff --git a/packages/drivers/appium/utils/promptHandler.ts b/packages/drivers/appium/utils/promptHandler.ts new file mode 100644 index 0000000..c1d65bf --- /dev/null +++ b/packages/drivers/appium/utils/promptHandler.ts @@ -0,0 +1,98 @@ +import axios, { AxiosResponse } from "axios"; +import { promises as fs } from "fs"; + +interface UploadImageResponseData { + url: string; +} + +interface RunPromptResponseData { + generatedTexts: string[]; +} + +export class PromptHandler { + async uploadImage(imagePath: string): Promise { + const image = await fs.readFile(imagePath); + + try { + const response: AxiosResponse = await axios.post( + "https://bo.wix.com/mobile-infra-ai-services/v1/image-upload", + { + image, + }, + ); + + const imageUrl: string | undefined = response.data.url; + if (!imageUrl) { + throw new Error( + `Cannot find uploaded URL, got response: ${JSON.stringify(response.data)}`, + ); + } + + return imageUrl; + } catch (error) { + console.error("Error while uploading image:", error); + throw error; + } + } + + async runPrompt(prompt: string, imagePath?: string): Promise { + if (!imagePath) { + try { + const response: AxiosResponse = await axios.post( + "https://bo.wix.com/mobile-infra-ai-services/v1/prompt", + { + prompt, + model: "SONNET_3_5", + ownershipTag: "Detox OSS", + project: "Detox OSS", + images: [], + }, + ); + + const generatedText: string | undefined = + response.data.generatedTexts[0]; + if (!generatedText) { + throw new Error( + `Failed to generate text, got response: ${JSON.stringify(response.data)}`, + ); + } + + return generatedText; + } catch (error) { + console.error("Error running prompt:", error); + throw error; + } + } + + const imageUrl = await this.uploadImage(imagePath); + + try { + const response: AxiosResponse = await axios.post( + "https://bo.wix.com/mobile-infra-ai-services/v1/prompt", + { + prompt, + model: "SONNET_3_5", + ownershipTag: "Detox OSS", + project: "Detox OSS", + images: [imageUrl], + }, + ); + + const generatedText: string | undefined = response.data.generatedTexts[0]; + if (!generatedText) { + throw new Error( + `Failed to generate text, got response: ${JSON.stringify(response.data)}`, + ); + } + + return generatedText; + } catch (error) { + console.error("Error running prompt:", error); + throw error; + } + } + + isSnapshotImageSupported(): boolean { + return true; + } +} diff --git a/packages/drivers/detox/ExampleApp/ios/Podfile b/packages/drivers/detox/ExampleApp/ios/Podfile index 0430743..d577167 100644 --- a/packages/drivers/detox/ExampleApp/ios/Podfile +++ b/packages/drivers/detox/ExampleApp/ios/Podfile @@ -37,4 +37,4 @@ target 'ExampleApp' do # :ccache_enabled => true ) end -end +end \ No newline at end of file diff --git a/packages/drivers/detox/ExampleApp/ios/Podfile.lock b/packages/drivers/detox/ExampleApp/ios/Podfile.lock index 8ff8f1f..c09f42d 100644 --- a/packages/drivers/detox/ExampleApp/ios/Podfile.lock +++ b/packages/drivers/detox/ExampleApp/ios/Podfile.lock @@ -2003,4 +2003,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 72df293ed948c1516ebf19fedb5625988cc692b3 -COCOAPODS: 1.15.2 +COCOAPODS: 1.15.2 \ No newline at end of file diff --git a/website/docs/pages/supported-frameworks.md b/website/docs/pages/supported-frameworks.md index aeff14d..7ddc10e 100644 --- a/website/docs/pages/supported-frameworks.md +++ b/website/docs/pages/supported-frameworks.md @@ -20,6 +20,26 @@ it('should update profile', async () => { }); ``` +### WebdriverIO with Appium + +WebdriverIO integration with Appium. supports both iOS and Android testing + +```js +// 1. Install: npm install --save-dev @wix-pilot/webdriverio-appium +// 2. Import and use: +import { WebdriverIOAppiumFrameworkDriver } from '@wix-pilot/webdriverio-appium'; + +it('should update profile', async () => { + await pilot.perform( + 'Launch the app', + 'Navigate to Settings', + 'Tap on "Edit Profile"', + 'Update username to "john_doe"', + 'Verify changes are saved' + ); +}); +``` + ## Web Testing Support ### Playwright