Skip to content

Commit ddd3638

Browse files
Feature: Record and Replay Backend Traffic (#49)
1 parent 804fdb1 commit ddd3638

File tree

11 files changed

+586
-31
lines changed

11 files changed

+586
-31
lines changed

package-lock.json

Lines changed: 16 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
{
22
"name": "appium-interceptor",
3-
"version": "1.0.0-beta.10",
3+
"version": "1.0.0-beta.11",
44
"description": "Appium 2.0 plugin to mock api calls for android apps",
55
"main": "./lib/index.js",
66
"types": "./lib/types/index.d.ts",
77
"scripts": {
88
"build": "npx tsc",
99
"test": "mocha --require ts-node/register -p test/plugin.spec.js --exit --timeout 260000",
10+
"record-replay-test": "mocha --require ts-node/register -p test/record.replay.js --exit",
1011
"prepublish": "npx tsc",
1112
"lint": "eslint '**/*.js' --fix",
1213
"prettier": "prettier '**/*.js' --write --single-quote",
@@ -104,6 +105,7 @@
104105
],
105106
"dependencies": {
106107
"@appium/support": "^4.1.11",
108+
"queue-typescript": "^1.0.1",
107109
"ajv": "^6.12.6",
108110
"appium-adb": "^11.0.9",
109111
"axios": "^0.27.0",

src/api-sniffer.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { RequestInfo, SniffConfig } from './types';
22
import { doesUrlMatch } from './utils/proxy';
3+
import log from './logger';
34

45
export class ApiSniffer {
56
private readonly requests: RequestInfo[] = [];
@@ -23,7 +24,10 @@ export class ApiSniffer {
2324
private doesRequestMatchesConfig(request: RequestInfo) {
2425
const doesIncludeRuleMatches = !this.config.include
2526
? true
26-
: this.config.include.some((config) => doesUrlMatch(config.url, request.url));
27+
: this.config.include.some((config) => {
28+
log.info(`Matching include url ${request.url} with request ${config.url}`);
29+
doesUrlMatch(config.url, request.url)
30+
});
2731
const doesExcludeRuleMatches = !this.config.exclude
2832
? true
2933
: !this.config.exclude.some((config) => doesUrlMatch(config.url, request.url));

src/interceptor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import stream from 'stream';
33
import { constructURLFromHttpRequest } from './utils/proxy';
44
import responseDecoder from './response-decoder';
55
import parseHeader from 'parse-headers';
6+
import log from './logger';
67

78
function readBodyFromStream(writable: stream.Writable | undefined, callback: (value: any) => void) {
89
if (!writable) {

src/plugin.ts

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { BasePlugin } from 'appium/plugin';
22
import http from 'http';
33
import { Application } from 'express';
4-
import { CliArg, ISessionCapability, MockConfig, RequestInfo, SniffConfig } from './types';
4+
import { CliArg, ISessionCapability, MockConfig, RecordConfig, RequestInfo, ReplayConfig, SniffConfig } from './types';
55
import _ from 'lodash';
66
import { configureWifiProxy, isRealDevice } from './utils/adb';
77
import { cleanUpProxyServer, sanitizeMockConfig, setupProxyServer } from './utils/proxy';
@@ -40,6 +40,26 @@ export class AppiumInterceptorPlugin extends BasePlugin {
4040
command: 'stopListening',
4141
params: { optional: ['id'] },
4242
},
43+
44+
'interceptor: startRecording': {
45+
command: 'startRecording',
46+
params: { optional: ['config'] },
47+
},
48+
49+
'interceptor: stopRecording': {
50+
command: 'stopRecording',
51+
params: { optional: ['id'] },
52+
},
53+
54+
'interceptor: startReplaying': {
55+
command: 'startReplaying',
56+
params: { required: ['replayConfig'] },
57+
},
58+
59+
'interceptor: stopReplaying': {
60+
command: 'stopReplaying',
61+
params: { optional: ['id'] },
62+
},
4363
};
4464

4565
constructor(name: string, cliArgs: CliArg) {
@@ -77,6 +97,7 @@ export class AppiumInterceptorPlugin extends BasePlugin {
7797
await configureWifiProxy(adb, deviceUDID, realDevice, proxy);
7898
proxyCache.add(sessionId, proxy);
7999
}
100+
log.info("Creating session for appium interceptor");
80101
return response;
81102
}
82103

@@ -162,10 +183,53 @@ export class AppiumInterceptorPlugin extends BasePlugin {
162183
}
163184

164185
log.info(`Stopping listener with id: ${id}`);
165-
return proxy.removeSniffer(id);
186+
return proxy.removeSniffer(false, id);
187+
}
188+
189+
async startRecording(next: any, driver: any, config: SniffConfig): Promise<string> {
190+
const proxy = proxyCache.get(driver.sessionId);
191+
if (!proxy) {
192+
logger.error('Proxy is not running');
193+
throw new Error('Proxy is not active for current session');
194+
}
195+
196+
log.info(`Adding listener with config ${config}`);
197+
return proxy?.addSniffer(config);
198+
}
199+
200+
async stopRecording(next: any, driver: any, id: any): Promise<RecordConfig[]> {
201+
const proxy = proxyCache.get(driver.sessionId);
202+
if (!proxy) {
203+
logger.error('Proxy is not running');
204+
throw new Error('Proxy is not active for current session');
205+
}
206+
207+
log.info(`Stopping recording with id: ${id}`);
208+
return proxy.removeSniffer(true, id);
209+
}
210+
211+
async startReplaying(next:any, driver:any, replayConfig: ReplayConfig) {
212+
const proxy = proxyCache.get(driver.sessionId);
213+
if (!proxy) {
214+
logger.error('Proxy is not running');
215+
throw new Error('Proxy is not active for current session');
216+
}
217+
log.info('Starting replay traffic');
218+
proxy.startReplaying();
219+
return proxy.getRecordingManager().replayTraffic(replayConfig);
220+
}
221+
222+
async stopReplaying(next: any, driver:any, id:any) {
223+
const proxy = proxyCache.get(driver.sessionId);
224+
if (!proxy) {
225+
logger.error('Proxy is not running');
226+
throw new Error('Proxy is not active for current session');
227+
}
228+
log.info("Initiating stop replaying traffic");
229+
proxy.getRecordingManager().stopReplay(id);
166230
}
167231

168232
async execute(next: any, driver: any, script: any, args: any) {
169233
return await this.executeMethod(next, driver, script, args);
170234
}
171-
}
235+
}

src/proxy.ts

Lines changed: 48 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { MockConfig, RequestInfo, SniffConfig } from './types';
1+
import { MockConfig, RecordConfig, RequestInfo, SniffConfig } from './types';
22
import { Proxy as HttpProxy, IContext, IProxyOptions } from 'http-mitm-proxy';
33
import { v4 as uuid } from 'uuid';
44
import {
@@ -19,7 +19,8 @@ import { Mock } from './mock';
1919
import { RequestInterceptor } from './interceptor';
2020
import { ApiSniffer } from './api-sniffer';
2121
import _ from 'lodash';
22-
import logger from './logger';
22+
import log from './logger';
23+
import { RecordingManager } from './recording-manager';
2324

2425
export interface ProxyOptions {
2526
deviceUDID: string;
@@ -31,20 +32,35 @@ export interface ProxyOptions {
3132

3233
export class Proxy {
3334
private _started = false;
35+
private _replayStarted = false;
3436
private readonly mocks = new Map<string, Mock>();
3537
private readonly sniffers = new Map<string, ApiSniffer>();
3638

3739
private readonly httpProxy: HttpProxy;
40+
private readonly recordingManager: RecordingManager;
3841

3942
public isStarted(): boolean {
4043
return this._started;
4144
}
4245

46+
public isReplayStarted(): boolean {
47+
return this._replayStarted;
48+
}
49+
50+
public startReplaying(): void {
51+
this._replayStarted = true;
52+
}
53+
4354
constructor(private readonly options: ProxyOptions) {
4455
this.httpProxy = new HttpProxy();
56+
this.recordingManager = new RecordingManager(options);
4557
addDefaultMocks(this);
4658
}
4759

60+
public getRecordingManager(): RecordingManager {
61+
return this.recordingManager;
62+
}
63+
4864
public get port(): number {
4965
return this.options.port;
5066
}
@@ -81,7 +97,7 @@ export class Proxy {
8197
this.httpProxy.onRequest(this.handleMockApiRequest.bind(this));
8298

8399
this.httpProxy.onError((context, error, errorType) => {
84-
logger.error(`${errorType}: ${error}`);
100+
log.error(`${errorType}: ${error}`);
85101
});
86102

87103
await new Promise((resolve) => {
@@ -123,26 +139,37 @@ export class Proxy {
123139
return id;
124140
}
125141

126-
public removeSniffer(id?: string): RequestInfo[] {
127-
const _sniffers = [...this.sniffers.values()];
128-
if (id && !_.isNil(this.sniffers.get(id))) {
129-
_sniffers.push(this.sniffers.get(id)!);
142+
public removeSniffer(record: boolean, id?: string): RequestInfo[] {
143+
const _sniffers = [...this.sniffers.values()];
144+
if (id && !_.isNil(this.sniffers.get(id))) {
145+
_sniffers.push(this.sniffers.get(id)!);
146+
}
147+
let apiRequests;
148+
if (record) {
149+
apiRequests = this.recordingManager.getCapturedTraffic(_sniffers);
150+
}
151+
else {
152+
apiRequests = _sniffers.reduce((acc, sniffer) => {
153+
acc.push(...sniffer.getRequests());
154+
return acc;
155+
}, [] as RequestInfo[]);
156+
}
157+
_sniffers.forEach((sniffer) => this.sniffers.delete(sniffer.getId()));
158+
return apiRequests;
130159
}
131-
const apiRequests = _sniffers.reduce((acc, sniffer) => {
132-
acc.push(...sniffer.getRequests());
133-
return acc;
134-
}, [] as RequestInfo[]);
135-
_sniffers.forEach((sniffer) => this.sniffers.delete(sniffer.getId()));
136-
return apiRequests;
137-
}
138160

139161
private async handleMockApiRequest(ctx: IContext, next: () => void): Promise<void> {
140-
const matchedMocks = await this.findMatchingMocks(ctx);
141-
if (matchedMocks.length) {
142-
const compiledMock = compileMockConfig(matchedMocks);
143-
this.applyMockToRequest(ctx, compiledMock, next);
144-
} else {
145-
next();
162+
if (this.isReplayStarted()) {
163+
this.recordingManager.handleRecordingApiRequest(ctx, next);
164+
} else if (!this.isReplayStarted()) {
165+
const matchedMocks = await this.findMatchingMocks(ctx);
166+
if (matchedMocks.length) {
167+
const compiledMock = compileMockConfig(matchedMocks);
168+
this.applyMockToRequest(ctx, compiledMock, next);
169+
}
170+
else {
171+
next();
172+
}
146173
}
147174
}
148175

@@ -197,4 +224,4 @@ export class Proxy {
197224
next();
198225
}
199226
}
200-
}
227+
}

src/record.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { RecordConfig } from './types';
2+
3+
export class Record {
4+
private enabled = true;
5+
6+
constructor(private id: string, private config: RecordConfig) {}
7+
8+
getId() {
9+
return this.id;
10+
}
11+
12+
getConfig() {
13+
return this.config;
14+
}
15+
16+
isEnabled() {
17+
return this.enabled;
18+
}
19+
20+
setEnableStatus(enbaleStatus: boolean) {
21+
this.enabled = enbaleStatus;
22+
}
23+
24+
updateConfig(config: RecordConfig) {
25+
this.config = config;
26+
}
27+
}

0 commit comments

Comments
 (0)