diff --git a/README.md b/README.md index accfb80..c657c32 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ # appium-interceptor-plugin This is an Appium plugin designed to intercept API response and mocking easy. -This plugin uses mitmproxy +This plugin uses `mitmproxy`. ## Prerequisite 1. Appium version 3.0 2. Intercepting API requests from android requires CA certificate to be installed on the device. Follow the instructions in [How to install CA certificate on android](./docs/certificate-installation.md) section and install the CA certificate. -## Installation - Server +## Installation - server Install the plugin using Appium's plugin CLI, either as a named plugin or via NPM: @@ -22,20 +22,79 @@ The plugin will not be active unless turned on when invoking the Appium server: `appium server -ka 800 --use-plugins=appium-interceptor -pa /wd/hub` -## Custom certificate +## What does this plugin do? + +The **Appium Interceptor Plugin** provides network interception and mocking capabilities specifically for **Android** devices. It manages a local proxy server and automatically configures the device's WiFi settings to route all network traffic through it. + +By using this plugin, you can intercept, record, and mock HTTP requests directly within your Appium tests without manually configuring certificates or proxy settings on the device. + +## Configuration + +### Server Arguments + +Custom certificate If you need to use a custom certificate, it can be done by passing `certdirectory` as an argument of the plugin: -`appium server -ka 800 --use-plugins=appium-interceptor --plugin-appium-interceptor-certdirectory="" -pa /wd/hub` +`appium server -ka 800 --use-plugins=appium-interceptor --plugin-appium-interceptor-certdirectory="" -pa /wd/hub` Please keep the same directory structure as the existing certificate folder. -## what does this plugin do? +### Capabilities + +To control the plugin behavior, you can use the following capabilities: -For every appium session, interceptor plugin will start a proxy server and updates the device proxy settings to pass all network traffic to proxy server. Mocking is disabled by default and can be enabled from the test by passing `appium:intercept : true` in the desired capability while creating a new appium session. +| Capability | Type | Default | Description | +| :------------------------------- | :-------- | :------ | :----------------------------------------------------------------------------------------------------------------------- | +| `appium:startProxyAutomatically` | `boolean` | `false` | When `true`, the plugin initializes the proxy server and configures the device WiFi immediately during session creation. | + +--- + +## Usages + +### 1. Automatic lifecycle management + +Set `appium:startProxyAutomatically` to `true` in your capabilities. The plugin will handle the proxy **setup** during session creation and the proxy **cleanup** (reverting WiFi settings and closing the server) when the session ends. + +```javascript +// Example with WebdriverIO +const caps = { + "platformName": "Android", + "appium:automationName": "UiAutomator2", + "appium:startProxyAutomatically": true +}; +``` + +### 2. Manual management + +If you want to control exactly when the proxy starts (e.g., only for specific test cases), leave the capability at `false` and use the following commands within your test scripts: + +* **Start Proxy**: `driver.execute('interceptor: startProxy')` +* **Stop Proxy**: `driver.execute('interceptor: stopProxy')` + +> **Pro tip for troubleshooting**: These commands can be useful for **on-the-fly recovery** during a test session. If you encounter a network glitch or a proxy timeout, you can manually call `stopProxy` followed by `startProxy` to perform a "clean restart" without terminating your entire Appium session. + +> **Note**: Even in manual mode, the plugin **automatically handles the cleanup** when the session ends. +> Unlike a simple deactivation, the plugin **restores your previous device settings** (such as your original global proxy configuration) instead of just wiping them. This ensures your device returns exactly to the state it was in before the test started. + +## Usable commands Please refer to the [commands](/docs/commands.md) sections for detailed usage. + +## Logging & debugging + +The plugin integrates with the standard Appium logging system. For deep troubleshooting, set the server log level to `debug`: + +```json +{ + "server": { + "log-level": "debug:debug" + } +} +``` + + ## Supported Platforms 💚 `Android` diff --git a/docs/commands.md b/docs/commands.md index d1082df..efd79b6 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,6 +1,12 @@ ## Appium interceptor commands -Create the appium session by passing `appium:intercept : true` option in the desired capability. Once the session is successfully created, tests can manage the api mocking using below commands. +To enable network interception, configure your Appium session using the `appium:startProxyAutomatically` capability. Depending on your configuration, you can manage the proxy and mocking as follows: + +🔸 ***Automatic Mode***: Set `appium:startProxyAutomatically` to `true` in your desired capabilities. The plugin will immediately initialize the proxy and configure the device upon session start. + +🔸 ***Manual Mode***: If the capability is set to false (default), you must explicitly trigger the proxy initialization using the `startProxy` command during your test. + +👉 Once the proxy is successfully started (either automatically or manually), you can manage API mocking, recording, and sniffing using the commands detailed below. To route emulator traffic through another proxy, set one of the environment variables UPSTREAM_PROXY, HTTPS_PROXY, or HTTP_PROXY. All traffic from the emulator will then be forwarded to the specified upstream proxy. @@ -38,16 +44,34 @@ Mock configuration is a json object that defines the specification for filtering ## Commands: -### interceptor: addMock +### interceptor: startProxy / stopProxy -Add a new mock specification for intercepting and updating the request. The command will returns a unique id for each mock which can be used in future to delete the mock at any point in the test. +These commands allow you to manually manage the proxy lifecycle during a test session. This is the preferred method when `appium:startProxyAutomatically` is set to `false`, or if you need to reset the network configuration on-the-fly. -#### Example: +> ***Note:*** Even when using these manual commands, the plugin provides **smart cleanup**: it will automatically stop the proxy and restore your previous device settings when the session ends or crashes. +#### Example: ***Note:*** Below example uses wedriver.io javascript client. For Java client you need to use `((JavascriptExecutor) driver).executeScript()` for executing commands instead of `driver.execute()` +```javascript +// Manually starting the proxy +await driver.execute("interceptor: startProxy"); + +// ... perform your intercepted tests ... +// Manually stopping the proxy +// This will revert your device WiFi settings to their original state +await driver.execute("interceptor: stopProxy"); +``` + +### interceptor: addMock + +Add a new mock specification for intercepting and updating the request. The command will returns a unique id for each mock which can be used in future to delete the mock at any point in the test. + +#### Example: + +***Note:*** Below example uses wedriver.io javascript client. For Java client you need to use `((JavascriptExecutor) driver).executeScript()` for executing commands instead of `driver.execute()` ```javascript const authorizationMock = await driver.execute("interceptor: addMock", { diff --git a/src/plugin.ts b/src/plugin.ts index c52e7ec..2c0d594 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,68 +1,74 @@ import { BasePlugin } from 'appium/plugin'; import http from 'http'; import { Application } from 'express'; -import { CliArg, ISessionCapability, MockConfig, RecordConfig, RequestInfo, ReplayConfig, SniffConfig } from './types'; -import { DefaultPluginArgs, IPluginArgs } from './interfaces'; import _ from 'lodash'; -import { configureWifiProxy, isRealDevice, getGlobalProxyValue, getAdbReverseTunnels } from './utils/adb'; + +import { + CliArg, + ISessionCapability, + MockConfig, + RecordConfig, + RequestInfo, + ReplayConfig, + SniffConfig, +} from './types'; +import { DefaultPluginArgs, IPluginArgs } from './interfaces'; +import { + configureWifiProxy, + isRealDevice, + getGlobalProxyValue, + getAdbReverseTunnels, + ADBInstance, + UDID, +} from './utils/adb'; import { cleanUpProxyServer, sanitizeMockConfig, setupProxyServer } from './utils/proxy'; import proxyCache from './proxy-cache'; -import logger from './logger'; import log from './logger'; export class AppiumInterceptorPlugin extends BasePlugin { private pluginArgs: IPluginArgs = Object.assign({}, DefaultPluginArgs); + static executeMethodMap = { 'interceptor: addMock': { command: 'addMock', params: { required: ['config'] }, }, - 'interceptor: removeMock': { command: 'removeMock', params: { required: ['id'] }, }, - 'interceptor: disableMock': { command: 'disableMock', params: { required: ['id'] }, }, - 'interceptor: enableMock': { command: 'enableMock', params: { required: ['id'] }, }, - 'interceptor: startListening': { command: 'startListening', params: { optional: ['config'] }, }, - 'interceptor: getInterceptedData': { command: 'getInterceptedData', params: { optional: ['id'] }, }, - 'interceptor: stopListening': { command: 'stopListening', params: { optional: ['id'] }, }, - 'interceptor: startRecording': { command: 'startRecording', params: { optional: ['config'] }, }, - 'interceptor: stopRecording': { command: 'stopRecording', params: { optional: ['id'] }, }, - 'interceptor: startReplaying': { command: 'startReplaying', params: { required: ['replayConfig'] }, }, - 'interceptor: stopReplaying': { command: 'stopReplaying', params: { optional: ['id'] }, @@ -71,14 +77,24 @@ export class AppiumInterceptorPlugin extends BasePlugin { 'interceptor: getProxyState': { command: 'getProxyState', }, + 'interceptor: startProxy': { + command: 'startProxy', + }, + 'interceptor: stopProxy': { + command: 'stopProxy', + }, }; constructor(name: string, cliArgs: CliArg) { - log.debug(`📱 Plugin Args: ${JSON.stringify(cliArgs)}`); super(name, cliArgs); + log.debug(`📱 Initializing plugin with CLI args: ${JSON.stringify(cliArgs)}`); this.pluginArgs = Object.assign({}, DefaultPluginArgs, cliArgs as unknown as IPluginArgs); } + /** + * Static method called by Appium at server startup. + * Can be used to extend the Express server with new routes. + */ static async updateServer(expressApp: Application, httpServer: http.Server, cliArgs: CliArg) {} async createSession( @@ -86,173 +102,214 @@ export class AppiumInterceptorPlugin extends BasePlugin { driver: any, jwpDesCaps: any, jwpReqCaps: any, - caps: ISessionCapability + caps: ISessionCapability, ) { const response = await next(); - //If session creation failed + + // Early return if session creation failed at driver level if ((response.value && response.value.error) || response.error) { + log.warn('Session creation failed. Skipping interceptor setup.'); return response; } const mergedCaps = { ...caps.alwaysMatch, ..._.get(caps, 'firstMatch[0]', {}) }; - const interceptFlag = mergedCaps['appium:intercept']; - const { deviceUDID, platformName } = response.value[1]; - const certDirectory = this.pluginArgs.certdirectory; - const sessionId = response.value[0]; + const startProxyAutomatically = mergedCaps['appium:startProxyAutomatically'] === true; + const [sessionId, sessionCaps] = response.value; + const { deviceUDID, platformName } = sessionCaps; const adb = driver.sessions[sessionId]?.adb; - if (interceptFlag && platformName.toLowerCase().trim() === 'android') { - if(!adb) { - log.info(`Unable to find adb instance from session ${sessionId}. So skipping api interception.`); - return response; - } - const realDevice = await isRealDevice(adb, deviceUDID); - const currentGlobalProxy = await getGlobalProxyValue(adb, deviceUDID) - const proxy = await setupProxyServer(sessionId, deviceUDID, realDevice, certDirectory, currentGlobalProxy); - await configureWifiProxy(adb, deviceUDID, realDevice, proxy.options); - proxyCache.add(sessionId, proxy); + // Platform validation (Android only) + if (platformName?.toLowerCase().trim() !== 'android') { + log.warn( + `Platform '${platformName}' is not supported. Appium interceptor plugin only supports Android. Skipping interceptor setup.`, + ); + return response; + } + + if (!adb) { + throw log.errorWithException( + `[${sessionId}] Unable to find ADB instance. API interception cannot be initialized.`, + ); + } + + if (startProxyAutomatically) { + log.debug( + `[${sessionId}] Capability 'startProxyAutomatically' is enabled. Initializing proxy setup...`, + ); + await this.setupProxy(adb, sessionId, deviceUDID); + } else { + log.debug( + `[${sessionId}] Capability 'startProxyAutomatically' is disabled. Use command 'startProxy' to start proxy.`, + ); } - log.info("Creating session for appium interceptor"); + return response; } - async deleteSession(next: () => any, driver: any, sessionId: any) { - const proxy = proxyCache.get(sessionId); - if (proxy) { - const adb = driver.sessions[sessionId]?.adb; - await configureWifiProxy(adb, proxy.deviceUDID, false, proxy.previousGlobalProxy); - await cleanUpProxyServer(proxy); - } + async deleteSession(next: () => any, driver: any, sessionId: string) { + log.debug(`[${sessionId}] Deleting session. Starting proxy cleanup...`); + const adb = driver.sessions[sessionId]?.adb; + await this.clearProxy(adb, sessionId); return next(); } async onUnexpectedShutdown(driver: any, cause: any) { + log.error( + `Unexpected shutdown detected (Cause: ${cause}). Cleaning up all active proxy sessions...`, + ); const sessions = Object.keys(driver.sessions || {}); for (const sessionId of sessions) { - const proxy = proxyCache.get(sessionId); - if (proxy) { - const adb = driver.sessions[sessionId]?.adb; - await configureWifiProxy(adb, proxy.deviceUDID, false, proxy.previousGlobalProxy); - await cleanUpProxyServer(proxy); - } + const adb = driver.sessions[sessionId]?.adb; + await this.clearProxy(adb, sessionId); } } - async addMock(next: any, driver: any, config: MockConfig) { - const proxy = proxyCache.get(driver.sessionId); - if (!proxy) { - logger.error('Proxy is not running'); - throw new Error('Proxy is not active for current session'); - } - + async addMock(_next: any, driver: any, config: MockConfig) { + const proxy = this.getSessionProxy(driver.sessionId); + log.debug(`[${driver.sessionId}] Registering new mock rule (config=${JSON.stringify(config)})`); sanitizeMockConfig(config); - return proxy?.addMock(config); + return proxy.addMock(config); } - async removeMock(next: any, driver: any, id: any) { - const proxy = proxyCache.get(driver.sessionId); - if (!proxy) { - logger.error('Proxy is not running'); - throw new Error('Proxy is not active for current session'); - } - + async removeMock(_next: any, driver: any, id: string) { + const proxy = this.getSessionProxy(driver.sessionId); + log.debug(`[${driver.sessionId}] Removing mock rule with ID: ${id}`); proxy.removeMock(id); } - async disableMock(next: any, driver: any, id: any) { - const proxy = proxyCache.get(driver.sessionId); - if (!proxy) { - logger.error('Proxy is not running'); - throw new Error('Proxy is not active for current session'); - } - + async disableMock(_next: any, driver: any, id: string) { + const proxy = this.getSessionProxy(driver.sessionId); + log.debug(`[${driver.sessionId}] Disabling mock rule with ID: ${id}`); proxy.disableMock(id); } - async enableMock(next: any, driver: any, id: any) { - const proxy = proxyCache.get(driver.sessionId); - if (!proxy) { - logger.error('Proxy is not running'); - throw new Error('Proxy is not active for current session'); - } - + async enableMock(_next: any, driver: any, id: string) { + const proxy = this.getSessionProxy(driver.sessionId); + log.debug(`[${driver.sessionId}] Enabling mock rule with ID: ${id}`); proxy.enableMock(id); } - async startListening(next: any, driver: any, config: SniffConfig): Promise { - const proxy = proxyCache.get(driver.sessionId); - if (!proxy) { - logger.error('Proxy is not running'); - throw new Error('Proxy is not active for current session'); - } + async startListening(_next: any, driver: any, config: SniffConfig): Promise { + const proxy = this.getSessionProxy(driver.sessionId); + log.debug(`[${driver.sessionId}] Starting network listener (config=${JSON.stringify(config)})`); + return proxy.addSniffer(config); + } - log.info(`Adding listener with config ${config}`); - return proxy?.addSniffer(config); + async getInterceptedData(_next: any, driver: any, id: string): Promise { + const proxy = this.getSessionProxy(driver.sessionId); + log.debug(`[${driver.sessionId}] Fetching intercepted data for listener with ID: ${id}`); + return proxy.getInterceptedData(false, id); } - async getInterceptedData(next: any, driver: any, id: any): Promise { - const proxy = proxyCache.get(driver.sessionId); - if (!proxy) { - logger.error('Proxy is not running'); - throw new Error('Proxy is not active for current session'); - } + async stopListening(_next: any, driver: any, id: string): Promise { + const proxy = this.getSessionProxy(driver.sessionId); + log.debug(`[${driver.sessionId}] Stopping network listener with ID: ${id}`); + return proxy.removeSniffer(false, id); + } - log.info(`Getting intercepted requests for listener with id: ${id}`); - return proxy.getInterceptedData(false, id); + async startRecording(_next: any, driver: any, config: SniffConfig): Promise { + const proxy = this.getSessionProxy(driver.sessionId); + log.debug(`[${driver.sessionId}] Starting traffic recording`); + return proxy.addSniffer(config); } - async stopListening(next: any, driver: any, id: any): Promise { - const proxy = proxyCache.get(driver.sessionId); - if (!proxy) { - logger.error('Proxy is not running'); - throw new Error('Proxy is not active for current session'); - } + async stopRecording(_next: any, driver: any, id: string): Promise { + const proxy = this.getSessionProxy(driver.sessionId); + log.debug(`[${driver.sessionId}] Stopping traffic recording for listener with ID: ${id}`); + return proxy.removeSniffer(true, id); + } - log.info(`Stopping listener with id: ${id}`); - return proxy.removeSniffer(false, id); + async startReplaying(_next: any, driver: any, replayConfig: ReplayConfig) { + const proxy = this.getSessionProxy(driver.sessionId); + log.debug(`[${driver.sessionId}] Starting traffic replay`); + proxy.startReplaying(); + return proxy.getRecordingManager().replayTraffic(replayConfig); } - async startRecording(next: any, driver: any, config: SniffConfig): Promise { - const proxy = proxyCache.get(driver.sessionId); - if (!proxy) { - logger.error('Proxy is not running'); - throw new Error('Proxy is not active for current session'); - } + async stopReplaying(_next: any, driver: any, id: string) { + const proxy = this.getSessionProxy(driver.sessionId); + log.debug(`[${driver.sessionId}] Stopping traffic replay`); + proxy.getRecordingManager().stopReplay(id); + } - log.info(`Adding listener with config ${config}`); - return proxy?.addSniffer(config); + async execute(next: any, driver: any, script: string, args: any) { + return await this.executeMethod(next, driver, script, args); } - async stopRecording(next: any, driver: any, id: any): Promise { - const proxy = proxyCache.get(driver.sessionId); - if (!proxy) { - logger.error('Proxy is not running'); - throw new Error('Proxy is not active for current session'); - } + async startProxy(_next: any, driver: any) { + await this.setupProxy(driver.adb, driver.sessionId, driver.adb?.curDeviceId); + } - log.info(`Stopping recording with id: ${id}`); - return proxy.removeSniffer(true, id); + async stopProxy(_next: any, driver: any) { + await this.clearProxy(driver.adb, driver.sessionId); } - async startReplaying(next:any, driver:any, replayConfig: ReplayConfig) { - const proxy = proxyCache.get(driver.sessionId); + private getSessionProxy(sessionId: string) { + log.debug(`getSessionProxy(sessionId=${sessionId})`); + const proxy = proxyCache.get(sessionId); if (!proxy) { - logger.error('Proxy is not running'); - throw new Error('Proxy is not active for current session'); + throw log.errorWithException( + `No active proxy found for session ${sessionId}. Please call 'startProxy' first.`, + ); } - log.info('Starting replay traffic'); - proxy.startReplaying(); - return proxy.getRecordingManager().replayTraffic(replayConfig); + return proxy; } - async stopReplaying(next: any, driver:any, id:any) { - const proxy = proxyCache.get(driver.sessionId); + private async setupProxy(adb: ADBInstance, sessionId: string, deviceUDID: UDID) { + log.debug(`setupProxy(sessionId=${sessionId}, deviceUDID:${deviceUDID})`); + + if (proxyCache.get(sessionId)) { + log.warn(`[${sessionId}] A proxy is already active for this session. Skipping setup.`); + return; + } + + if (!adb) throw log.errorWithException('Proxy setup failed: ADB instance is missing.'); + if (!sessionId) throw log.errorWithException('Proxy setup failed: Session ID is missing.'); + if (!deviceUDID) throw log.errorWithException('Proxy setup failed: Device UDID is missing.'); + + try { + const realDevice = await isRealDevice(adb, deviceUDID); + const currentGlobalProxy = await getGlobalProxyValue(adb, deviceUDID); + + const proxy = await setupProxyServer( + sessionId, + deviceUDID, + realDevice, + this.pluginArgs.certdirectory, + currentGlobalProxy, + ); + + await configureWifiProxy(adb, deviceUDID, realDevice, proxy.options); + + proxyCache.add(sessionId, proxy); + log.debug( + `[${sessionId}] Proxy successfully registered (ip=${proxy.options.ip}, port=${proxy.options.port}).`, + ); + } catch (err: any) { + throw log.errorWithException(`[${sessionId}] Failed to initialize proxy: ${err.message}`); + } + } + + private async clearProxy(adb: ADBInstance, sessionId: string) { + const proxy = proxyCache.get(sessionId); if (!proxy) { - logger.error('Proxy is not running'); - throw new Error('Proxy is not active for current session'); + log.debug(`[${sessionId}] No proxy registered for this session. Nothing to clear.`); + return; + } + + log.debug(`[${sessionId}] Reverting device settings and cleaning up proxy resources...`); + + try { + // Revert WiFi settings to previous state or off + await configureWifiProxy(adb, proxy.options.deviceUDID, false, proxy.previousGlobalProxy); + // Shutdown the local proxy server + await cleanUpProxyServer(proxy); + proxyCache.remove(sessionId); + log.debug(`[${sessionId}] Proxy cleanup successful.`); + } catch (err: any) { + // Log the error but do not block the session deletion process + log.error(`[${sessionId}] Critical error during proxy cleanup: ${err.message}`); } - log.info("Initiating stop replaying traffic"); - proxy.getRecordingManager().stopReplay(id); } /** @@ -287,8 +344,4 @@ export class AppiumInterceptorPlugin extends BasePlugin { }; return JSON.stringify(proxyState); } - - async execute(next: any, driver: any, script: any, args: any) { - return await this.executeMethod(next, driver, script, args); - } } \ No newline at end of file