Skip to content
This repository was archived by the owner on Jul 10, 2024. It is now read-only.

Commit 44229e2

Browse files
feat:provide ability to execute webdriver io script for via UI (#52)
* feat: Ability to execute webdriver io script for debugging the tests * feat:ability to view the live video in full screen
1 parent 1e411c8 commit 44229e2

30 files changed

+1881
-248
lines changed

package-lock.json

Lines changed: 472 additions & 135 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
@@ -28,7 +28,7 @@
2828
"mainClass": "AppiumDashboardPlugin"
2929
},
3030
"dependencies": {
31-
"@appium/base-plugin": "^1.8.0",
31+
"@appium/base-plugin": "^1.8.1",
3232
"@appium/support": "^2.55.4",
3333
"@ffmpeg-installer/ffmpeg": "^1.1.0",
3434
"appium-adb": "^9.0.0",
@@ -58,6 +58,8 @@
5858
"sqlite3": "^5.0.2",
5959
"typedi": "^0.10.0",
6060
"uuid": "^8.3.2",
61+
"vm2": "^3.9.8",
62+
"webdriverio": "^7.16.15",
6163
"winston": "^3.3.3"
6264
},
6365
"devDependencies": {

src/app/controllers/debug-controller.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,78 @@ import { NextFunction, Router, Request, Response } from "express";
22
import { EventEmitter } from "events";
33
import { BaseController } from "../commons/base-controller";
44
import { Config } from "../../config";
5+
import { defer } from "../utils/common-utils";
6+
import SessionDebugMap from "../../plugin/session-debug-map";
7+
import { Session } from "../../models";
58

69
export class DebugController extends BaseController {
710
constructor(private debugEventEmitter: EventEmitter) {
811
super();
912
}
1013

1114
public initializeRoutes(router: Router, config: Config) {
15+
router.use("/:sessionId/*", async (request, response, next) => {
16+
let { sessionId } = request.params;
17+
let session = await Session.findOne({
18+
where: {
19+
session_id: sessionId,
20+
},
21+
});
22+
if (!SessionDebugMap.get(sessionId) || !session) {
23+
return this.sendFailureResponse(response, "Invalid sessionid");
24+
}
25+
26+
if (session.is_completed) {
27+
return this.sendFailureResponse(response, "Cannot perform this operation for completed session");
28+
}
29+
return next();
30+
});
31+
router.post("/:sessionId/execute_driver_script", this.executeDriverScript.bind(this));
1232
router.post("/:sessionId/:state", this.changeSessionState.bind(this));
1333
}
1434

35+
private async triggerAndWaitForEvent(opts: { sessionId: string; eventObj: any }) {
36+
const deferred = defer();
37+
this.debugEventEmitter.emit(opts.sessionId, {
38+
...opts.eventObj,
39+
callback: deferred.resolve,
40+
});
41+
return await deferred.promise;
42+
}
43+
1544
public async changeSessionState(request: Request, response: Response, next: NextFunction) {
1645
let { sessionId, state } = request.params;
46+
1747
if (!state.match("play|pause")) {
1848
return this.sendFailureResponse(response, "Invalid state. Supported states are play,pause");
1949
}
20-
this.debugEventEmitter.emit(sessionId, {
21-
event: "change_state",
22-
state,
50+
51+
await this.triggerAndWaitForEvent({
52+
sessionId,
53+
eventObj: {
54+
event: "change_state",
55+
state,
56+
},
2357
});
58+
2459
return this.sendSuccessResponse(response, "Changed session state");
2560
}
61+
62+
public async executeDriverScript(request: Request, response: Response, next: NextFunction) {
63+
let { sessionId } = request.params;
64+
let { script } = request.body;
65+
if (!script) {
66+
return this.sendFailureResponse(response, "please provide a valid script to execute");
67+
}
68+
69+
let output = await this.triggerAndWaitForEvent({
70+
sessionId,
71+
eventObj: {
72+
event: "execute_driver_script",
73+
script,
74+
},
75+
});
76+
77+
return this.sendSuccessResponse(response, output);
78+
}
2679
}

src/app/controllers/session-controller.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export class SessionController extends BaseController {
2222
router.get("/:sessionId/logs/debug", this.getDebugLogs.bind(this));
2323
router.get("/:sessionId/profiling_data", this.getProfilingData.bind(this));
2424
router.get("/:sessionId/http_logs", this.getHttpLogs.bind(this));
25-
router.get("/:sessionId/live_video", this.getVideo.bind(this));
25+
router.get("/:sessionId/live_video", this.getLiveVideo.bind(this));
2626
}
2727

2828
public async getSessions(request: Request, response: Response, next: NextFunction) {
@@ -75,16 +75,42 @@ export class SessionController extends BaseController {
7575

7676
public async getVideoForSession(request: Request, response: Response, next: NextFunction) {
7777
let sessionId: string = request.params.sessionId;
78+
const range = request.headers.range;
7879
let session = await Session.findOne({
7980
where: {
8081
session_id: sessionId,
8182
},
8283
});
83-
if (session && session.video_path) {
84-
return response.status(200).sendFile(session.video_path);
84+
const videoPath = session?.video_path;
85+
86+
if (session && videoPath && range) {
87+
const videoSize = fs.statSync(videoPath).size;
88+
// Parse Range
89+
// Example: "bytes=32324-"
90+
const CHUNK_SIZE = 10 ** 6; // 1MB
91+
const start = Number(range.replace(/\D/g, ""));
92+
const end = Math.min(start + CHUNK_SIZE, videoSize - 1);
93+
94+
// Create headers
95+
const contentLength = end - start + 1;
96+
const headers = {
97+
"Content-Range": `bytes ${start}-${end}/${videoSize}`,
98+
"Accept-Ranges": "bytes",
99+
"Content-Length": contentLength,
100+
"Content-Type": "video/mp4",
101+
};
102+
103+
// HTTP Status 206 for Partial Content
104+
response.writeHead(206, headers);
105+
106+
// create video read stream for this particular chunk
107+
const videoStream = fs.createReadStream(videoPath, { start, end });
108+
109+
// Stream the video chunk to the client
110+
videoStream.pipe(response);
111+
} else {
112+
this.sendFailureResponse(response, "Video not available");
85113
}
86-
87-
this.sendFailureResponse(response, "Video not available");
88114
}
89115

90116
public async getTextLogs(request: Request, response: Response, next: NextFunction) {
@@ -166,7 +192,7 @@ export class SessionController extends BaseController {
166192
this.sendSuccessResponse(response, logs);
167193
}
168194

169-
public async getVideo(request: Request, response: Response, next: NextFunction) {
195+
public async getLiveVideo(request: Request, response: Response, next: NextFunction) {
170196
let sessionId: string = request.params.sessionId;
171197
let session = await Session.findOne({
172198
where: {
@@ -182,6 +208,10 @@ export class SessionController extends BaseController {
182208
const url = `${request.protocol}://${request.hostname}:${proxyPort}`;
183209
SessionController.mjpegProxyCache.set(proxyPort, new MjpegProxy(url));
184210
}
185-
SessionController.mjpegProxyCache.get(proxyPort)?.proxyRequest(request, response);
211+
try {
212+
SessionController.mjpegProxyCache.get(proxyPort)?.proxyRequest(request, response);
213+
} catch (e) {
214+
return this.sendFailureResponse(response, "Live video not available");
215+
}
186216
}
187217
}

src/app/utils/common-utils.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
11
import { filter } from "lodash";
22
import { Op, Sequelize } from "sequelize";
33

4-
export function parseSessionFilterParams(params: Record<string, string>) {
4+
function defer() {
5+
var resolve, reject;
6+
var promise = new Promise(function () {
7+
resolve = arguments[0];
8+
reject = arguments[1];
9+
});
10+
return {
11+
resolve: resolve,
12+
reject: reject,
13+
promise: promise,
14+
};
15+
}
16+
function parseSessionFilterParams(params: Record<string, string>) {
517
let { start_time, name, os, status, device_udid } = params;
618
let filters: any = [];
719
if (start_time) {
@@ -45,3 +57,5 @@ export function parseSessionFilterParams(params: Record<string, string>) {
4557
}
4658
return filters;
4759
}
60+
61+
export { defer, parseSessionFilterParams };

src/database-loader.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,25 @@
11
import { Sequelize } from "sequelize-typescript";
22
import * as models from "./models/index";
3-
import fs from "fs";
43
import * as path from "path";
4+
5+
async function sanitizeSessionsTable() {
6+
await models.Session.update(
7+
{
8+
session_status: "TIMEOUT",
9+
is_completed: true,
10+
end_time: new Date(),
11+
is_paused: false,
12+
is_profiling_available: false,
13+
is_http_logs_available: false,
14+
},
15+
{
16+
where: {
17+
is_completed: false,
18+
},
19+
}
20+
);
21+
}
22+
523
/**
624
* Intialize Sequelize object and load the database models.
725
*/
@@ -18,5 +36,6 @@ export let sequelizeLoader = async ({ dbPath }: { dbPath: string }): Promise<Seq
1836

1937
/* check whether the database connection is instantiated */
2038
await sequelize.authenticate();
39+
await sanitizeSessionsTable();
2140
return sequelize;
2241
};

src/plugin/debugger.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@ function isSessionPaused(sessionId: string) {
3232
}
3333

3434
async function handler(req: any, res: any, next: any) {
35-
if (!!req.query.internal || new RegExp(/dashboard\//).test(req.url)) {
35+
if (new RegExp(/wd-internal\//).test(req.url)) {
36+
req.url = req.originalUrl = req.url.replace("wd-internal/", "");
37+
return next();
38+
} else if (!!req.query.internal || new RegExp(/dashboard\//).test(req.url)) {
3639
return next();
3740
}
3841

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { SessionInfo } from "../../interfaces/session-info";
2+
import cp from "child_process";
3+
import { timing } from "@appium/support";
4+
import B from "bluebird";
5+
import { getWdioServerOpts } from "../utils/plugin-utils";
6+
7+
const childScript = require.resolve("./script.js");
8+
const DEFAULT_SCRIPT_TIMEOUT_MS = 1000 * 60 * 60; // default to 1 hour timeout
9+
10+
class DriverScriptExecutor {
11+
private driverOptions: any;
12+
13+
constructor(private sessionInfo: SessionInfo, private driver: any) {
14+
let { hostname, port, path } = getWdioServerOpts(driver);
15+
16+
this.driverOptions = {
17+
sessionId: sessionInfo.session_id,
18+
protocol: "http",
19+
hostname,
20+
port,
21+
path,
22+
isW3C: true,
23+
isMobile: true,
24+
capabilities: driver.caps,
25+
};
26+
}
27+
28+
public async execute({ script, timeoutMs = DEFAULT_SCRIPT_TIMEOUT_MS }: { script: string; timeoutMs?: number }) {
29+
const scriptProc = cp.fork(childScript);
30+
let timeoutCanceled = false;
31+
32+
try {
33+
const timer = new timing.Timer();
34+
timer.start();
35+
36+
const waitForResult = async () => {
37+
const res: any = await new B((res) => {
38+
scriptProc.on("message", res); // this is node IPC
39+
});
40+
41+
return res.data;
42+
};
43+
44+
const waitForTimeout = async () => {
45+
while (!timeoutCanceled && timer.getDuration().asMilliSeconds < timeoutMs) {
46+
await B.delay(500);
47+
}
48+
49+
if (timeoutCanceled) {
50+
return;
51+
}
52+
53+
throw new Error(
54+
`Execute driver script timed out after ${timeoutMs}ms. ` + `You can adjust this with the 'timeout' parameter.`
55+
);
56+
};
57+
58+
scriptProc.send({ driverOpts: this.driverOptions, script, timeoutMs });
59+
60+
// and set up a race between the response from the child and the timeout
61+
return await B.race([waitForResult(), waitForTimeout()]);
62+
} finally {
63+
// ensure we always cancel the timeout so that the timeout promise stops
64+
// spinning and allows this process to die gracefully
65+
timeoutCanceled = true;
66+
67+
if (scriptProc.connected) {
68+
scriptProc.disconnect();
69+
}
70+
71+
if (scriptProc.exitCode === null) {
72+
scriptProc.kill();
73+
}
74+
}
75+
}
76+
}
77+
78+
export { DriverScriptExecutor };

0 commit comments

Comments
 (0)