From 8ffeda7bd2d8f41b90f9341b06a75338e8f4aeb3 Mon Sep 17 00:00:00 2001 From: sushmakannedari Date: Wed, 8 Apr 2026 20:17:24 -0400 Subject: [PATCH] feat: add Kobiton as a device provider --- README.md | 31 +++- docs/config.md | 15 ++ package-lock.json | 4 +- src/providers/index.ts | 3 + src/providers/kobiton/index.ts | 324 +++++++++++++++++++++++++++++++++ src/providers/kobiton/utils.ts | 6 + src/types/index.ts | 26 +++ 7 files changed, 406 insertions(+), 3 deletions(-) create mode 100644 src/providers/kobiton/index.ts create mode 100644 src/providers/kobiton/utils.ts diff --git a/README.md b/README.md index 0b00728d..547cc638 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ export default defineConfig({ - `platform`: The platform you want to test on, such as 'android' or 'ios'. - `provider`: The device provider where you want to run your tests. - You can choose between `browserstack`, `lambdatest`, `emulator`, or `local-device`. + You can choose between `browserstack`, `lambdatest`, `kobiton`, `emulator`, or `local-device`. - `buildPath`: The path to your build file. For Android, it should be an APK file. For iOS, if you are running tests on real device, it should be an `.ipa` file. For running tests on an emulator, it should be a `.app` file. @@ -135,6 +135,35 @@ the provider in your config. }, ``` +#### Run tests on Kobiton + +Appwright supports Kobiton out of the box. To run tests on Kobiton, configure +the provider in your config. + + +```ts +{ + name: "android", + use: { + platform: Platform.ANDROID, + device: { + provider: "kobiton", + // Specify device to run the tests on + // Find devices in the Kobiton portal under Devices + name: "Galaxy S24", + osVersion: "14", + }, + buildPath: "app-release.apk", + appBundleId: "com.example.app", + }, +}, +``` +Set the following environment variables before running: + +- `KOBITON_USERNAME` — your Kobiton username +- `KOBITON_API_KEY` — your Kobiton API key (from Settings → API Keys) + + ## Run the sample project To run the sample project: diff --git a/docs/config.md b/docs/config.md index 2a096203..8aca7bbf 100644 --- a/docs/config.md +++ b/docs/config.md @@ -12,6 +12,7 @@ providers are supported: - `emulator` - `browserstack` - `lambdatest` +- `kobiton` ### BrowserStack @@ -37,6 +38,20 @@ These environment variables are required for the LambdaTest LambdaTest also requires `name` and `osVersion` of the device to be set in the projects in appwright config file. +### Kobiton + +Kobiton [Real Device Cloud](https://kobiton.com/) can be used to provide +remote devices to Appwright. + +These environment variables are required for Kobiton: + +- KOBITON_USERNAME +- KOBITON_API_KEY + +You can find your API key in the Kobiton portal under **Settings > API Keys**. + +Kobiton requires `name` of the device to be set in the projects in appwright config file. `osVersion` is optional but recommended. + ### Android Emulator To run tests on the Android emulator, ensure the following installations are available. If not, follow these steps: diff --git a/package-lock.json b/package-lock.json index 1c989bd0..df500009 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "appwright", - "version": "0.1.37", + "version": "0.1.46", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "appwright", - "version": "0.1.37", + "version": "0.1.46", "license": "Apache-2.0", "dependencies": { "@empiricalrun/llm": "^0.9.25", diff --git a/src/providers/index.ts b/src/providers/index.ts index 0560a201..b7c0fb7f 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -4,6 +4,7 @@ import { LocalDeviceProvider } from "./local"; import { EmulatorProvider } from "./emulator"; import { FullProject } from "@playwright/test"; import { LambdaTestDeviceProvider } from "./lambdatest"; +import { KobitonDeviceProvider } from "./kobiton"; export function getProviderClass(provider: string): any { switch (provider) { @@ -11,6 +12,8 @@ export function getProviderClass(provider: string): any { return BrowserStackDeviceProvider; case "lambdatest": return LambdaTestDeviceProvider; + case "kobiton": + return KobitonDeviceProvider; case "emulator": return EmulatorProvider; case "local-device": diff --git a/src/providers/kobiton/index.ts b/src/providers/kobiton/index.ts new file mode 100644 index 00000000..12451454 --- /dev/null +++ b/src/providers/kobiton/index.ts @@ -0,0 +1,324 @@ +import retry from "async-retry"; +import fs from "fs"; +import path from "path"; +import { AppwrightConfig, DeviceProvider, KobitonConfig } from "../../types"; +import { FullProject } from "@playwright/test"; +import { Device } from "../../device"; +import { logger } from "../../logger"; +import { getAuthHeader } from "./utils"; + +const API_BASE_URL = "https://api.kobiton.com/v1"; + +const envVarKeyForBuild = (projectName: string) => + `KOBITON_APP_ID_${projectName.toUpperCase()}`; + +async function getSessionDetails(sessionId: string) { + const response = await fetch(`${API_BASE_URL}/sessions/${sessionId}`, { + method: "GET", + headers: { + Authorization: getAuthHeader(), + Accept: "application/json", + }, + }); + if (!response.ok) { + throw new Error(`Error fetching session details: ${response.statusText}`); + } + const data = await response.json(); + return data; +} + +export class KobitonDeviceProvider implements DeviceProvider { + sessionId?: string; + private projectName = path.basename(process.cwd()); + + constructor( + private project: FullProject, + private appBundleId: string | undefined, + ) { + if (!appBundleId) { + throw new Error( + "App Bundle ID is required for running tests on Kobiton. Set the `appBundleId` for your projects that run on this provider.", + ); + } + } + + async globalSetup() { + if (!this.project.use.buildPath) { + throw new Error( + `Build path not found. Please set the build path in the config file.`, + ); + } + if (!(process.env.KOBITON_USERNAME && process.env.KOBITON_API_KEY)) { + throw new Error( + "KOBITON_USERNAME and KOBITON_API_KEY are required environment variables for this device provider. Please set them before running tests.", + ); + } + + const buildPath = this.project.use.buildPath!; + const isKobitonAppId = buildPath.startsWith("kobiton-store:"); + + let appId: string | undefined = undefined; + + if (isKobitonAppId) { + // User already provided a kobiton-store app ID directly + appId = buildPath; + } else { + // Upload the app to Kobiton via the 3-step S3 upload flow + if (!fs.existsSync(buildPath)) { + throw new Error(`Build file not found: ${buildPath}`); + } + + const filename = path.basename(buildPath); + const fetch = (await import("node-fetch")).default; + + // Step 1: Get a presigned S3 upload URL + logger.log(`Requesting upload URL for: ${filename}`); + const uploadUrlResponse = await fetch(`${API_BASE_URL}/apps/uploadUrl`, { + method: "POST", + headers: { + Authorization: getAuthHeader(), + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ filename }), + }); + + if (!uploadUrlResponse.ok) { + throw new Error( + `Failed to get upload URL: ${uploadUrlResponse.statusText}`, + ); + } + + const uploadUrlData = (await uploadUrlResponse.json()) as { + url: string; + appPath: string; + }; + const { url: presignedUrl, appPath } = uploadUrlData; + + // Step 2: Upload the file directly to S3 + logger.log(`Uploading app to S3: ${buildPath}`); + const fileBuffer = fs.readFileSync(buildPath); + const s3Response = await fetch(presignedUrl, { + method: "PUT", + headers: { + "Content-Type": "application/octet-stream", + "x-amz-tagging": "unsaved=true", + }, + body: fileBuffer, + }); + + if (!s3Response.ok) { + throw new Error(`S3 upload failed: ${s3Response.statusText}`); + } + + // Step 3: Register the uploaded app in Kobiton + logger.log(`Registering app in Kobiton repository...`); + const registerResponse = await fetch(`${API_BASE_URL}/apps`, { + method: "POST", + headers: { + Authorization: getAuthHeader(), + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ filename, appPath }), + }); + + if (!registerResponse.ok) { + const errorBody = await registerResponse.text(); + logger.error("Registering the build failed:", errorBody); + throw new Error( + `Failed to register app in Kobiton: ${registerResponse.statusText}`, + ); + } + + const registerData = (await registerResponse.json()) as { + appId: number; + versionId: number; + }; + appId = `kobiton-store:v${registerData.versionId}`; + + if (!appId) { + throw new Error( + "App upload succeeded but no versionId was returned from Kobiton.", + ); + } + + logger.log(`App uploaded successfully. Kobiton versionId: ${appId}`); + logger.log(`Waiting for Kobiton to process the app...`); + await new Promise((resolve) => setTimeout(resolve, 10000)); + } + + process.env[envVarKeyForBuild(this.project.name)] = appId; + } + + async getDevice(): Promise { + this.validateConfig(); + const config = this.createConfig(); + return await this.createDriver(config); + } + + private validateConfig() { + const device = this.project.use.device as KobitonConfig; + if (!device.name) { + throw new Error( + "Device name is required for running tests on Kobiton. Please set the device name in the `appwright.config.ts` file.", + ); + } + } + + private async createDriver(config: any): Promise { + const WebDriver = (await import("webdriver")).default; + const webDriverClient = await WebDriver.newSession(config); + this.sessionId = webDriverClient.sessionId; + const testOptions = { + expectTimeout: this.project.use.expectTimeout!, + }; + return new Device( + webDriverClient, + this.appBundleId, + testOptions, + this.project.use.device?.provider!, + ); + } + + static async downloadVideo( + sessionId: string, + outputDir: string, + fileName: string, + ): Promise<{ path: string; contentType: string } | null> { + const sessionData = await getSessionDetails(sessionId); + const videoURL = sessionData?.video; + const pathToTestVideo = path.join(outputDir, `${fileName}.mp4`); + const tempPathForWriting = `${pathToTestVideo}.part`; + const dir = path.dirname(pathToTestVideo); + fs.mkdirSync(dir, { recursive: true }); + const fileStream = fs.createWriteStream(tempPathForWriting); + try { + if (videoURL) { + await retry( + async () => { + const response = await fetch(videoURL, { + method: "GET", + }); + if (response.status !== 200) { + throw new Error( + `Video not found: ${response.status} (URL: ${videoURL})`, + ); + } + const reader = response.body?.getReader(); + if (!reader) { + throw new Error("Failed to get reader from response body."); + } + const streamToFile = async () => { + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read(); + if (done) break; + fileStream.write(value); + } + }; + await streamToFile(); + fileStream.close(); + }, + { + retries: 10, + minTimeout: 3_000, + onRetry: (err, i) => { + if (i > 5) { + logger.warn(`Retry attempt ${i} failed: ${err.message}`); + } + }, + }, + ); + return new Promise((resolve, reject) => { + fileStream.on("finish", () => { + try { + fs.renameSync(tempPathForWriting, pathToTestVideo); + logger.log( + `Download finished and file closed: ${pathToTestVideo}`, + ); + resolve({ path: pathToTestVideo, contentType: "video/mp4" }); + } catch (err) { + logger.error(`Failed to rename file: `, err); + reject(err); + } + }); + + fileStream.on("error", (err) => { + logger.error(`Failed to write file: ${err.message}`); + reject(err); + }); + }); + } else { + return null; + } + } catch (e) { + logger.log(`Error Downloading video: `, e); + return null; + } + } + + async syncTestDetails(details: { + status?: string; + reason?: string; + name?: string; + }) { + try { + const body: Record = {}; + if (details.name) body["name"] = details.name; + if (details.status) body["status"] = details.status; + if (details.reason) body["description"] = details.reason; + const response = await fetch( + `${API_BASE_URL}/sessions/${this.sessionId}`, + { + method: "PATCH", + headers: { + Authorization: getAuthHeader(), + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(body), + }, + ); + if (response.ok) return await response.json(); + } catch (e) { + logger.warn(`Could not sync test details to Kobiton`); + } + return {}; + } + + private createConfig() { + const platformName = this.project.use.platform; + const device = this.project.use.device as KobitonConfig; + const envVarKey = envVarKeyForBuild(this.project.name); + if (!process.env[envVarKey]) { + throw new Error( + `process.env.${envVarKey} is not set. Did the file upload work?`, + ); + } + return { + protocol: "https", + logLevel: "warn", + hostname: "api.kobiton.com", + path: "/wd/hub", + port: 443, + user: process.env.KOBITON_USERNAME, + key: process.env.KOBITON_API_KEY, + capabilities: { + "kobiton:sessionName": `${this.projectName} ${platformName} test`, + "kobiton:sessionDescription": `Appwright test run on Kobiton - ${this.projectName}`, + "kobiton:deviceOrientation": device?.orientation ?? "portrait", + "kobiton:captureScreenshots": true, + "kobiton:deviceGroup": "KOBITON", + "appium:deviceName": device.name, + "appium:platformVersion": device.osVersion, + platformName: platformName, + "appium:app": process.env[envVarKey], + "appium:autoGrantPermissions": true, + "appium:autoAcceptAlerts": true, + "appium:fullReset": true, + "appium:settings[snapshotMaxDepth]": 62, + }, + }; + } +} diff --git a/src/providers/kobiton/utils.ts b/src/providers/kobiton/utils.ts new file mode 100644 index 00000000..523659ab --- /dev/null +++ b/src/providers/kobiton/utils.ts @@ -0,0 +1,6 @@ +export function getAuthHeader() { + const userName = process.env.KOBITON_USERNAME; + const apiKey = process.env.KOBITON_API_KEY; + const key = Buffer.from(`${userName}:${apiKey}`).toString("base64"); + return `Basic ${key}`; +} diff --git a/src/types/index.ts b/src/types/index.ts index 39574b83..26110838 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -56,6 +56,7 @@ export type AppwrightConfig = { export type DeviceConfig = | BrowserStackConfig | LambdaTestConfig + | KobitonConfig | LocalDeviceConfig | EmulatorConfig; @@ -121,6 +122,31 @@ export type LambdaTestConfig = { */ enableCameraImageInjection?: boolean; }; +/** + * Configuration for devices running on Kobiton. + */ +export type KobitonConfig = { + provider: "kobiton"; + + /** + * The name of the device to be used on Kobiton. + * Find device names in the Kobiton portal under Devices. + * Example: "iPhone 15 Pro Max", "Galaxy S24". + */ + name: string; + + /** + * The operating system version of the device. + * Example: "17", "14". + */ + osVersion?: string; + + /** + * The orientation of the device on Kobiton. + * Default orientation is "portrait". + */ + orientation?: DeviceOrientation; +}; /** * Configuration for locally connected physical devices.