diff --git a/package-lock.json b/package-lock.json index c6c11d6..1035431 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "appium-interceptor", - "version": "1.0.0-beta.10", + "version": "1.0.0-beta.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "appium-interceptor", - "version": "1.0.0-beta.10", + "version": "1.0.0-beta.11", "license": "ISC", "dependencies": { "@appium/support": "^4.1.11", @@ -23,6 +23,7 @@ "lodash": "^4.17.21", "minimatch": "^9.0.3", "parse-headers": "^2.0.5", + "queue-typescript": "^1.0.1", "regex-parser": "^2.3.0", "uuid": "^9.0.1", "yargs": "^17.7.2" @@ -17617,6 +17618,11 @@ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=" }, + "node_modules/linked-list-typescript": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/linked-list-typescript/-/linked-list-typescript-1.0.15.tgz", + "integrity": "sha512-RIyUu9lnJIyIaMe63O7/aFv/T2v3KsMFuXMBbUQCHX+cgtGro86ETDj5ed0a8gQL2+DFjzYYsgVG4I36/cUwgw==" + }, "node_modules/lint-staged": { "version": "11.1.2", "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-11.1.2.tgz", @@ -19696,6 +19702,14 @@ "resolved": "https://registry.npmmirror.com/queue-tick/-/queue-tick-1.0.1.tgz", "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==" }, + "node_modules/queue-typescript": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/queue-typescript/-/queue-typescript-1.0.1.tgz", + "integrity": "sha512-tkK08uPfmpPl0cX1WRSU3EoNb/T5zSoZPGkkpfGX4E8QayWvEmLS2cI3pFngNPkNTCU5pCDQ1IwlzN0L5gdFPg==", + "dependencies": { + "linked-list-typescript": "^1.0.11" + } + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmmirror.com/quick-lru/-/quick-lru-5.1.1.tgz", diff --git a/package.json b/package.json index 358c0e2..ef0dbc3 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { "name": "appium-interceptor", - "version": "1.0.0-beta.10", + "version": "1.0.0-beta.11", "description": "Appium 2.0 plugin to mock api calls for android apps", "main": "./lib/index.js", "types": "./lib/types/index.d.ts", "scripts": { "build": "npx tsc", "test": "mocha --require ts-node/register -p test/plugin.spec.js --exit --timeout 260000", + "record-replay-test": "mocha --require ts-node/register -p test/record.replay.js --exit", "prepublish": "npx tsc", "lint": "eslint '**/*.js' --fix", "prettier": "prettier '**/*.js' --write --single-quote", @@ -104,6 +105,7 @@ ], "dependencies": { "@appium/support": "^4.1.11", + "queue-typescript": "^1.0.1", "ajv": "^6.12.6", "appium-adb": "^11.0.9", "axios": "^0.27.0", diff --git a/src/api-sniffer.ts b/src/api-sniffer.ts index cd297b2..4d9224f 100644 --- a/src/api-sniffer.ts +++ b/src/api-sniffer.ts @@ -1,5 +1,6 @@ import { RequestInfo, SniffConfig } from './types'; import { doesUrlMatch } from './utils/proxy'; +import log from './logger'; export class ApiSniffer { private readonly requests: RequestInfo[] = []; @@ -23,7 +24,10 @@ export class ApiSniffer { private doesRequestMatchesConfig(request: RequestInfo) { const doesIncludeRuleMatches = !this.config.include ? true - : this.config.include.some((config) => doesUrlMatch(config.url, request.url)); + : this.config.include.some((config) => { + log.info(`Matching include url ${request.url} with request ${config.url}`); + doesUrlMatch(config.url, request.url) + }); const doesExcludeRuleMatches = !this.config.exclude ? true : !this.config.exclude.some((config) => doesUrlMatch(config.url, request.url)); diff --git a/src/interceptor.ts b/src/interceptor.ts index 300213c..2272c2e 100644 --- a/src/interceptor.ts +++ b/src/interceptor.ts @@ -3,6 +3,7 @@ import stream from 'stream'; import { constructURLFromHttpRequest } from './utils/proxy'; import responseDecoder from './response-decoder'; import parseHeader from 'parse-headers'; +import log from './logger'; function readBodyFromStream(writable: stream.Writable | undefined, callback: (value: any) => void) { if (!writable) { diff --git a/src/plugin.ts b/src/plugin.ts index 3ca699c..f18a05c 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,7 +1,7 @@ import { BasePlugin } from 'appium/plugin'; import http from 'http'; import { Application } from 'express'; -import { CliArg, ISessionCapability, MockConfig, RequestInfo, SniffConfig } from './types'; +import { CliArg, ISessionCapability, MockConfig, RecordConfig, RequestInfo, ReplayConfig, SniffConfig } from './types'; import _ from 'lodash'; import { configureWifiProxy, isRealDevice } from './utils/adb'; import { cleanUpProxyServer, sanitizeMockConfig, setupProxyServer } from './utils/proxy'; @@ -40,6 +40,26 @@ export class AppiumInterceptorPlugin extends BasePlugin { 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'] }, + }, }; constructor(name: string, cliArgs: CliArg) { @@ -77,6 +97,7 @@ export class AppiumInterceptorPlugin extends BasePlugin { await configureWifiProxy(adb, deviceUDID, realDevice, proxy); proxyCache.add(sessionId, proxy); } + log.info("Creating session for appium interceptor"); return response; } @@ -162,10 +183,53 @@ export class AppiumInterceptorPlugin extends BasePlugin { } log.info(`Stopping listener with id: ${id}`); - return proxy.removeSniffer(id); + return proxy.removeSniffer(false, id); + } + + 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'); + } + + log.info(`Adding listener with config ${config}`); + return proxy?.addSniffer(config); + } + + 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'); + } + + log.info(`Stopping recording with id: ${id}`); + return proxy.removeSniffer(true, id); + } + + async startReplaying(next:any, driver:any, replayConfig: ReplayConfig) { + const proxy = proxyCache.get(driver.sessionId); + if (!proxy) { + logger.error('Proxy is not running'); + throw new Error('Proxy is not active for current session'); + } + log.info('Starting replay traffic'); + proxy.startReplaying(); + return proxy.getRecordingManager().replayTraffic(replayConfig); + } + + async stopReplaying(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'); + } + log.info("Initiating stop replaying traffic"); + proxy.getRecordingManager().stopReplay(id); } async execute(next: any, driver: any, script: any, args: any) { return await this.executeMethod(next, driver, script, args); } -} +} \ No newline at end of file diff --git a/src/proxy.ts b/src/proxy.ts index 443e3b0..d1d01ed 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,4 +1,4 @@ -import { MockConfig, RequestInfo, SniffConfig } from './types'; +import { MockConfig, RecordConfig, RequestInfo, SniffConfig } from './types'; import { Proxy as HttpProxy, IContext, IProxyOptions } from 'http-mitm-proxy'; import { v4 as uuid } from 'uuid'; import { @@ -19,7 +19,8 @@ import { Mock } from './mock'; import { RequestInterceptor } from './interceptor'; import { ApiSniffer } from './api-sniffer'; import _ from 'lodash'; -import logger from './logger'; +import log from './logger'; +import { RecordingManager } from './recording-manager'; export interface ProxyOptions { deviceUDID: string; @@ -31,20 +32,35 @@ export interface ProxyOptions { export class Proxy { private _started = false; + private _replayStarted = false; private readonly mocks = new Map(); private readonly sniffers = new Map(); private readonly httpProxy: HttpProxy; + private readonly recordingManager: RecordingManager; public isStarted(): boolean { return this._started; } + public isReplayStarted(): boolean { + return this._replayStarted; + } + + public startReplaying(): void { + this._replayStarted = true; + } + constructor(private readonly options: ProxyOptions) { this.httpProxy = new HttpProxy(); + this.recordingManager = new RecordingManager(options); addDefaultMocks(this); } + public getRecordingManager(): RecordingManager { + return this.recordingManager; + } + public get port(): number { return this.options.port; } @@ -81,7 +97,7 @@ export class Proxy { this.httpProxy.onRequest(this.handleMockApiRequest.bind(this)); this.httpProxy.onError((context, error, errorType) => { - logger.error(`${errorType}: ${error}`); + log.error(`${errorType}: ${error}`); }); await new Promise((resolve) => { @@ -123,26 +139,37 @@ export class Proxy { return id; } - public removeSniffer(id?: string): RequestInfo[] { - const _sniffers = [...this.sniffers.values()]; - if (id && !_.isNil(this.sniffers.get(id))) { - _sniffers.push(this.sniffers.get(id)!); + public removeSniffer(record: boolean, id?: string): RequestInfo[] { + const _sniffers = [...this.sniffers.values()]; + if (id && !_.isNil(this.sniffers.get(id))) { + _sniffers.push(this.sniffers.get(id)!); + } + let apiRequests; + if (record) { + apiRequests = this.recordingManager.getCapturedTraffic(_sniffers); + } + else { + apiRequests = _sniffers.reduce((acc, sniffer) => { + acc.push(...sniffer.getRequests()); + return acc; + }, [] as RequestInfo[]); + } + _sniffers.forEach((sniffer) => this.sniffers.delete(sniffer.getId())); + return apiRequests; } - const apiRequests = _sniffers.reduce((acc, sniffer) => { - acc.push(...sniffer.getRequests()); - return acc; - }, [] as RequestInfo[]); - _sniffers.forEach((sniffer) => this.sniffers.delete(sniffer.getId())); - return apiRequests; - } private async handleMockApiRequest(ctx: IContext, next: () => void): Promise { - const matchedMocks = await this.findMatchingMocks(ctx); - if (matchedMocks.length) { - const compiledMock = compileMockConfig(matchedMocks); - this.applyMockToRequest(ctx, compiledMock, next); - } else { - next(); + if (this.isReplayStarted()) { + this.recordingManager.handleRecordingApiRequest(ctx, next); + } else if (!this.isReplayStarted()) { + const matchedMocks = await this.findMatchingMocks(ctx); + if (matchedMocks.length) { + const compiledMock = compileMockConfig(matchedMocks); + this.applyMockToRequest(ctx, compiledMock, next); + } + else { + next(); + } } } @@ -197,4 +224,4 @@ export class Proxy { next(); } } -} +} \ No newline at end of file diff --git a/src/record.ts b/src/record.ts new file mode 100644 index 0000000..34ab2ce --- /dev/null +++ b/src/record.ts @@ -0,0 +1,27 @@ +import { RecordConfig } from './types'; + +export class Record { + private enabled = true; + + constructor(private id: string, private config: RecordConfig) {} + + getId() { + return this.id; + } + + getConfig() { + return this.config; + } + + isEnabled() { + return this.enabled; + } + + setEnableStatus(enbaleStatus: boolean) { + this.enabled = enbaleStatus; + } + + updateConfig(config: RecordConfig) { + this.config = config; + } +} \ No newline at end of file diff --git a/src/recording-manager.ts b/src/recording-manager.ts new file mode 100644 index 0000000..29a1a15 --- /dev/null +++ b/src/recording-manager.ts @@ -0,0 +1,165 @@ +import { MockConfig, RecordConfig, RequestInfo, ReplayConfig, ReplayStrategy, SniffConfig } from './types'; +import { Queue } from 'queue-typescript'; +import { ProxyOptions } from './proxy'; +import { Record } from './record'; +import { v4 as uuid } from 'uuid'; +import _ from 'lodash'; +import { IContext } from 'http-mitm-proxy'; +import { constructURLFromHttpRequest, modifyRequestBody, modifyRequestHeaders, modifyRequestUrl, modifyResponseBody, sleep } from './utils/record'; +import { doesUrlMatch, parseJson } from './utils/proxy'; +import { ApiSniffer } from './api-sniffer'; +import log from './logger'; + + +export class RecordingManager { + private readonly records = new Map>(); + private readonly simulationStrategyMap = new Map(); + + constructor(private readonly options: ProxyOptions) {} + + public getCapturedTraffic(_sniffers: ApiSniffer[]): RequestInfo[] { + const apiRequests: RequestInfo[] = []; + + _sniffers.forEach(sniffer => { + const apiConfigMap = new Map(); + const requests = sniffer.getRequests(); + + if (!requests || requests.length === 0) { + return; + } + + requests.forEach(request => { + const path = new URL(request.url).pathname; + const key = `${path}_${request.method}`; + + if (apiConfigMap.has(key)) { + const existingConfig = apiConfigMap.get(key)!; + existingConfig.responseBody.push(request.responseBody); + } else { + const recordConfig: RequestInfo = { + url: request.url, + method: request.method, + requestBody: request.requestBody, + statusCode: request.statusCode, + requestHeaders: request.requestHeaders, + responseHeaders: request.responseHeaders, + responseBody: [request.responseBody] + }; + apiConfigMap.set(key, recordConfig); + } + }); + + apiRequests.push(...apiConfigMap.values()); + }); + return apiRequests; + } + + public replayTraffic(simulationConfig: ReplayConfig) { + const recordConfigs = simulationConfig.recordings; + this.simulationStrategyMap.set(this.options.sessionId, simulationConfig.replayStrategy ? + simulationConfig.replayStrategy : ReplayStrategy.DEFAULT); + + const recordMap = new Map(); + const replayId = `${this.options.deviceUDID}-${this.options.sessionId}`; + + recordConfigs.forEach(recordConfig => { + const responseBody : Queue = new Queue(); + const url = new URL(recordConfig.url); + const id = `${this.options.deviceUDID}_${url.pathname}_${recordConfig.method?.toLowerCase()}`; + + if (recordConfig.responseBody && recordConfig.responseBody.length > 0) { + for (const response of recordConfig.responseBody) { + responseBody.append(response); + } + } + recordConfig.responseBody = responseBody; + recordMap.set(id, new Record(id, recordConfig)); + }) + this.records.set(replayId, recordMap); + return replayId; + } + + public stopReplay(id?: string): void { + const replayId = id ?? `${this.options.deviceUDID}-${this.options.sessionId}`; + this.records.delete(replayId); + } + + public async handleRecordingApiRequest(ctx: IContext, next: () => void): Promise { + const matchedRecords = await this.findMatchingRecords(ctx); + if (matchedRecords.length) { + matchedRecords.forEach(matchedRecord => { + this.applyRecordToRequest(ctx, matchedRecord, next); + }) + } else { + next(); + } + } + + public async findMatchingRecords(ctx: IContext): Promise { + const request = ctx.clientToProxyRequest; + if (!request.headers?.host || !request.url) { + return []; + } + + const url = constructURLFromHttpRequest({ + host: request.headers.host, + path: request.url, + protocol: ctx.isSSL ? 'https://' : 'http://', + }); + + const matchedRecords: RecordConfig[] = []; + const id = `${this.options.deviceUDID}_${url.pathname}_${request.method?.toLowerCase()}`; + const records = this.records.get(`${this.options.deviceUDID}-${this.options.sessionId}`); + + if (records && records.has(id)) { + const record = records.get(id); + const recordConfig = record?.getConfig(); + if (recordConfig) { + matchedRecords.push(recordConfig); + } + } else if (records) { + for (const record of records.values()) { + const recordConfig = record?.getConfig(); + if (doesUrlMatch(recordConfig.url, url.toString())) { + matchedRecords.push(recordConfig); + break; + } + } + } + + return matchedRecords; + } + + private async applyRecordToRequest(ctx: IContext, recordConfig: RecordConfig, next: () => void) { + if (recordConfig.delay) { + await sleep(recordConfig.delay); + } + this.modifyClientRequest(ctx, recordConfig); + this.modifyClientResponse(ctx, recordConfig, next); + } + + private modifyClientRequest(ctx: IContext, recordConfig: RecordConfig): void { + modifyRequestUrl(ctx, recordConfig); + modifyRequestHeaders(ctx, recordConfig); + modifyRequestBody(ctx, recordConfig); + } + + private async modifyClientResponse(ctx: IContext, recordConfig: RecordConfig, next: () => void) { + const id = `${this.options.deviceUDID}_${recordConfig.url}_${recordConfig.method?.toLowerCase()}`; + + if (recordConfig.statusCode && recordConfig.responseBody && recordConfig.responseBody.length > 0) { + ctx.proxyToClientResponse.writeHead(recordConfig.statusCode); + const responseBody = recordConfig.responseBody.dequeue(); + + this.simulationStrategyMap.get(this.options.sessionId) === ReplayStrategy.CIRCULAR ? recordConfig.responseBody.enqueue(responseBody) : null; + ctx.proxyToClientResponse.end(responseBody); + + if (this.records.has(id) && recordConfig.responseBody.length <= 0) { + this.records.delete(id); + } + } else { + modifyResponseBody(ctx, recordConfig); + next(); + } + } +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 2d75668..7cf8bbb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import { Queue } from 'queue-typescript'; + export type CliArg = Record; export type ISessionCapability = { @@ -5,6 +7,11 @@ export type ISessionCapability = { alwaysMatch: any; }; +export enum ReplayStrategy { + CIRCULAR = 'CIRCULAR', + DEFAULT = 'DEFAULT' +} + export type UrlPattern = string; export type HttpHeader = | Record @@ -25,7 +32,7 @@ export type RegExpReplacer = { export type UpdateBodySpec = JsonPathReplacer | RegExpReplacer; -export type MockConfig = { +type BaseConfig = { url: UrlPattern; method?: string; updateUrl?: RegExpReplacer[]; @@ -34,9 +41,21 @@ export type MockConfig = { updateRequestBody?: UpdateBodySpec[]; statusCode?: number; responseHeaders?: HttpHeader; + delay?: number; +}; + +export type MockConfig = BaseConfig & { responseBody?: string; updateResponseBody?: UpdateBodySpec[]; - delay?: number; +}; + +export type RecordConfig = BaseConfig & { + responseBody?: Queue; +}; + +export type ReplayConfig = { + recordings: RecordConfig[]; + replayStrategy?: ReplayStrategy; }; export type SniffConfig = { @@ -52,4 +71,4 @@ export type RequestInfo = { requestHeaders: Record; responseBody: any; responseHeaders: Record; -}; +}; \ No newline at end of file diff --git a/src/utils/record.ts b/src/utils/record.ts new file mode 100644 index 0000000..c9f6a3e --- /dev/null +++ b/src/utils/record.ts @@ -0,0 +1,131 @@ +import { IContext, OnRequestDataCallback } from 'http-mitm-proxy'; +import { + RecordConfig, +} from '../types'; +import _ from 'lodash'; +import log from '../logger'; +import { parseRegex, processBody } from './proxy'; + +export function constructURLFromHttpRequest(request: { + protocol: string; + path: string; + host: string; +}) { + const urlString = `${request.protocol}${request?.host}${request.path}`; + return new URL(urlString); +} + +export function modifyRequestUrl(ctx: IContext, recordConfig: RecordConfig) { + if (!recordConfig.updateUrl || !ctx.clientToProxyRequest || !ctx.proxyToServerRequestOptions) { + return; + } + + const { headers, url } = ctx.clientToProxyRequest; + const protocol = ctx.isSSL ? 'https://' : 'http://'; + const originalUrl = constructURLFromHttpRequest({ + host: headers.host!, + path: url!, + protocol, + }); + + const updateUrlMatchers = _.castArray(recordConfig.updateUrl); + const updatedUrlString = updateUrlMatchers.reduce((current, matcher) => { + return current.replace(parseRegex(matcher.regexp as string), matcher.value); + }, originalUrl.toString()); + + const updatedUrl = new URL(updatedUrlString); + ctx.proxyToServerRequestOptions.host = updatedUrl.hostname; + ctx.proxyToServerRequestOptions.path = `${updatedUrl.pathname}${updatedUrl.search}`; + ctx.proxyToServerRequestOptions.port = updatedUrl.port || ctx.proxyToServerRequestOptions.port; + ctx.proxyToServerRequestOptions.headers.host= updatedUrl.hostname; +} + +export function modifyRequestHeaders(ctx: IContext, recordConfig: RecordConfig) { + if (!recordConfig.headers || !ctx.proxyToServerRequestOptions) { + return; + } + + const { headers } = ctx.proxyToServerRequestOptions; + if (recordConfig.headers?.add) { + Object.assign(headers, recordConfig.headers.add); + } + if (recordConfig.headers?.remove && Array.isArray(recordConfig.headers?.remove)) { + recordConfig.headers.remove.forEach((header: string) => delete headers[header]); + } +} + +export function modifyRequestBody(ctx: IContext, recordConfig: RecordConfig) { + const requestBodyChunks: Buffer[] = []; + ctx.onRequestData((ctx: IContext, chunk: Buffer, callback: OnRequestDataCallback) => { + requestBodyChunks.push(chunk); + callback(null, undefined); + }); + ctx.onRequestEnd((ctx: IContext, callback: OnRequestDataCallback) => { + const originalBody = Buffer.concat(requestBodyChunks).toString('utf-8'); + let postBody = recordConfig.requestBody || originalBody; + if (recordConfig.updateRequestBody) { + postBody = processBody(recordConfig.updateRequestBody, originalBody); + } + ctx.proxyToServerRequest?.setHeader('Content-Length', Buffer.byteLength(postBody)); + ctx.proxyToServerRequest?.write(postBody); + callback(); + }); +} + +export function modifyResponseBody(ctx: IContext, recordConfig: RecordConfig) { + const responseBodyChunks: Buffer[] = []; + + // Collect response data chunks + ctx.onResponseData((ctx: IContext, chunk: Buffer, callback: OnRequestDataCallback) => { + responseBodyChunks.push(chunk); + return callback(null, undefined); + }); + + // Handle end of response data + ctx.onResponseEnd((ctx: IContext, callback: OnRequestDataCallback) => { + const responseBody = Buffer.concat(responseBodyChunks).toString('utf8'); + const statusCode = recordConfig.statusCode ?? ctx.serverToProxyResponse?.statusCode as number; + try { + ctx.proxyToClientResponse.writeHead(statusCode); + } catch (error) { + log.error(`Error occurred while writing status code to response for URL: ${recordConfig.url}`); + } + try { + ctx.proxyToClientResponse.write(responseBody); + } catch (error) { + log.error(`Error occurred while writing response body for URL: ${recordConfig.url}`); + } + callback(null); + }); +} + + +export function compileRecordConfig(records: Array) { + const compiledRecord: RecordConfig = { + url: '', + updateUrl: [], + headers: { + add: {}, + remove: [], + }, + responseHeaders: { + add: {}, + remove: [], + }, + updateRequestBody: [], + }; + + records.reduce((finalRecord, record) => { + return _.mergeWith(finalRecord, record, (objValue, srcValue) => { + if (_.isArray(objValue)) { + return objValue.concat(srcValue); + } + }); + }, compiledRecord); + + return compiledRecord; +} + +export function sleep(timeMs: number) { + return new Promise((resolve) => setTimeout(resolve, timeMs)); +} \ No newline at end of file diff --git a/test/record.replay.js b/test/record.replay.js new file mode 100644 index 0000000..3fd6957 --- /dev/null +++ b/test/record.replay.js @@ -0,0 +1,101 @@ +import { remote } from 'webdriverio'; +import path from 'path'; +import fs from 'fs'; + +const APPIUM_HOST = '127.0.0.1'; +const APPIUM_PORT = 4723; +const APK_PATH = path.join(__dirname, '..', 'assets', 'test_app_mitm_proxy.apk'); + +const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + +const capabilities = { + platformName: 'Android', + 'appium:automationName': 'UIAutomator2', + 'appium:app': APK_PATH, + 'appium:intercept': true, +}; + +const wdOpts = { + hostname: APPIUM_HOST, + port: APPIUM_PORT, + path: '/', + logLevel: 'info', + capabilities, +}; + +let driver; + +describe('Different APK Plugin Test', function() { + this.timeout(60000); // Extend timeout if necessary + + beforeEach(async function() { + driver = await remote(wdOpts); + }); + + it('Should handle multiple clicks and start/stop recording', async function() { + + await driver.execute("interceptor: startRecording"); + await sleep(2000); + + // Loop 5 times from 0 to 4 + for (let i = 0; i < 6; i++) { + console.log(`Click iteration: ${i + 1}`); + + // Perform a click action on the button + const element = await driver.$('//android.widget.TextView[@text="Get User List"]'); + await element.click(); + await sleep(1000); // Wait between clicks if necessary + } + + const recordedData = await driver.execute('interceptor: stopRecording'); + const jsonString = JSON.stringify(recordedData, null, 2); + // console.log(recordedData); + + fs.writeFileSync('./recordedData.json', jsonString, 'utf8'); + }); + + it('Replay traffic', async function() { + const recordedData = JSON.parse(fs.readFileSync('./recordedData.json', 'utf8')); + + await driver.execute("interceptor: startReplaying", { + replayConfig: { + recordings: recordedData, + replayStrategy: 'CIRCULAR' + } + }); + await sleep(2000); + for (let i = 0; i < 8; i++) { + console.log(`Click iteration: ${i + 1}`); + const element = await driver.$('//android.widget.TextView[@text="Get User List"]'); + await element.click(); + await sleep(2000); + } + + }); + + it('Stop replaying in between replay traffic', async function() { + const recordedData = JSON.parse(fs.readFileSync('./recordedData.json', 'utf8')); + + await driver.execute("interceptor: startReplaying", { + replayConfig: { + recordings: recordedData, + replayStrategy: 'CIRCULAR' + } + }); + await sleep(2000); + for (let i = 0; i < 8; i++) { + console.log(`Click iteration: ${i + 1}`); + const element = await driver.$('//android.widget.TextView[@text="Get User List"]'); + await element.click(); + await sleep(2000); + if (i == 4) { + driver.execute("interceptor: stopReplaying"); + } + } + }) + + afterEach(async function() { + await driver.pause(1000); + await driver.deleteSession(); + }); +}); \ No newline at end of file