Skip to content

Commit 1a2cc01

Browse files
Merge pull request #18 from AppiumTestDistribution/feature/sniff
Add functionality to listen network traffic during the session
2 parents 7bfbb9b + 094a3f7 commit 1a2cc01

File tree

9 files changed

+372
-16
lines changed

9 files changed

+372
-16
lines changed

docs/commands.md

Lines changed: 146 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ Add a new mock specification for intercepting and updating the request. The comm
4545
```javascript
4646
const authorizationMock = await driver.execute("interceptor: addMock", [{
4747
config: {
48-
url "*reqres.in*",
48+
url "**/reqres.in/**",
4949
headers: {
5050
"Authorization" : "Bearer bearertoken"
5151
}
@@ -54,7 +54,7 @@ Add a new mock specification for intercepting and updating the request. The comm
5454

5555
const userListGetMock = await driver.execute("interceptor: addMock", [{
5656
config: {
57-
url "**reqres.in/api/users",
57+
url "**/reqres.in/api/users",
5858
method: "GET",
5959
responseBody: JSON.stringify({
6060
page: 2,
@@ -84,7 +84,7 @@ Given a mockId return during addMock command, will remove the mock configuration
8484
```javascript
8585
const authorizationMock = await driver.execute("interceptor: addMock", [{
8686
config: {
87-
url "*reqres.in*",
87+
url "**/reqres.in/**",
8888
headers: {
8989
"Authorization" : "Bearer bearertoken"
9090
}
@@ -103,3 +103,146 @@ Given a mockId return during addMock command, will remove the mock configuration
103103
// authorizationMock will not be active after this point and the test will proceed with normal flow
104104
```
105105

106+
### interceptor: startListening
107+
108+
Start listening for all network traffic (API calls) made by the device during a session
109+
110+
#### Example:
111+
112+
```javascript
113+
await driver.execute("interceptor: startListening");
114+
// perform some action
115+
// ...
116+
```
117+
118+
It also supports filtering the request based on the url. `include` will only listents for requests that macthes the given url pattern and `exclude` will listen for all api's that doesn't match the url pattern.
119+
120+
```javascript
121+
await driver.execute("interceptor: startListening", [{
122+
config: {
123+
"include" : {
124+
url "**/reqres.in/**",
125+
}
126+
}
127+
}]);
128+
// perform some action
129+
// ...
130+
```
131+
132+
```javascript
133+
await driver.execute("interceptor: startListening", [{
134+
config: {
135+
"exclude" : {
136+
url "**/reqres.in/**",
137+
}
138+
}
139+
}]);
140+
// perform some action
141+
// ...
142+
```
143+
144+
### interceptor: stopListening
145+
146+
Stops listening for networks traffic and return all previously recorded api calls.
147+
148+
#### Example:
149+
150+
```javascript
151+
await driver.execute("interceptor: startListening");
152+
// perform some action
153+
// ...
154+
const apiRequests = await driver.execute("interceptor: stopListening");
155+
```
156+
157+
#### Returns:
158+
159+
stopListening command will retunrs an array of network details in the below JSON format
160+
161+
```javascript
162+
[
163+
{
164+
"requestBody": "",
165+
"requestHeaders": {
166+
"host": "reqres.in",
167+
"connection": "keep-alive",
168+
"content-length": "41",
169+
"sec-ch-ua": "\" Not;A Brand\";v=\"99\", \"Google Chrome\";v=\"91\", \"Chromium\";v=\"91\"",
170+
"sec-ch-ua-mobile": "?1",
171+
"user-agent": "Mozilla/5.0 (Linux; Android 12; sdk_gphone64_arm64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Mobile Safari/537.36",
172+
"content-type": "application/json",
173+
"accept": "*/*",
174+
"origin": "https://reqres.in",
175+
"sec-fetch-site": "same-origin",
176+
"sec-fetch-mode": "cors",
177+
"sec-fetch-dest": "empty",
178+
"referer": "https://reqres.in/",
179+
"accept-encoding": "gzip, deflate, br",
180+
"accept-language": "en-US,en;q=0.9",
181+
"cookie": "_gid=GA1.2.1828776619.1706164095; __stripe_mid=3d0fd295-9d68-4d75-bdb2-55b809fb49ed8dba35; __stripe_sid=466b9d3a-2d7b-4f48-9c66-ca859c8d342f06a86f; _gat=1; _gat_gtag_UA_174008107_1=1; _ga_CESXN06JTW=GS1.1.1706164096.1.1.1706166680.0.0.0; _ga=GA1.1.546181777.1706164095; _ga_WSM10MMEKC=GS1.2.1706164097.1.1.1706166681.0.0.0"
182+
},
183+
"url": "https://reqres.in/api/users/2",
184+
"method": "PUT",
185+
"responseBody": "{\"name\":\"morpheus\",\"job\":\"zion resident\",\"updatedAt\":\"2024-01-25T07:24:58.607Z\"}",
186+
"responseHeaders": {
187+
"http/1.1 200 ok": "HTTP/1.1 200 OK",
188+
"date": "Thu, 25 Jan 2024 07:24:58 GMT",
189+
"content-type": "application/json; charset=utf-8",
190+
"transfer-encoding": "chunked",
191+
"connection": "close",
192+
"report-to": "{\"group\":\"heroku-nel\",\"max_age\":3600,\"endpoints\":[{\"url\":\"https://nel.heroku.com/reports?ts=1706167498&sid=c4c9725f-1ab0-44d8-820f-430df2718e11&s=OTZf6wqjMxJtlD7uxpJC1eBUfbrlcO7RrUKeTeefoG0%3D\"}]}",
193+
"reporting-endpoints": "heroku-nel=https://nel.heroku.com/reports?ts=1706167498&sid=c4c9725f-1ab0-44d8-820f-430df2718e11&s=OTZf6wqjMxJtlD7uxpJC1eBUfbrlcO7RrUKeTeefoG0%3D",
194+
"nel": "{\"report_to\":\"heroku-nel\",\"max_age\":3600,\"success_fraction\":0.005,\"failure_fraction\":0.05,\"response_headers\":[\"Via\"]}",
195+
"x-powered-by": "Express",
196+
"access-control-allow-origin": "*",
197+
"etag": "W/\"50-XmcMaub9BFf/y9879X3p35X0L4c\"",
198+
"via": "1.1 vegur",
199+
"cf-cache-status": "DYNAMIC",
200+
"vary": "Accept-Encoding",
201+
"server": "cloudflare",
202+
"cf-ray": "84aec7909fed601c-SIN"
203+
},
204+
"statusCode": 200
205+
},
206+
{
207+
"requestBody": "",
208+
"requestHeaders": {
209+
"host": "reqres.in",
210+
"connection": "keep-alive",
211+
"sec-ch-ua": "\" Not;A Brand\";v=\"99\", \"Google Chrome\";v=\"91\", \"Chromium\";v=\"91\"",
212+
"sec-ch-ua-mobile": "?1",
213+
"user-agent": "Mozilla/5.0 (Linux; Android 12; sdk_gphone64_arm64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Mobile Safari/537.36",
214+
"content-type": "application/json",
215+
"accept": "*/*",
216+
"origin": "https://reqres.in",
217+
"sec-fetch-site": "same-origin",
218+
"sec-fetch-mode": "cors",
219+
"sec-fetch-dest": "empty",
220+
"referer": "https://reqres.in/",
221+
"accept-encoding": "gzip, deflate, br",
222+
"accept-language": "en-US,en;q=0.9",
223+
"cookie": "_gid=GA1.2.1828776619.1706164095; __stripe_mid=3d0fd295-9d68-4d75-bdb2-55b809fb49ed8dba35; __stripe_sid=466b9d3a-2d7b-4f48-9c66-ca859c8d342f06a86f; _gat=1; _gat_gtag_UA_174008107_1=1; _ga_CESXN06JTW=GS1.1.1706164096.1.1.1706166680.0.0.0; _ga=GA1.1.546181777.1706164095; _ga_WSM10MMEKC=GS1.2.1706164097.1.1.1706166681.0.0.0"
224+
},
225+
"url": "https://reqres.in/api/users/2",
226+
"method": "DELETE",
227+
"responseBody": "",
228+
"responseHeaders": {
229+
"http/1.1 204 no content": "HTTP/1.1 204 No Content",
230+
"date": "Thu, 25 Jan 2024 07:24:59 GMT",
231+
"content-length": "0",
232+
"connection": "close",
233+
"report-to": "{\"group\":\"heroku-nel\",\"max_age\":3600,\"endpoints\":[{\"url\":\"https://nel.heroku.com/reports?ts=1706167499&sid=c4c9725f-1ab0-44d8-820f-430df2718e11&s=GzTutDCgQC4QQ%2BomNat%2BqJScD%2BtwfgViqmG7fz6%2F9yk%3D\"}]}",
234+
"reporting-endpoints": "heroku-nel=https://nel.heroku.com/reports?ts=1706167499&sid=c4c9725f-1ab0-44d8-820f-430df2718e11&s=GzTutDCgQC4QQ%2BomNat%2BqJScD%2BtwfgViqmG7fz6%2F9yk%3D",
235+
"nel": "{\"report_to\":\"heroku-nel\",\"max_age\":3600,\"success_fraction\":0.005,\"failure_fraction\":0.05,\"response_headers\":[\"Via\"]}",
236+
"x-powered-by": "Express",
237+
"access-control-allow-origin": "*",
238+
"etag": "W/\"2-vyGp6PvFo4RvsFtPoIWeCReyIC8\"",
239+
"via": "1.1 vegur",
240+
"cf-cache-status": "DYNAMIC",
241+
"server": "cloudflare",
242+
"cf-ray": "84aec7977f6c9f77-SIN"
243+
},
244+
"statusCode": 204
245+
}
246+
]
247+
```
248+

package-lock.json

Lines changed: 8 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 & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "appium-interceptor",
3-
"version": "1.0.0-beta.5",
3+
"version": "1.0.0-beta.6",
44
"description": "Appium 2.0 plugin to mock api calls for android apps",
55
"main": "./lib/index.js",
66
"scripts": {
@@ -13,7 +13,7 @@
1313
"uninstall": "export APPIUM_HOME=/tmp/some-temp-dir && (appium plugin uninstall appium-interceptor || exit 0)",
1414
"install-plugin": "export APPIUM_HOME=/tmp/some-temp-dir && npm run build && appium plugin install --source=local $(pwd)",
1515
"reinstall-plugin": "export APPIUM_HOME=/tmp/some-temp-dir && (appium plugin uninstall appium-interceptor || exit 0) && npm run install-plugin",
16-
"run-server": "export APPIUM_HOME=/tmp/some-temp-dir && appium server -ka 800 --use-plugins=appium-interceptor -pa /wd/hub "
16+
"run-server": "export APPIUM_HOME=/tmp/some-temp-dir && appium server -ka 800 --use-plugins=appium-interceptor -pa /wd/hub"
1717
},
1818
"contributors": [
1919
{
@@ -110,6 +110,7 @@
110110
"jsonpath": "^1.1.1",
111111
"lodash": "^4.17.21",
112112
"minimatch": "^9.0.3",
113+
"parse-headers": "^2.0.5",
113114
"regex-parser": "^2.3.0",
114115
"uuid": "^9.0.1",
115116
"yargs": "^17.7.2",

src/api-sniffer.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { RequestInfo, SniffConfig } from './types';
2+
import { doesUrlMatch } from './utils/proxy';
3+
4+
export class ApiSniffer {
5+
private readonly requests: RequestInfo[] = [];
6+
7+
constructor(private id: string, private config: SniffConfig) {}
8+
9+
getId() {
10+
return this.id;
11+
}
12+
13+
onApiRequest(request: RequestInfo) {
14+
if (this.doesRequestMatchesConfig(request)) {
15+
this.requests.push(request);
16+
}
17+
}
18+
19+
getRequests() {
20+
return this.requests;
21+
}
22+
23+
private doesRequestMatchesConfig(request: RequestInfo) {
24+
const doesIncludeRuleMatches = !this.config.include
25+
? true
26+
: this.config.include.some((config) => doesUrlMatch(config.url, request.url));
27+
const doesExcludeRuleMatches = !this.config.exclude
28+
? true
29+
: !this.config.exclude.some((config) => doesUrlMatch(config.url, request.url));
30+
31+
return doesIncludeRuleMatches && doesExcludeRuleMatches;
32+
}
33+
}

src/interceptor.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { ErrorCallback, IContext } from 'http-mitm-proxy';
2+
import stream from 'stream';
3+
import { constructURLFromHttpRequest } from './utils/proxy';
4+
import responseDecoder from './response-decoder';
5+
import parseHeader from 'parse-headers';
6+
7+
function readBodyFromStream(writable: stream.Writable | undefined, callback: (value: any) => void) {
8+
if (!writable) {
9+
return callback(null);
10+
}
11+
const [originalWrite, originalEnd] = [writable.write, writable.end];
12+
const chunks: Buffer[] = [];
13+
14+
(writable.write as unknown) = function (...args: any) {
15+
chunks.push(typeof args[0] === 'string' ? Buffer.from(args[0]) : args[0]);
16+
originalWrite.apply(writable, args);
17+
};
18+
19+
(writable.end as unknown) = async function (...args: any) {
20+
if (args[0]) {
21+
chunks.push(typeof args[0] === 'string' ? Buffer.from(args[0]) : args[0]);
22+
}
23+
originalEnd.apply(writable, args);
24+
callback(Buffer.concat(chunks).toString('utf8'));
25+
};
26+
}
27+
28+
function RequestInterceptor(requestCompletionCallback: (value: any) => void) {
29+
return function (ctx: IContext, callback: ErrorCallback) {
30+
ctx.use(responseDecoder);
31+
const requestData = {} as any;
32+
ctx.onRequestEnd((ctx, callback) => {
33+
readBodyFromStream(ctx.proxyToServerRequest, (requestBody) => {
34+
requestData['requestBody'] = requestBody;
35+
requestData['requestHeaders'] = ctx.proxyToServerRequest?.getHeaders();
36+
});
37+
callback();
38+
});
39+
40+
readBodyFromStream(ctx.proxyToClientResponse, (response) => {
41+
const { headers, url } = ctx.clientToProxyRequest;
42+
const protocol = ctx.isSSL ? 'https://' : 'http://';
43+
const _url = constructURLFromHttpRequest({
44+
host: headers.host!,
45+
path: url!,
46+
protocol,
47+
});
48+
const responseHeaders = parseHeader((ctx.proxyToClientResponse as any)?._header || '');
49+
requestData['url'] = _url.toString();
50+
requestData['method'] = ctx.clientToProxyRequest.method;
51+
requestData['responseBody'] = response;
52+
requestData['responseHeaders'] = responseHeaders;
53+
requestData['statusCode'] = ctx.proxyToClientResponse.statusCode;
54+
55+
requestCompletionCallback(requestData);
56+
});
57+
callback();
58+
};
59+
}
60+
61+
export { RequestInterceptor };

0 commit comments

Comments
 (0)