diff --git a/package-lock.json b/package-lock.json index af2ad52e9..098f55179 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8554,6 +8554,45 @@ "typescript": ">=2.7" } }, + "node_modules/appium-xcuitest-driver/node_modules/@szmarczak/http-timer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", + "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", + "extraneous": true, + "dependencies": { + "defer-to-connect": "^2.0.1" + }, + "engines": { + "node": ">=14.16" + } + }, + "node_modules/appium-xcuitest-driver/node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "extraneous": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/appium-xcuitest-driver/node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "extraneous": true + }, + "node_modules/appium-xcuitest-driver/node_modules/@tsconfig/node10": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", + "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", + "extraneous": true + }, + "node_modules/appium-xcuitest-driver/node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "extraneous": true + }, "node_modules/appium-xcuitest-driver/node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", diff --git a/package.json b/package.json index 714da7424..747817e09 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "install-driver": "export APPIUM_HOME=/tmp/some-temp-dir && appium driver install xcuitest", "reinstall-plugin": "export APPIUM_HOME=/tmp/some-temp-dir && npm run appium-home && (appium plugin uninstall device-farm || exit 0) && npm run install-plugin", "run-server": "export APPIUM_HOME=/tmp/some-temp-dir && appium server -ka 800 --use-plugins=device-farm,appium-dashboard -pa /wd/hub --plugin-device-farm-platform=ios --plugin-device-farm-max-sessions=8", - "run-db-migration": "((path-exists lib/src/scripts/initialize-database.js || npm run build) && node lib/src/scripts/initialize-database.js) || exit 0", + "run-db-migration": "path-exists lib/src/scripts/initialize-database.js && node lib/src/scripts/initialize-database.js || ts-node src/scripts/initialize-database.ts", "generate-migration": "ts-node src/scripts/generate-database-migration.ts", "postinstall": "npm run run-db-migration" }, diff --git a/prisma/migrations/20231226115334_update_session_log/migration.sql b/prisma/migrations/20231226115334_update_session_log/migration.sql new file mode 100644 index 000000000..bf995add0 --- /dev/null +++ b/prisma/migrations/20231226115334_update_session_log/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "SessionLog" ADD COLUMN "is_success" BOOLEAN; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5a1da6686..ecf8d2a80 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -52,6 +52,7 @@ model SessionLog { body String? response String screenshot String? + is_success Boolean? // Timestamps createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/CapabilityManager.ts b/src/CapabilityManager.ts index 30a58c64d..ac813f724 100644 --- a/src/CapabilityManager.ts +++ b/src/CapabilityManager.ts @@ -10,6 +10,7 @@ export enum DEVICE_FARM_CAPABILITIES { VIDEO_RECORDING = 'record_video', VIDEO_RESOLUTION = 'video_resolution', LIVE_VIDEO = 'live_video', + SCREENSHOT_ON_FAILURE = 'screenshot_on_failure', DEVICE_FARM_OPTIONS = 'df:options', } diff --git a/src/app.ts b/src/app.ts deleted file mode 100644 index a69ee378d..000000000 --- a/src/app.ts +++ /dev/null @@ -1,210 +0,0 @@ -import express from 'express'; -import path from 'path'; -import log from './logger'; -import { ADTDatabase } from './data-service/db'; -import { getCLIArgs } from './data-service/pluginArgs'; -import cors from 'cors'; -import AsyncLock from 'async-lock'; -import axios from 'axios'; -import { - addNewDevice, - userBlockDevice, - getDevice, - removeDevice, - userUnblockDevice, -} from './data-service/device-service'; -import { prisma } from './prisma'; -import { MjpegProxy } from 'mjpeg-proxy'; -import { SESSION_MANAGER } from './sessions/SessionManager'; -import { config } from './config'; -import _ from 'lodash'; - -const asyncLock = new AsyncLock(), - serverUpTime = new Date().toISOString(); -let dashboardPluginUrl: any = null; - -const router = express.Router(), - apiRouter = express.Router(), - staticFilesRouter = express.Router(); - -const MJPEG_PROXY_CACHE: Map = new Map(); - -router.use(cors()); -apiRouter.use(cors()); -staticFilesRouter.use(cors()); - -/** - * Middleware to check if the appium-dashboard plugin is installed - * If the plugin is runnig, then we should enable the react app to - * open the dashboard link upon clicking the device card in the UI. - */ -apiRouter.use(async (req, res, next) => { - await asyncLock.acquire('dashboard-plugin-check', async () => { - if (dashboardPluginUrl == null) { - const pingurl = `${req.protocol}://${req.get('host')}/dashboard/api/ping`; - try { - const response: any = await axios.get(pingurl); - if (response.data['pong']) { - dashboardPluginUrl = `${req.protocol}://${req.get('host')}/dashboard`; - } else { - dashboardPluginUrl = ''; - } - } catch (err) { - dashboardPluginUrl = ''; - } - } - }); - (req as any)['dashboard-plugin-url'] = dashboardPluginUrl; - return next(); -}); - -apiRouter.get('/devices', async (req, res) => { - let devices = (await ADTDatabase.DeviceModel).find(); - if (req.query.sessionId) { - return res.json(devices.find((value) => value.session_id === req.query.sessionId)); - } - /* dashboard-plugin-url is the base url for opening the appium-dashboard-plugin - * This value will be attached to all express request via middleware - */ - const dashboardPluginUrl = (req as any)['dashboard-plugin-url']; - if (dashboardPluginUrl) { - const sessions = - (await axios.get(`${dashboardPluginUrl}/api/sessions?start_time=${serverUpTime}`)).data - ?.result?.rows || []; - const deviceSessionMap: any = {}; - sessions.forEach((session: any) => { - if (!deviceSessionMap[session.udid]) { - deviceSessionMap[session.udid] = []; - } - deviceSessionMap[session.udid].push(session); - }); - devices = devices.map((d) => { - d.dashboard_link = `${dashboardPluginUrl}?device_udid=${d.udid}&start_time=${serverUpTime}`; - d.total_session_count = deviceSessionMap[d.udid]?.length || 0; - return d; - }); - } - return res.json(devices); -}); - -apiRouter.get('/queues/length', async (req, res) => { - res.json((await ADTDatabase.PendingSessionsModel).chain().find().count()); -}); - -apiRouter.get('/queues', async (req, res) => { - res.json((await ADTDatabase.PendingSessionsModel).chain().find().data()); -}); - -apiRouter.get('/cliArgs', async (req, res) => { - res.json(await getCLIArgs()); -}); - -apiRouter.get('/devices/android', async (req, res) => { - res.json( - (await ADTDatabase.DeviceModel).find({ - platform: 'android', - }), - ); -}); - -apiRouter.post('/register', async (req, res) => { - const requestBody = req.body; - if (req.query.type === 'add') { - const addedDevices = await addNewDevice(requestBody); - if (addedDevices.length > 0) log.info(`Added new devices: ${JSON.stringify(addedDevices)}`); - } else if (req.query.type === 'remove') { - await removeDevice(requestBody); - } - res.json('200'); -}); - -apiRouter.post('/block', async (req, res) => { - const requestBody = req.body; - - const device = await getDevice(requestBody); - if (device != undefined) await userBlockDevice(device.udid, device.host); - - res.json('200'); -}); - -apiRouter.post('/unblock', async (req, res) => { - const requestBody = req.body; - - const device = await getDevice(requestBody); - if (device != undefined) await userUnblockDevice(device.udid, device.host); - - res.json('200'); -}); - -apiRouter.get('/devices/ios', async (req, res) => { - const devices = (await ADTDatabase.DeviceModel).find({ - platform: 'ios', - }); - if (req.query.deviceType === 'real') { - const realDevices = devices.filter((value) => value.deviceType === 'real'); - res.json(realDevices); - } else if (req.query.deviceType === 'simulated') { - const simulators = devices.filter((value) => value.deviceType === 'simulator'); - if (Object.hasOwn(req.query, 'booted')) { - res.json(simulators.filter((value) => value.state === 'Booted')); - } else { - res.json(simulators); - } - } else { - res.json(devices); - } -}); - -apiRouter.get('/session', async (req, res) => { - const buildId = req.query.buildId; - const sessions = await prisma.session.findMany({ - orderBy: { - createdAt: 'desc', - }, - where: { - build_id: buildId as any, - }, - }); - return res.status(200).json(sessions); -}); - -apiRouter.get('/build', async (req, res) => { - const builds = await prisma.build.findMany({ - orderBy: { - createdAt: 'desc', - }, - }); - return res.status(200).json(builds); -}); - -apiRouter.get('/session/:sessionId/live_video', async (req, res) => { - const sessionId = req.params.sessionId; - const session = SESSION_MANAGER.getSession(req.params.sessionId); - if (!session) { - return res.status(404).send({ - error: true, - message: `Sesssion with id ${sessionId} not found`, - }); - } - - const videoUrl = session.getLiveVideoUrl(); - if (videoUrl) { - if (!MJPEG_PROXY_CACHE.has(sessionId)) { - MJPEG_PROXY_CACHE.set(sessionId, new MjpegProxy(videoUrl)); - } - - MJPEG_PROXY_CACHE.get(sessionId)?.proxyRequest(req, res); - } else { - return res.status(500).send({ - error: true, - message: `Live video not available for session with id ${sessionId}`, - }); - } -}); - -staticFilesRouter.use(express.static(path.join(__dirname, '..', 'public'))); -router.use('/api', apiRouter); -router.use(staticFilesRouter); -router.use('/assets', express.static(config.sessionAssetsPath)); - -export { router }; diff --git a/src/app/index.ts b/src/app/index.ts new file mode 100644 index 000000000..99533b09e --- /dev/null +++ b/src/app/index.ts @@ -0,0 +1,65 @@ +import express from 'express'; +import path from 'path'; + +import { getCLIArgs } from '../data-service/pluginArgs'; +import cors from 'cors'; +import AsyncLock from 'async-lock'; +import axios from 'axios'; +import { config } from '../config'; +import _ from 'lodash'; + +import DashboardRouter from './routers/dashboard'; +import GridRouter from './routers/grid'; + +let dashboardPluginUrl: any = null; + +const ASYNC_LOCK = new AsyncLock(); + +const router = express.Router(), + apiRouter = express.Router(), + staticFilesRouter = express.Router(); + +router.use(cors()); +apiRouter.use(cors()); +staticFilesRouter.use(cors()); + +/** + * Middleware to check if the appium-dashboard plugin is installed + * If the plugin is runnig, then we should enable the react app to + * open the dashboard link upon clicking the device card in the UI. + */ + +//TODO: Remove the middleware after integrating with dashbaod +apiRouter.use(async (req, res, next) => { + await ASYNC_LOCK.acquire('dashboard-plugin-check', async () => { + if (dashboardPluginUrl == null) { + const pingurl = `${req.protocol}://${req.get('host')}/dashboard/api/ping`; + try { + const response: any = await axios.get(pingurl); + if (response.data['pong']) { + dashboardPluginUrl = `${req.protocol}://${req.get('host')}/dashboard`; + } else { + dashboardPluginUrl = ''; + } + } catch (err) { + dashboardPluginUrl = ''; + } + } + }); + (req as any)['dashboard-plugin-url'] = dashboardPluginUrl; + return next(); +}); + +apiRouter.get('/cliArgs', async (req, res) => { + res.json(await getCLIArgs()); +}); + +staticFilesRouter.use(express.static(path.join(__dirname, '..', '..', 'public'))); +router.use('/api', apiRouter); +router.use('/assets', express.static(config.sessionAssetsPath)); +router.use(staticFilesRouter); + +DashboardRouter.register(apiRouter); +GridRouter.register(apiRouter); + +export { router }; diff --git a/src/app/routers/dashboard.ts b/src/app/routers/dashboard.ts new file mode 100644 index 000000000..5072fb5d8 --- /dev/null +++ b/src/app/routers/dashboard.ts @@ -0,0 +1,92 @@ +import { Request, Response, Router, NextFunction } from 'express'; +import { prisma } from '../../prisma'; +import { SESSION_MANAGER } from '../../sessions/SessionManager'; +import { MjpegProxy } from 'mjpeg-proxy'; + +const MJPEG_PROXY_CACHE: Map = new Map(); + +//session gaurd +async function isValidSession(request: Request, response: Response, next: NextFunction) { + const sessionId = request.params.sessionId; + const session = await prisma.session.findFirst({ + where: { + id: sessionId, + }, + }); + if (!session) { + return response.status(404).send({ + error: true, + message: `Sesssion with id ${sessionId} not found`, + }); + } else { + return next(); + } +} + +async function getSessions(request: Request, response: Response) { + const buildId = request.query.buildId || undefined; + const sessions = await prisma.session.findMany({ + orderBy: { + createdAt: 'desc', + }, + where: { + build_id: buildId as any, + }, + }); + return response.status(200).json(sessions); +} + +async function getBuilds(request: Request, response: Response) { + const builds = await prisma.build.findMany({ + orderBy: { + createdAt: 'desc', + }, + }); + return response.status(200).json(builds); +} + +async function getSessionLogs(request: Request, response: Response) { + const sessionId = request.params.sessionId; + + const logs = await prisma.sessionLog.findMany({ + orderBy: { + createdAt: 'desc', + }, + where: { + session_id: sessionId, + }, + }); + return response.status(200).json(logs); +} + +async function streamLiveSessionVideo(request: Request, response: Response) { + const sessionId = request.params.sessionId; + const session = SESSION_MANAGER.getSession(sessionId); + + const videoUrl = session?.getLiveVideoUrl(); + if (videoUrl) { + if (!MJPEG_PROXY_CACHE.has(sessionId)) { + MJPEG_PROXY_CACHE.set(sessionId, new MjpegProxy(videoUrl)); + } + + MJPEG_PROXY_CACHE.get(sessionId)?.proxyRequest(request, response); + } else { + return response.status(500).send({ + error: true, + message: `Live video not available for session with id ${sessionId}`, + }); + } +} + +function register(router: Router) { + router.use('/session/:sessionId', isValidSession); + + router.get('/session', getSessions); + router.get('/build', getBuilds); + router.get('/session/:sessionId/live_video', streamLiveSessionVideo); + router.get('/session/:sessionId/session_log', getSessionLogs); +} + +export default { + register, +}; diff --git a/src/app/routers/grid.ts b/src/app/routers/grid.ts new file mode 100644 index 000000000..2c46c0f87 --- /dev/null +++ b/src/app/routers/grid.ts @@ -0,0 +1,125 @@ +import { Response, Request, Router } from 'express'; +import { ADTDatabase } from '../../data-service/db'; +import axios from 'axios'; +import _ from 'lodash'; +import { + addNewDevice, + userBlockDevice, + getDevice, + removeDevice, + userUnblockDevice, +} from '../../data-service/device-service'; +import log from '../../logger'; + +const SERVER_UP_TIME = new Date().toISOString(); + +async function getDevices(request: Request, response: Response) { + let devices = (await ADTDatabase.DeviceModel).find(); + const { sessionId } = request.query; + if (sessionId) { + return response.json(devices.find((value) => value.session_id === sessionId)); + } + /* dashboard-plugin-url is the base url for opening the appium-dashboard-plugin + * This value will be attached to all express request via middleware + */ + const dashboardPluginUrl = (request as any)['dashboard-plugin-url']; + if (dashboardPluginUrl) { + const sessions = + (await axios.get(`${dashboardPluginUrl}/api/sessions?start_time=${SERVER_UP_TIME}`)).data + ?.result?.rows || []; + const deviceSessionMap: any = {}; + sessions.forEach((session: any) => { + if (!deviceSessionMap[session.udid]) { + deviceSessionMap[session.udid] = []; + } + deviceSessionMap[session.udid].push(session); + }); + devices = devices.map((d) => { + d.dashboard_link = `${dashboardPluginUrl}?device_udid=${d.udid}&start_time=${SERVER_UP_TIME}`; + d.total_session_count = deviceSessionMap[d.udid]?.length || 0; + return d; + }); + } + return response.json(devices); +} + +async function getDeviceByPlatform(request: Request, response: Response) { + const { platform } = request.params; + const { deviceType, booted } = request.query; + if (!platform || ['ios', 'android'].indexOf(platform.toLowerCase()) < 0) { + return response.status(200).send([]); + } + let devices = (await ADTDatabase.DeviceModel).find({ + platform: platform.toLowerCase(), + }); + + if (!_.isNil(deviceType)) { + devices = devices.filter((value) => value.deviceType === deviceType); + } + + if (!_.isNil(booted)) { + devices = devices.filter((d) => d.state === 'Booted'); + } + + return response.status(200).send(devices); +} + +async function registerNode(request: Request, response: Response) { + const requestBody = request.body; + const { type } = request.query; + if (type === 'add') { + const addedDevices = await addNewDevice(requestBody); + if (addedDevices.length > 0) { + log.info(`Added new devices: ${JSON.stringify(addedDevices)}`); + } + } else if (type === 'remove') { + await removeDevice(requestBody); + } + response.status(200).send({ + success: true, + }); +} + +async function blockDevice(request: Request, response: Response) { + const requestBody = request.body; + const device = await getDevice(requestBody); + if (!_.isNil(device)) { + await userBlockDevice(device.udid, device.host); + } + response.status(200).send({ + success: true, + }); +} + +async function unBlockDevice(request: Request, response: Response) { + const requestBody = request.body; + const device = await getDevice(requestBody); + if (!_.isNil(device)) { + await userUnblockDevice(device.udid, device.host); + } + response.status(200).send({ + success: true, + }); +} + +async function getQueuedSessionLength(request: Request, response: Response) { + response.json((await ADTDatabase.PendingSessionsModel).chain().find().count()); +} + +async function getQueuedSessionRequests(request: Request, response: Response) { + response.json((await ADTDatabase.PendingSessionsModel).chain().find().data()); +} + +function register(router: Router) { + router.get('/device', getDevices); + router.get('/device/:platform', getDeviceByPlatform); + router.post('/register', registerNode); + router.post('/block', blockDevice); + router.post('/unblock', unBlockDevice); + router.get('/queue/length', getQueuedSessionLength); + router.get('/queue', getQueuedSessionRequests); +} + +export default { + register, +}; diff --git a/src/config.ts b/src/config.ts index d42c34eb3..05efc0098 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,6 +14,6 @@ export interface Config { export const config = { cacheDir: basePath, databasePath: `${basePath}/device-farm.db`, - sessionAssetsPath: path.join(basePath, 'session'), + sessionAssetsPath: path.join(basePath, 'assets', 'sessions'), takeScreenshotsFor: ['click', 'setUrl', 'setValue', 'performActions'], }; diff --git a/src/dashboard/asset-manager.ts b/src/dashboard/asset-manager.ts index 9eb33d4be..fc8461afe 100644 --- a/src/dashboard/asset-manager.ts +++ b/src/dashboard/asset-manager.ts @@ -12,7 +12,7 @@ export function prepareDirectory(sessionId: string) { }); } -export function saveScreenShot(sessionId: string, screenshotBase64String: string) { +export function saveScreenShot(sessionId: string, screenshotBase64String: string): string { const assetPath = path.join(sessionId, SCREENSHOT_DIRECTORY, `${uuidv4()}.jpg`); fs.writeFileSync( path.join(config.sessionAssetsPath, assetPath), diff --git a/src/dashboard/event-manager.ts b/src/dashboard/event-manager.ts index de0767af9..db5af739b 100644 --- a/src/dashboard/event-manager.ts +++ b/src/dashboard/event-manager.ts @@ -1,6 +1,5 @@ import { Request, Response } from 'express'; import { SESSION_MANAGER } from '../sessions/SessionManager'; -import { ISession } from '../interfaces/ISession'; import { IDevice } from '../interfaces/IDevice'; import { prisma } from '../prisma'; import log from '../logger'; @@ -15,10 +14,17 @@ import { safeParseJson } from '../helpers'; import { prepareDirectory, saveScreenShot, saveVideoRecording } from './asset-manager'; import { dashboardCommands } from './commands'; import { SessionStatus } from '../types/SessionStatus'; +import { SessionLog } from '@prisma/client'; +import { DeviceFarmSession } from '../sessions/DeviceFarmSession'; + export class DashboardEventManager { - private SCREENSHOT_FOR_COMMANDS = ['click', 'setUrl', 'setValue', 'performActions']; + // private SCREENSHOT_FOR_COMMANDS = ['click', 'setUrl', 'setValue', 'performActions']; - async onSessionStarted(capabilities: Record, session: ISession, device: IDevice) { + async onSessionStarted( + capabilities: Record, + session: DeviceFarmSession, + device: IDevice, + ) { const createOptions = { id: session.getId(), } as Record; @@ -59,7 +65,7 @@ export class DashboardEventManager { } async onSessionStoped(sessionId: string) { - const session: ISession | undefined = SESSION_MANAGER.getSession(sessionId); + const session: DeviceFarmSession | undefined = SESSION_MANAGER.getSession(sessionId); if (session) { const sessionEntry = await getSessionById(sessionId); const updateData: any = { @@ -79,7 +85,7 @@ export class DashboardEventManager { request: Request, response: Response, ): Promise { - const session: ISession | undefined = SESSION_MANAGER.getSession(sessionId); + const session: DeviceFarmSession | undefined = SESSION_MANAGER.getSession(sessionId); if (!session) { return false; @@ -114,22 +120,57 @@ export class DashboardEventManager { response: Response, responseBody: string, ) { - const session: ISession | undefined = SESSION_MANAGER.getSession(sessionId); + const session: DeviceFarmSession | undefined = SESSION_MANAGER.getSession(sessionId); if (session) { - const parsedBody: any = safeParseJson(responseBody) as any; + const parsedResponse: any = safeParseJson(responseBody) as any; const isSuccessResponse = - _.isObjectLike(parsedBody) && - (parsedBody.value === null || (parsedBody.value && !parsedBody.value.error)); - //console.log(new Date(), ` ↪ [${response.statusCode}]: ${responseBody}`); - let screenShotPath; - if (!isSuccessResponse || this.SCREENSHOT_FOR_COMMANDS.indexOf(commandName || '') >= 0) { + _.isObjectLike(parsedResponse) && + (parsedResponse.value === null || (parsedResponse.value && !parsedResponse.value.error)); + + const logEntry: Partial = { + session_id: session.getId(), + command_name: commandName || null, + body: JSON.stringify(request.body), + response: responseBody, + is_success: isSuccessResponse, + method: request.method, + title: this.getTitleFromCommandName(commandName), + subtitle: '', + screenshot: null, + url: request.originalUrl, + }; + + const screenShotCapability = session.getDeviceFarmOption( + DEVICE_FARM_CAPABILITIES.SCREENSHOT_ON_FAILURE, + false, + ); + const shouldTakeScreenshot = + !_.isNil(screenShotCapability) && screenShotCapability.toString() === 'true'; + + if ( + shouldTakeScreenshot && + !isSuccessResponse + // && this.SCREENSHOT_FOR_COMMANDS.indexOf(commandName || '') >= 0 + ) { const screenshot = await session.getScreenShot(); if (screenshot) { - screenShotPath = saveScreenShot(session.getId(), screenshot); + logEntry['screenshot'] = saveScreenShot(session.getId(), screenshot); } } - // Save the logs to DB + + await prisma.sessionLog.create({ + data: logEntry as SessionLog, + }); + } + } + + private getTitleFromCommandName(commandName: string | undefined) { + if (commandName) { + return commandName.replace(/([A-Z])/g, ' $1').replace(/^./, function (str: string) { + return str.toUpperCase(); + }); } + return undefined; } } diff --git a/src/interfaces/ISession.ts b/src/interfaces/ISession.ts deleted file mode 100644 index a66839a48..000000000 --- a/src/interfaces/ISession.ts +++ /dev/null @@ -1,12 +0,0 @@ -import SessionType from '../enums/SessionType'; - -export interface ISession { - getId(): string; - getCapabilities(): Record; - getScreenShot(): Promise; - stopVideoRecording(): Promise; - startVideoRecording(options?: { resolution: string }): Promise; - isVideoRecordingInProgress(): boolean; - getType(): SessionType; - getLiveVideoUrl(): string | null; -} diff --git a/src/plugin.ts b/src/plugin.ts index 713381996..665c135cc 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -63,12 +63,12 @@ import { SESSION_MANAGER } from './sessions/SessionManager'; import { LocalSession } from './sessions/LocalSession'; import { CloudSession } from './sessions/CloudSession'; import { RemoteSession } from './sessions/RemoteSession'; -import { ISession } from './interfaces/ISession'; import { DASHBORD_EVENT_MANAGER } from './dashboard/event-manager'; import { getDeviceFarmCapabilities } from './CapabilityManager'; import ip from 'ip'; import _ from 'lodash'; import SessionType from './enums/SessionType'; +import { DeviceFarmSession, DeviceFarmSessionOptions } from './sessions/DeviceFarmSession'; const commandsQueueGuard = new AsyncLock(); const DEVICE_MANAGER_LOCK_NAME = 'DeviceManager'; @@ -351,14 +351,29 @@ class DevicePlugin extends BasePlugin { addProxyHandler(sessionId, device.host); } - let sessionInstance: ISession; + let sessionInstance: DeviceFarmSession; + const sessionOptions: DeviceFarmSessionOptions = { + sessionId, + device, + sessionResponse, + deviceFarmOption: deviceFarmCapabilities, + }; const nodeWebdriverUrl = nodeUrl(device, DevicePlugin.nodeBasePath); if (device.nodeId === DevicePlugin.NODE_ID) { - sessionInstance = new LocalSession(sessionId, driver, device, sessionResponse); + sessionInstance = new LocalSession({ + ...sessionOptions, + driver, + }); } else if (device.hasOwnProperty('cloud')) { - sessionInstance = new CloudSession(sessionId, nodeWebdriverUrl, device, sessionResponse); + sessionInstance = new CloudSession({ + ...sessionOptions, + baseUrl: nodeWebdriverUrl, + }); } else { - sessionInstance = new RemoteSession(sessionId, nodeWebdriverUrl, device, sessionResponse); + sessionInstance = new RemoteSession({ + ...sessionOptions, + baseUrl: nodeWebdriverUrl, + }); } const isDashboardEnabled = !!this.pluginArgs.enableDashboard; diff --git a/src/prisma.ts b/src/prisma.ts index 3c50bcf82..f9542acfd 100644 --- a/src/prisma.ts +++ b/src/prisma.ts @@ -1,5 +1,6 @@ import { PrismaClient } from '@prisma/client'; import { config } from './config'; + export const prisma = new PrismaClient({ datasources: { db: { diff --git a/src/scripts/generate-database-migration.ts b/src/scripts/generate-database-migration.ts index b3f4d05a8..691239b74 100644 --- a/src/scripts/generate-database-migration.ts +++ b/src/scripts/generate-database-migration.ts @@ -6,7 +6,7 @@ async function main() { if (!fs.existsSync(config.cacheDir)) { fs.mkdirSync(config.cacheDir, { recursive: true }); } - execSync('prisma migrate dev', { + execSync(`prisma migrate dev ${process.argv.slice(2)}`, { env: { ...process.env, DATABASE_URL: `file:${config.databasePath}`, diff --git a/src/scripts/initialize-database.ts b/src/scripts/initialize-database.ts index 182d94b6f..7d74847f1 100644 --- a/src/scripts/initialize-database.ts +++ b/src/scripts/initialize-database.ts @@ -1,15 +1,24 @@ import { execSync } from 'node:child_process'; import { config } from '../config'; -async function main() { - const env = { - ...process.env, - DATABASE_URL: `file:${config.databasePath}`, - }; - execSync('prisma migrate deploy && prisma generate', { +const env = { + ...process.env, + DATABASE_URL: `file:${config.databasePath}`, +}; + +function executeCmd(cmd: string) { + execSync(cmd, { env, stdio: 'inherit', }); } +async function main() { + console.log('Deploying the database migrations'); + executeCmd('prisma migrate deploy'); + + console.log('Generating prims client'); + executeCmd('prisma generate'); +} + (async () => await main())(); diff --git a/src/sessions/CloudSession.ts b/src/sessions/CloudSession.ts index fd4440d75..1de39e440 100644 --- a/src/sessions/CloudSession.ts +++ b/src/sessions/CloudSession.ts @@ -6,10 +6,6 @@ export class CloudSession extends RemoteSession { return SessionType.CLOUD; } - getId(): string { - return this.sessionId; - } - getScreenShot(): Promise { throw new Error('Method not implemented.'); } diff --git a/src/sessions/DeviceFarmSession.ts b/src/sessions/DeviceFarmSession.ts new file mode 100644 index 000000000..3af8140c8 --- /dev/null +++ b/src/sessions/DeviceFarmSession.ts @@ -0,0 +1,51 @@ +import { DEVICE_FARM_CAPABILITIES } from '../CapabilityManager'; +import SessionType from '../enums/SessionType'; +import { IDevice } from '../interfaces/IDevice'; + +export type DeviceFarmSessionOptions = { + sessionId: string; + deviceFarmOption: Record; + device: IDevice; + sessionResponse: Record; +}; + +export abstract class DeviceFarmSession { + protected sessionId: string; + protected deviceFarmOption: Record; + + constructor(private options: DeviceFarmSessionOptions) { + this.sessionId = options.sessionId; + this.deviceFarmOption = options.deviceFarmOption; + } + + getId(): string { + return this.sessionId; + } + + getDeviefarmOptions(): Record { + return this.deviceFarmOption; + } + + getCapabilities(): Record { + return this.options.sessionResponse; + } + + getDeviceFarmOption( + option: DEVICE_FARM_CAPABILITIES, + defaultValue: any = undefined, + ): string | undefined { + return this.deviceFarmOption[option] ? this.deviceFarmOption[option] : defaultValue; + } + + abstract getScreenShot(): Promise; + + abstract stopVideoRecording(): Promise; + + abstract startVideoRecording(options?: { resolution: string } | undefined): Promise; + + abstract isVideoRecordingInProgress(): boolean; + + abstract getType(): SessionType; + + abstract getLiveVideoUrl(): string | null; +} diff --git a/src/sessions/LocalSession.ts b/src/sessions/LocalSession.ts index 1013a555b..e50e6dbe7 100644 --- a/src/sessions/LocalSession.ts +++ b/src/sessions/LocalSession.ts @@ -1,5 +1,5 @@ import SessionType from '../enums/SessionType'; -import { IDevice } from '../interfaces/IDevice'; +import { DeviceFarmSessionOptions } from './DeviceFarmSession'; import { RemoteSession } from './RemoteSession'; function constructBasePath(path: string) { @@ -15,37 +15,31 @@ function constructBasePath(path: string) { return `${path}/wd-internal`; } +export type LocalSessionOptions = DeviceFarmSessionOptions & { + driver: any; +}; + export class LocalSession extends RemoteSession { - constructor( - sessionId: string, - private driver: any, - device: IDevice, - sessionResponse: Record, - ) { - const { address, port, basePath } = driver.opts || driver; - super( - sessionId, - `http://${address}:${port}${constructBasePath(basePath)}`, - device, - sessionResponse, - ); + protected driver: any; + + constructor(options: LocalSessionOptions) { + const { address, port, basePath } = options.driver.opts || options.driver; + super({ + ...options, + baseUrl: `http://${address}:${port}${constructBasePath(basePath)}`, + }); + this.driver = options.driver; } getType(): SessionType { return SessionType.LOCAL; } - getId(): string { - return this.sessionId; - } - getLiveVideoUrl() { const { address } = this.driver.opts || this.driver; - if ( - this.sessionResponse['mjpegServerPort'] && - !isNaN(this.sessionResponse['mjpegServerPort']) - ) { - return `http://${address}:${this.sessionResponse['mjpegServerPort']}`; + const mjpegServerPort = this.getCapabilities()['mjpegServerPort']; + if (mjpegServerPort && !isNaN(mjpegServerPort)) { + return `http://${address}:${mjpegServerPort}`; } else { return null; } diff --git a/src/sessions/RemoteSession.ts b/src/sessions/RemoteSession.ts index e310436dc..7c7f39847 100644 --- a/src/sessions/RemoteSession.ts +++ b/src/sessions/RemoteSession.ts @@ -1,34 +1,28 @@ import axios from 'axios'; import SessionType from '../enums/SessionType'; -import { ISession } from '../interfaces/ISession'; -import { IDevice } from '../interfaces/IDevice'; +import { DeviceFarmSession, DeviceFarmSessionOptions } from './DeviceFarmSession'; -export class RemoteSession implements ISession { +export type RemoteSessionOptions = DeviceFarmSessionOptions & { + baseUrl: any; +}; + +export class RemoteSession extends DeviceFarmSession { private isVideoAvailable = false; + private baseUrl: string; - constructor( - protected sessionId: string, - protected baseUrl: string, - private device: IDevice, - protected sessionResponse: Record, - ) {} + constructor(options: RemoteSessionOptions) { + super(options); + this.baseUrl = options.baseUrl; + } isVideoRecordingInProgress(): boolean { return this.isVideoAvailable; } - getCapabilities(): Record { - return this.sessionResponse; - } - getType(): SessionType { return SessionType.REMOTE; } - getId(): string { - return this.sessionId; - } - getScreenShot(): Promise { return axios({ method: 'get', @@ -77,10 +71,8 @@ export class RemoteSession implements ISession { getLiveVideoUrl(): string | null { const url = new URL(this.baseUrl); - if ( - this.sessionResponse['mjpegServerPort'] && - !isNaN(this.sessionResponse['mjpegServerPort']) - ) { + const capabilities = this.getCapabilities(); + if (capabilities['mjpegServerPort'] && !isNaN(capabilities['mjpegServerPort'])) { return `${url.origin}/device-farm/api/session/${this.sessionId}/live_video`; } else { return null; diff --git a/src/sessions/SessionManager.ts b/src/sessions/SessionManager.ts index b363da0cc..c2e569a7a 100644 --- a/src/sessions/SessionManager.ts +++ b/src/sessions/SessionManager.ts @@ -1,8 +1,9 @@ -import { ISession } from '../interfaces/ISession'; +import { DeviceFarmSession } from './DeviceFarmSession'; + export class SessionManager { - private sessionMap: Map = new Map(); + private sessionMap: Map = new Map(); - addSession(sessionId: string, session: ISession) { + addSession(sessionId: string, session: DeviceFarmSession) { this.sessionMap.set(sessionId, session); } diff --git a/test/e2e/browserstack.spec.ts b/test/e2e/browserstack.spec.ts index 74d3478b8..70e1d3067 100644 --- a/test/e2e/browserstack.spec.ts +++ b/test/e2e/browserstack.spec.ts @@ -41,7 +41,7 @@ describe('Browserstack Devices', () => { const hub_url = `http://${ip.address()}:${HUB_APPIUM_PORT}`; it('Should be able to run the android with Browerstack config', async () => { - let androidDevices = (await axios.get(`${hub_url}/device-farm/api/devices/android`)).data; + let androidDevices = (await axios.get(`${hub_url}/device-farm/api/device/android`)).data; androidDevices = androidDevices.filter((device: IDevice) => device.cloud === 'browserstack'); delete androidDevices[0].meta; delete androidDevices[0]['$loki']; @@ -65,12 +65,12 @@ describe('Browserstack Devices', () => { }); it('Should be able to run the plugin with Browerstack config', async () => { - const status = (await axios.get(`${hub_url}/device-farm/api/devices`)).status; + const status = (await axios.get(`${hub_url}/device-farm/api/device`)).status; expect(status).to.be.eql(200); }); it('Should be able to get iOS devices from Browerstack config', async () => { - let iosDevices = (await axios.get(`${hub_url}/device-farm/api/devices/ios`)).data; + let iosDevices = (await axios.get(`${hub_url}/device-farm/api/device/ios`)).data; iosDevices = iosDevices.filter((device: IDevice) => device.cloud === 'browserstack'); delete iosDevices[0].meta; delete iosDevices[0]['$loki']; diff --git a/test/e2e/hubnode/forward-request.spec.ts b/test/e2e/hubnode/forward-request.spec.ts index 15cd3e45b..20e60f506 100644 --- a/test/e2e/hubnode/forward-request.spec.ts +++ b/test/e2e/hubnode/forward-request.spec.ts @@ -132,9 +132,7 @@ describe('E2E Forward Request', () => { // all devices const allDevices = ( - await axios.get( - `http://${hub_config.bindHostOrIp}:${HUB_APPIUM_PORT}/device-farm/api/devices`, - ) + await axios.get(`http://${hub_config.bindHostOrIp}:${HUB_APPIUM_PORT}/device-farm/api/device`) ).data; // there should be at least one device @@ -155,9 +153,7 @@ describe('E2E Forward Request', () => { // busy device should be on the node const newAllDevices = ( - await axios.get( - `http://${hub_config.bindHostOrIp}:${HUB_APPIUM_PORT}/device-farm/api/devices`, - ) + await axios.get(`http://${hub_config.bindHostOrIp}:${HUB_APPIUM_PORT}/device-farm/api/device`) ).data; const busyDevice = newAllDevices.filter((device: any) => device.busy); @@ -174,9 +170,7 @@ describe('E2E Forward Request', () => { driver = await remote({ ...WDIO_PARAMS, capabilities } as Options.WebdriverIO); const allDevices = ( - await axios.get( - `http://${hub_config.bindHostOrIp}:${HUB_APPIUM_PORT}/device-farm/api/devices`, - ) + await axios.get(`http://${hub_config.bindHostOrIp}:${HUB_APPIUM_PORT}/device-farm/api/device`) ).data; const busyDevice = allDevices.filter((device: any) => device.busy); @@ -190,9 +184,7 @@ describe('E2E Forward Request', () => { // check lastCmdExecutedAt const newAllDevices = ( - await axios.get( - `http://${hub_config.bindHostOrIp}:${HUB_APPIUM_PORT}/device-farm/api/devices`, - ) + await axios.get(`http://${hub_config.bindHostOrIp}:${HUB_APPIUM_PORT}/device-farm/api/device`) ).data; const newBusyDevice = newAllDevices.filter( (device: any) => device.udid === busyDevice[0].udid && device.host === busyDevice[0].host, @@ -217,9 +209,7 @@ describe('E2E Forward Request', () => { driver = await remote({ ...WDIO_PARAMS, capabilities } as Options.WebdriverIO); const allDevices = ( - await axios.get( - `http://${hub_config.bindHostOrIp}:${HUB_APPIUM_PORT}/device-farm/api/devices`, - ) + await axios.get(`http://${hub_config.bindHostOrIp}:${HUB_APPIUM_PORT}/device-farm/api/device`) ).data; const busyDevice = allDevices.filter((device: any) => device.busy); @@ -235,9 +225,7 @@ describe('E2E Forward Request', () => { // check device status const newAllDevices = ( - await axios.get( - `http://${hub_config.bindHostOrIp}:${HUB_APPIUM_PORT}/device-farm/api/devices`, - ) + await axios.get(`http://${hub_config.bindHostOrIp}:${HUB_APPIUM_PORT}/device-farm/api/device`) ).data; const newBusyDevice = newAllDevices.filter( diff --git a/test/e2e/hubnode/hubnode.spec.ts b/test/e2e/hubnode/hubnode.spec.ts index 95548739c..71b3c34d2 100644 --- a/test/e2e/hubnode/hubnode.spec.ts +++ b/test/e2e/hubnode/hubnode.spec.ts @@ -106,8 +106,7 @@ describe('E2E Hub and Node', () => { it('should have devices on the hub', async () => { await waitForHubAndNode(); // check device-farm endpoint using axios - const res = await axios.get(`http://${APPIUM_HOST}:${HUB_APPIUM_PORT}/device-farm/api/devices`); - // const res = await axios.get(`http://${APPIUM_HOST}:${NODE_APPIUM_PORT}/device-farm/api/devices`); + const res = await axios.get(`http://${APPIUM_HOST}:${HUB_APPIUM_PORT}/device-farm/api/device`); expect(res.status).to.equal(200); expect(res.data.length).to.be.greaterThan(0); // one of the devices should be an android device from the node @@ -171,9 +170,9 @@ describe('E2E Hub and Node', () => { remote({ ...WDIO_PARAMS, capabilities: nonExistentAppCapabilities } as Options.WebdriverIO), ).to.eventually.be.rejected; - // check device-farm endpoint using axios: /api/queues/length + // check device-farm endpoint using axios: /api/queue/length const res = await axios.get( - `http://${APPIUM_HOST}:${HUB_APPIUM_PORT}/device-farm/api/queues/length`, + `http://${APPIUM_HOST}:${HUB_APPIUM_PORT}/device-farm/api/queue/length`, ); expect(res.status).to.equal(200); expect(res.data).to.equal(0); diff --git a/test/e2e/pcloudy.spec.ts b/test/e2e/pcloudy.spec.ts index a56e76a7a..e24c9145e 100644 --- a/test/e2e/pcloudy.spec.ts +++ b/test/e2e/pcloudy.spec.ts @@ -35,7 +35,7 @@ describe('PCloudy Devices', () => { const hub_url = `http://${ip.address()}:${HUB_APPIUM_PORT}`; it('Should be able to run the android with PCloudy config', async () => { - let androidDevices = (await axios.get(`${hub_url}/device-farm/api/devices/android`)).data; + let androidDevices = (await axios.get(`${hub_url}/device-farm/api/device/android`)).data; androidDevices = androidDevices.filter((device: IDevice) => device.cloud === 'pCloudy'); delete androidDevices[0].meta; delete androidDevices[0]['$loki']; @@ -63,12 +63,12 @@ describe('PCloudy Devices', () => { }); it('Should be able to run the plugin with PCloudy config', async () => { - const status = (await axios.get(`${hub_url}/device-farm/api/devices`)).status; + const status = (await axios.get(`${hub_url}/device-farm/api/device`)).status; expect(status).to.be.eql(200); }); it('Should be able to get iOS devices from PCloudy config', async () => { - let iosDevices = (await axios.get(`${hub_url}/device-farm/api/devices/ios`)).data; + const iosDevices = (await axios.get(`${hub_url}/device-farm/api/device/ios`)).data; //console.log(JSON.stringify(iosDevices)); const cloudDevices = iosDevices.filter((device: IDevice) => device.cloud === 'pCloudy'); delete cloudDevices[0].meta; diff --git a/test/e2e/plugin.spec.ts b/test/e2e/plugin.spec.ts index 712f40ea2..b3a6e9c41 100644 --- a/test/e2e/plugin.spec.ts +++ b/test/e2e/plugin.spec.ts @@ -55,7 +55,7 @@ describe('Basic Plugin Test', () => { }); it('Basic Plugin API test', async () => { - (await axios.get(`${hub_url}/device-farm/api/devices`)).status.should.eql(200); + (await axios.get(`${hub_url}/device-farm/api/device`)).status.should.eql(200); }); it('Add Android devices from node to hub', async () => { @@ -80,18 +80,18 @@ describe('Basic Plugin Test', () => { ]; const nodeDevices = new NodeDevices(hub_url); await nodeDevices.postDevicesToHub(nodeAndroidDevice, 'add'); - const devices = (await axios.get(`${hub_url}/device-farm/api/devices`)).data; + const devices = (await axios.get(`${hub_url}/device-farm/api/device`)).data; devices.find((d: any) => d.udid === 'emulator-5551').should.to.be.an('object'); nodeAndroidDevice[0].udid = 'emulator-5552'; await nodeDevices.postDevicesToHub(nodeAndroidDevice, 'add'); - const updatedDeviceList = (await axios.get(`${hub_url}/device-farm/api/devices`)).data; + const updatedDeviceList = (await axios.get(`${hub_url}/device-farm/api/device`)).data; //updatedDeviceList.should.have.lengthOf(2); updatedDeviceList.find((d: any) => d.udid === 'emulator-5552').should.to.be.an('object'); }); it('Remove Android devices from node to hub', async () => { const nodeDevices = new NodeDevices(hub_url); - const devices = (await axios.get(`${hub_url}/device-farm/api/devices`)).data; + const devices = (await axios.get(`${hub_url}/device-farm/api/device`)).data; const exptectedDevice = devices.find((d: any) => d.udid === 'emulator-5551'); devices.find((d: any) => d.udid === 'emulator-5551').should.to.be.an('object'); console.log('devices', exptectedDevice); @@ -99,7 +99,7 @@ describe('Basic Plugin Test', () => { [{ udid: 'emulator-5551', host: '127.2.1.41' } as unknown as IDevice], 'remove', ); - const updatedDeviceList = (await axios.get(`${hub_url}/device-farm/api/devices`)).data; + const updatedDeviceList = (await axios.get(`${hub_url}/device-farm/api/device`)).data; const find = updatedDeviceList.find((d: any) => d.udid === 'emulator-5551'); expect(find).to.be.undefined; }); diff --git a/web/src/api-service/api-client.ts b/web/src/api-service/api-client.ts index aed64f512..df65975fb 100644 --- a/web/src/api-service/api-client.ts +++ b/web/src/api-service/api-client.ts @@ -18,7 +18,7 @@ class ApiClient { } public formatUrl(url: string) { - return `/device-farm/api${url}`; + return `${process.env.REACT_APP_API_BASE_URL || 'device-farm'}/api${url}`; } private jsonResult(res: any) { diff --git a/web/src/api-service/index.ts b/web/src/api-service/index.ts index d15692d63..0643484ca 100644 --- a/web/src/api-service/index.ts +++ b/web/src/api-service/index.ts @@ -2,15 +2,15 @@ import apiClient from './api-client'; export default class DeviceFarmApiService { public static getDevices() { - return apiClient.makeGETRequest('/devices', {}); + return apiClient.makeGETRequest('/device', {}); } public static getPendingSessionsCount() { - return apiClient.makeGETRequest('/queues/length', {}); + return apiClient.makeGETRequest('/queue/length', {}); } public static getPendingSessions() { - return apiClient.makeGETRequest('/queues', {}); + return apiClient.makeGETRequest('/queue', {}); } public static blockDevice(udid: string, host: string) {