├── .github ├── labeler.yml ├── release-drafter-template.yml └── workflows │ ├── pr-labeler.yml │ └── release-drafter.yml ├── .gitignore ├── .npmignore ├── .prettierrc.json ├── .sequelizerc ├── README.md ├── assets ├── logo.png └── preview.png ├── migrations └── 001_create_table.js ├── package-lock.json ├── package.json ├── scripts ├── build_web.sh ├── cleanup-cache-dir.js └── is-prod.js ├── src ├── app │ ├── commons │ │ └── base-controller.ts │ ├── controllers │ │ ├── build-controller.ts │ │ ├── debug-controller.ts │ │ ├── index.ts │ │ ├── project-controller.ts │ │ └── session-controller.ts │ ├── index.ts │ ├── routes.ts │ └── utils │ │ ├── common-utils.ts │ │ └── express-utils.ts ├── config.ts ├── database-loader.ts ├── database-service.ts ├── index.ts ├── interfaces │ ├── PluginCliArgs.ts │ ├── appium-command.ts │ ├── custom-column-options.ts │ ├── express-controller.ts │ └── session-info.ts ├── loggers │ ├── logger.ts │ └── plugin-logger.ts ├── models │ ├── build.ts │ ├── command-logs.ts │ ├── http-logs.ts │ ├── index.ts │ ├── logs.ts │ ├── profiling.ts │ ├── project.ts │ └── session.ts ├── plugin │ ├── app-profiler │ │ └── android-app-profiler.ts │ ├── command-parser.ts │ ├── dashboard-commands.ts │ ├── debugger.ts │ ├── driver-command-executor.ts │ ├── http-logger │ │ ├── android-http-logger.ts │ │ ├── http-log-parser.ts │ │ └── ios-http-logger.ts │ ├── index.ts │ ├── interfaces │ │ └── http-logger.ts │ ├── locator-factory.ts │ ├── script-executor │ │ ├── executor.ts │ │ └── script.ts │ ├── session-debug-map.ts │ ├── session-manager.ts │ ├── session-timeout-tracker.ts │ └── utils │ │ ├── ios-utils.ts │ │ └── plugin-utils.ts └── utils.ts ├── tmp ├── cdp.js ├── command-parser.js ├── commands.js ├── ios-cdp.js ├── ios-network-profiler-backup.ts ├── sample responses.txt └── vm2.js ├── tsconfig.json ├── typings ├── appium-adb │ └── index.d.ts ├── appium-base-driver │ └── index.d.ts ├── appium-ios-device │ └── index.d.ts ├── appium-ios-simulator copy │ └── index.d.ts ├── appium-remote-debugger │ └── index.d.ts ├── appium-support │ └── index.d.ts ├── asyncbox │ └── index.d.ts ├── base-plugin │ └── index.d.ts ├── mjpeg-proxy │ └── index.d.ts └── node-fetch │ └── index.d.ts ├── web ├── .eslintrc.json ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── README.md ├── craco.config.js ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── manifest.json │ └── robots.txt ├── src │ ├── App-router.tsx │ ├── App.css │ ├── App.test.tsx │ ├── App.tsx │ ├── api │ │ ├── index.ts │ │ └── sessions.ts │ ├── assets │ │ ├── android.svg │ │ ├── ios.svg │ │ ├── ios.zip │ │ ├── lottie │ │ │ ├── arrow.json │ │ │ ├── spinner.json │ │ │ └── spinner_old.json │ │ └── safari.svg │ ├── components │ │ ├── UI │ │ │ ├── atoms │ │ │ │ ├── animation.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── checkbox.tsx │ │ │ │ ├── code-viewer.tsx │ │ │ │ ├── dropdown.tsx │ │ │ │ ├── icon.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── select.tsx │ │ │ │ ├── spinner.tsx │ │ │ │ ├── switch.tsx │ │ │ │ └── video-player.tsx │ │ │ ├── layouts │ │ │ │ ├── parallel-layout.tsx │ │ │ │ ├── serial-layout.tsx │ │ │ │ └── tab-layout.tsx │ │ │ ├── molecules │ │ │ │ ├── app-loader.tsx │ │ │ │ ├── centered.tsx │ │ │ │ ├── empty-message.tsx │ │ │ │ ├── header-logo.tsx │ │ │ │ └── overlay-parent.tsx │ │ │ └── organisms │ │ │ │ ├── app-header.tsx │ │ │ │ ├── http-log-details.tsx │ │ │ │ ├── http-logs-table.tsx │ │ │ │ ├── session-app-profiling.tsx │ │ │ │ ├── session-capability-details.tsx │ │ │ │ ├── session-card.tsx │ │ │ │ ├── session-debug-log-entry.tsx │ │ │ │ ├── session-debug-logs.tsx │ │ │ │ ├── session-details-menu-items.tsx │ │ │ │ ├── session-details.tsx │ │ │ │ ├── session-device-logs.tsx │ │ │ │ ├── session-http-logs.tsx │ │ │ │ ├── session-list-filter.tsx │ │ │ │ ├── session-list.tsx │ │ │ │ ├── session-logs.tsx │ │ │ │ ├── session-script-executor.tsx │ │ │ │ ├── session-summary.tsx │ │ │ │ ├── session-text-logs-entry.tsx │ │ │ │ ├── session-text-logs.tsx │ │ │ │ └── session-video.tsx │ │ ├── pages │ │ │ ├── dashboard.tsx │ │ │ └── page-not-found.tsx │ │ ├── route-reactive-component.tsx │ │ └── templates │ │ │ └── dashboard-template.tsx │ ├── constants │ │ ├── routes.ts │ │ ├── session.ts │ │ ├── themes.ts │ │ └── ui.ts │ ├── fonts │ │ └── bebasneue-regular.ttf │ ├── history.ts │ ├── index.css │ ├── index.tsx │ ├── interfaces │ │ ├── api.ts │ │ ├── common.tsx │ │ ├── filters.ts │ │ ├── redux.ts │ │ └── session.tsx │ ├── logo.svg │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ ├── setupTests.ts │ ├── store │ │ ├── actions │ │ │ ├── polling-actions.ts │ │ │ └── session-actions.ts │ │ ├── index.tsx │ │ ├── reducers │ │ │ ├── entities │ │ │ │ ├── index.ts │ │ │ │ ├── logs-reducer.ts │ │ │ │ └── sessions-reducer.ts │ │ │ ├── root-reducer.ts │ │ │ └── ui │ │ │ │ ├── app-initialised.tsx │ │ │ │ ├── filter-reducer.ts │ │ │ │ ├── index.ts │ │ │ │ ├── loaders.ts │ │ │ │ ├── selected-reducer.ts │ │ │ │ └── theme-reducer.ts │ │ ├── redux-action-types.ts │ │ ├── sagas │ │ │ ├── application-saga.ts │ │ │ ├── index.ts │ │ │ ├── polling-saga.ts │ │ │ └── session-saga.ts │ │ └── selectors │ │ │ ├── entities │ │ │ ├── logs-selector.ts │ │ │ └── sessions-selector.ts │ │ │ └── ui │ │ │ ├── filter-selector.ts │ │ │ └── theme-selector.ts │ └── utils │ │ ├── common-utils.ts │ │ ├── createReducer.ts │ │ ├── logger.ts │ │ └── ui.ts ├── tsconfig.json ├── typings │ └── video-react │ │ └── index.d.ts └── yarn.lock └── yarn.lock /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | labels: 3 | - label: "enhancement" 4 | title: 5 | - "^feat:.*" 6 | - label: "bug" 7 | title: 8 | - "^fix:.*" 9 | - label: "test" 10 | title: 11 | - "^test:.*" 12 | - label: "skip-changelog" 13 | title: 14 | - "^test:.*" 15 | -------------------------------------------------------------------------------- /.github/release-drafter-template.yml: -------------------------------------------------------------------------------- 1 | name-template: "Release v$RESOLVED_VERSION 🔥" 2 | tag-template: "v$RESOLVED_VERSION" 3 | categories: 4 | - title: "🚀 Features" 5 | labels: 6 | - "enhancement" 7 | - title: "🐛 Bug Fixes" 8 | labels: 9 | - "bug" 10 | 11 | # Only include the following labels in the release notes. All other labels are ignored. 12 | exclude-labels: 13 | - "skip-changelog" 14 | 15 | change-template: "- $TITLE (#$NUMBER) @$AUTHOR" 16 | 17 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 18 | 19 | version-resolver: 20 | major: 21 | labels: 22 | - "Major" 23 | minor: 24 | labels: 25 | - "Minor" 26 | patch: 27 | labels: 28 | - "Patch" 29 | default: patch 30 | 31 | template: | 32 | ## What's new? 33 | $CHANGES 34 | -------------------------------------------------------------------------------- /.github/workflows/pr-labeler.yml: -------------------------------------------------------------------------------- 1 | name: Label PRs based on title 2 | 3 | on: 4 | pull_request: 5 | branches: [release] 6 | types: [opened, reopened, edited] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: srvaroa/labeler@master 14 | env: 15 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 16 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - release 7 | # Only trigger if files have changed in this specific path 8 | paths: 9 | - "src/**" 10 | - "scripts/**" 11 | - "web/**" 12 | 13 | # pull_request event is required only for autolabeler 14 | # pull_request: 15 | # types: [opened, reopened, synchronize] 16 | # branches: [release] 17 | 18 | jobs: 19 | update_release_draft: 20 | runs-on: ubuntu-latest 21 | steps: 22 | # Drafts your next Release notes as Pull Requests are merged into "master" 23 | - uses: release-drafter/release-drafter@v5 24 | with: 25 | # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml 26 | config-name: release-drafter-template.yml 27 | disable-autolabeler: true 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .history 2 | node_modules 3 | videos 4 | database.sqlite 5 | lib 6 | log.txt 7 | sequelize-config.json 8 | .DS_Store 9 | 10 | # idea files 11 | .idea 12 | sequelize.log 13 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | web 3 | tmp 4 | node_modules 5 | sequelize-config.json 6 | .history 7 | .vscode 8 | .github 9 | assets -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120 3 | } 4 | -------------------------------------------------------------------------------- /.sequelizerc: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const fs = require("fs"); 3 | const config = require("./lib/config.js").config; 4 | 5 | let dbConfig = {}; 6 | ["development", "test", "production"].forEach((env) => { 7 | dbConfig[env] = { 8 | dialect: "sqlite", 9 | storage: path.join(config.databasePath, "database.sqlite"), 10 | }; 11 | }); 12 | fs.writeFileSync("sequelize-config.json", JSON.stringify(dbConfig, null, 2)); 13 | 14 | module.exports = { 15 | config: path.resolve("sequelize-config.json"), 16 | "migrations-path": path.resolve("migrations"), 17 | }; 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 🚨 Attention!!!! 2 | We have successfully integrated the dashboard with the [appium-device-farm](https://github.com/AppiumTestDistribution/appium-device-farm) plugin. As a result, I am now archiving this repository. I highly recommend that everyone begin using appium-device-farm from now on. For any new feature requests or bugs, please create an issue directly in the [device farm repository](https://github.com/AppiumTestDistribution/appium-device-farm/issues). Thank you everyone for your support!!! 3 | 4 |

5 | 6 |

7 | 8 |

9 | Appium plugin that provides complete test logs, video recording of test and device logs(logcat and syslogs) for easy debugging of tests. 10 |

11 | 12 | ## Features 13 | 14 | ### Complete Session Log: (IOS and Android) 15 | 16 | Monitor all webdriver session api calls made by the test will full request and response details. 17 | 18 | https://user-images.githubusercontent.com/20136913/153456977-6032ed52-2437-495c-afe6-6e02825354d0.mov 19 | 20 | ### Live video stream: (IOS and Android) 21 | 22 | Watch the live video of the test execution 23 | 24 | https://user-images.githubusercontent.com/20136913/153455978-26cb7820-bf03-47dc-a5c6-af26724efe2c.mp4 25 | 26 | ### Device Logs: (IOS and Android) 27 | 28 | View device logs from android(logcat) and Ios(syslogs) devices/emulators. 29 | 30 | ### App Profiling: (Android) 31 | 32 | Get insignts on the performance of the application by analysing the CPU and Memory usage during the test execution. 33 | 34 | ![Screenshot 2022-02-10 at 10 32 17 PM](https://user-images.githubusercontent.com/20136913/153458372-3d572b6a-04f1-4396-8440-667f6a6226e6.png) 35 | 36 | ### Network Logs: (Android) 37 | 38 | Monitor the network requests made by native/hybrid appilcation(inside WebView) and web based tests that runs on chrome browser 39 | 40 | https://user-images.githubusercontent.com/20136913/153460750-7dd49ef6-4451-464a-8084-f18a3d128b40.mov 41 | 42 | ### Execute webdriver.io scripts: (Android & IOS) 43 | 44 | Ability to run webdriver.io script on a running session for better debugging. 45 | 46 | https://user-images.githubusercontent.com/20136913/155574091-aedd55cc-63ef-4a9a-b659-e8f273a71674.mp4 47 | 48 | ### And lot more yet to come.... 49 | 50 | ## Installation 51 | 52 | This plugin requires Appium version 2.0. Once appium 2.0 is installed, run the bellow command to install the plugin 53 | 54 | ```sh 55 | appium plugin install --source=npm appium-dashboard 56 | ``` 57 | 58 | ## Plugin Activation 59 | 60 | Once the installion is done, the plugin needs to be activated each time when the appium server is started using below command 61 | 62 | ```sh 63 | appium --use-plugin=appium-dashboard 64 | ``` 65 | 66 | Now navigate to `http://localhost:4723/dashboard` to open the web app which will show the complete list of tests and its details that are being executed. 67 | 68 | NOTE: This plugin is still in beta phase and heavy testing is being done to eliminate all possible issues along with lot other new features. 69 | 70 | ## Custom capabilitis: 71 | 72 | | Name | Type | Description | Example | 73 | | ------------------------------ | ------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------- | 74 | | dashboard:name | string | Custom name for the session | `{"dashboard:name" : Sample login test }` | 75 | | dashboard:enableLiveVideo | boolean | if `true` live video of the execution will be streamed from the dashboard | defaults to `true` | 76 | | dashboard:enableVideoRecording | boolean | if `true`, video recording of the session can be viewd from the dashboard after the session is killed | defaults to `true` | 77 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sudharsan-selvaraj/appium-dashboard-plugin/f3e042b51e978a40c1db793c97920d5d3415b25d/assets/logo.png -------------------------------------------------------------------------------- /assets/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sudharsan-selvaraj/appium-dashboard-plugin/f3e042b51e978a40c1db793c97920d5d3415b25d/assets/preview.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "appium-dashboard", 3 | "version": "v2.0.3", 4 | "description": "Appium plugin to view session execution details via detailed dashboard", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "sequelize:migrate": "sequelize-cli db:migrate", 9 | "sequelize:migrate:undo": "sequelize-cli db:migrate:undo", 10 | "clean:cache": "node ./scripts/cleanup-cache-dir.js", 11 | "postinstall": "(node ./scripts/is-prod.js || tsc) && npm run sequelize:migrate", 12 | "i": "tsc && (appium plugin uninstall appium-dashboard || exit 0) && appium plugin install --source=local $(pwd)", 13 | "start-appium": "appium --use-plugins=appium-dashboard --relaxed-security" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://sudharsan-selvaraj@github.com/sudharsan-selvaraj/appium-dashboard-plugin.git" 18 | }, 19 | "keywords": [], 20 | "author": "", 21 | "license": "ISC", 22 | "bugs": { 23 | "url": "https://github.com/sudharsan-selvaraj/appium-dashboard-plugin/issues" 24 | }, 25 | "homepage": "https://github.com/sudharsan-selvaraj/appium-dashboard-plugin#readme", 26 | "appium": { 27 | "pluginName": "appium-dashboard", 28 | "mainClass": "AppiumDashboardPlugin" 29 | }, 30 | "dependencies": { 31 | "@appium/base-plugin": "^1.8.1", 32 | "@appium/support": "^2.55.4", 33 | "@ffmpeg-installer/ffmpeg": "^1.1.0", 34 | "appium-adb": "^9.0.0", 35 | "appium-base-driver": "^7.10.1", 36 | "appium-ios-device": "^2.0.0", 37 | "appium-ios-simulator": "^4.0.0", 38 | "appium-remote-debugger": "9.0.0", 39 | "async-lock": "^1.3.0", 40 | "async-wait-until": "^2.0.12", 41 | "asyncbox": "^2.9.2", 42 | "bluebird": "^3.7.2", 43 | "body-parser": "^1.19.0", 44 | "chrome-remote-interface": "^0.31.1", 45 | "circular-json": "^0.5.9", 46 | "cors": "^2.8.5", 47 | "debug": "^4.3.2", 48 | "express": "^4.17.1", 49 | "get-port": "^5.1.1", 50 | "http-status": "^1.5.0", 51 | "lodash": "^4.17.21", 52 | "lokijs": "^1.5.12", 53 | "mjpeg-proxy": "^0.3.0", 54 | "reflect-metadata": "^0.1.13", 55 | "sequelize": "^6.6.5", 56 | "sequelize-cli": "^6.2.0", 57 | "sequelize-typescript": "^2.1.0", 58 | "sqlite3": "^5.1.7", 59 | "typedi": "^0.10.0", 60 | "uuid": "^8.3.2", 61 | "vm2": "^3.9.8", 62 | "webdriverio": "^7.16.15", 63 | "winston": "^3.3.3" 64 | }, 65 | "devDependencies": { 66 | "@types/async-lock": "^1.1.3", 67 | "@types/bluebird": "^3.5.36", 68 | "@types/chrome-remote-interface": "^0.31.4", 69 | "@types/express": "^4.17.13", 70 | "@types/lodash": "^4.14.178", 71 | "@types/lokijs": "^1.5.7", 72 | "@types/node": "^16.10.2", 73 | "@types/node-fetch": "^3.0.3", 74 | "@types/teen_process": "^1.16.0", 75 | "@types/uuid": "^8.3.1", 76 | "typescript": "^4.4.4" 77 | }, 78 | "peerDependencies": { 79 | "appium": "^2.0.0-beta.46" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /scripts/build_web.sh: -------------------------------------------------------------------------------- 1 | cd ../web 2 | yarn install 3 | export REACT_APP_API_BASE_URL="/dashboard" 4 | yarn run build 5 | cd .. 6 | rm -rf lib/public 7 | mkdir -p lib/public 8 | cp -R ./web/build/ ./lib/public/ 9 | -------------------------------------------------------------------------------- /scripts/cleanup-cache-dir.js: -------------------------------------------------------------------------------- 1 | var fs = require("fs"); 2 | var path = require("path"); 3 | var config = require("../lib/config.js").config; 4 | 5 | if (fs.existsSync(config.cacheDir)) { 6 | fs.rmdirSync(config.cacheDir, { recursive: true }); 7 | } 8 | -------------------------------------------------------------------------------- /scripts/is-prod.js: -------------------------------------------------------------------------------- 1 | var fs = require("fs"); 2 | var path = require("path"); 3 | /* if the lib directory is present, then the package is installed as a appium plugin*/ 4 | if (fs.existsSync(path.resolve("lib"))) { 5 | process.exit(0); 6 | } else { 7 | process.exit(1); 8 | } 9 | -------------------------------------------------------------------------------- /src/app/commons/base-controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, Router } from "express"; 2 | import { Config } from "../../config"; 3 | import { IExpressController, IExpressRequest } from "../../interfaces/express-controller"; 4 | import { ExpressUtils } from "../utils/express-utils"; 5 | 6 | export abstract class BaseController implements IExpressController { 7 | abstract initializeRoutes(router: Router, config: Config): void; 8 | 9 | public sendPaginatedResponse(result: { rows: any[]; count: number }, request: IExpressRequest, response: Response) { 10 | if (request.parsedQuery.paginate == true) { 11 | response.status(200).send({ 12 | success: true, 13 | count: result.count, 14 | prev: this.getPreviousUrl(request, result.count), 15 | next: this.getNextUrl(request, result.count), 16 | result: result.rows, 17 | }); 18 | } else { 19 | this.sendSuccessResponse(response, result.rows); 20 | } 21 | } 22 | 23 | public sendSuccessResponse(response: Response, result: any, statusCode: number = 200) { 24 | response.status(statusCode).send({ 25 | success: true, 26 | result: result, 27 | }); 28 | } 29 | 30 | public sendFailureResponse(response: Response, result: any, statusCode: number = 500) { 31 | response.status(statusCode).send({ 32 | success: false, 33 | message: result, 34 | }); 35 | } 36 | 37 | private getNextUrl(request: Request, count: number): string | null { 38 | if (count <= parseInt(request.query.page as any) * parseInt(request.query.page_size as any)) { 39 | return null; 40 | } 41 | var nextPage = parseInt(request.query.page as any) + 1; 42 | return this.getPaginationApiUrl(request, nextPage); 43 | } 44 | 45 | private getPreviousUrl(request: Request, count: number): string | null { 46 | if (parseInt(request.query.page as any) == 1 || count == 0) { 47 | return null; 48 | } 49 | let prevPage = parseInt(request.query.page as any) - 1; 50 | if (count <= parseInt(request.query.page_size as any)) { 51 | prevPage = 1; 52 | } 53 | return this.getPaginationApiUrl(request, prevPage); 54 | } 55 | 56 | private getPaginationApiUrl = function (request: Request, page: number) { 57 | let baseUrl = ExpressUtils.getUrl(request), 58 | queryString = "?paginate=true"; 59 | for (let param in request.query) { 60 | if (request.query.hasOwnProperty(param)) { 61 | if (param == "page") { 62 | queryString = `${queryString}&page=${page}`; 63 | } else if (param != "paginate") { 64 | queryString = `${queryString}&${param}=${request.query[param]}`; 65 | } 66 | } 67 | } 68 | return baseUrl + queryString; 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /src/app/controllers/build-controller.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Router, Request, Response } from "express"; 2 | import { Op } from "sequelize"; 3 | import { BaseController } from "../commons/base-controller"; 4 | import { Build } from "../../models/build"; 5 | import { Session } from "../../models/session"; 6 | import { parseSessionFilterParams } from "../utils/common-utils"; 7 | import _ from "lodash"; 8 | import { Project } from "../../models"; 9 | 10 | export class BuildController extends BaseController { 11 | public initializeRoutes(router: Router, config: any) { 12 | router.get("/", this.getBuilds.bind(this)); 13 | router.get("/:builds/sessions", this.getSessionsForBuild.bind(this)); 14 | } 15 | 16 | public async getBuilds(request: Request, response: Response, next: NextFunction) { 17 | let { created_at, name } = request.query as any; 18 | let filter: any = {}; 19 | if (created_at) { 20 | filter.created_at = { [Op.gte]: new Date(created_at) }; 21 | } 22 | if (name) { 23 | filter.name = { 24 | [Op.like]: `%${name.trim()}%`, 25 | }; 26 | } 27 | let builds = await Build.findAndCountAll({ 28 | where: filter, 29 | include: [ 30 | { 31 | model: Session, 32 | as: "sessions", 33 | required: true, 34 | where: parseSessionFilterParams(_.pick(request.query as any, ["device_udid", "os"])), 35 | }, 36 | { 37 | model: Project, 38 | as: "project", 39 | }, 40 | ], 41 | order: [["updated_at", "DESC"]], 42 | }); 43 | builds.rows = JSON.parse(JSON.stringify(builds.rows)).map((build: any) => { 44 | let sessionInfo = { 45 | total: build.sessions.length, 46 | passed: build.sessions.filter((s: Session) => s.session_status?.toLowerCase() === "passed").length, 47 | running: build.sessions.filter((s: Session) => s.session_status?.toLowerCase() === "running").length, 48 | failed: build.sessions.filter((s: Session) => s.session_status?.toLowerCase() === "failed").length, 49 | timeout: build.sessions.filter((s: Session) => s.session_status?.toLowerCase() === "timeout").length, 50 | }; 51 | 52 | return _.assign( 53 | {}, 54 | { 55 | session: sessionInfo, 56 | project_name: build.project.name, 57 | } 58 | ); 59 | }) as any; 60 | this.sendSuccessResponse(response, builds); 61 | } 62 | 63 | public async getSessionsForBuild(request: Request, response: Response, next: NextFunction) { 64 | let buildId = request.params.builId; 65 | this.sendSuccessResponse( 66 | response, 67 | await Session.findAndCountAll({ 68 | where: { 69 | build_id: buildId, 70 | }, 71 | }) 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/app/controllers/debug-controller.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Router, Request, Response } from "express"; 2 | import { EventEmitter } from "events"; 3 | import { BaseController } from "../commons/base-controller"; 4 | import { Config } from "../../config"; 5 | import { defer } from "../utils/common-utils"; 6 | import SessionDebugMap from "../../plugin/session-debug-map"; 7 | import { Session } from "../../models"; 8 | 9 | export class DebugController extends BaseController { 10 | constructor(private debugEventEmitter: EventEmitter) { 11 | super(); 12 | } 13 | 14 | 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)); 32 | router.post("/:sessionId/:state", this.changeSessionState.bind(this)); 33 | } 34 | 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 | 44 | public async changeSessionState(request: Request, response: Response, next: NextFunction) { 45 | let { sessionId, state } = request.params; 46 | 47 | if (!state.match("play|pause")) { 48 | return this.sendFailureResponse(response, "Invalid state. Supported states are play,pause"); 49 | } 50 | 51 | await this.triggerAndWaitForEvent({ 52 | sessionId, 53 | eventObj: { 54 | event: "change_state", 55 | state, 56 | }, 57 | }); 58 | 59 | return this.sendSuccessResponse(response, "Changed session state"); 60 | } 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 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/app/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export { SessionController } from "./session-controller"; 2 | export { BuildController } from "./build-controller"; 3 | export { ProjectController } from "./project-controller"; 4 | export { DebugController } from "./debug-controller"; 5 | -------------------------------------------------------------------------------- /src/app/controllers/project-controller.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Router, Request, Response } from "express"; 2 | import { BaseController } from "../commons/base-controller"; 3 | import { Project } from "../../models/project"; 4 | 5 | export class ProjectController extends BaseController { 6 | public initializeRoutes(router: Router, config: any) { 7 | router.get("/", this.getProjects.bind(this)); 8 | } 9 | 10 | public async getProjects(request: Request, response: Response, next: NextFunction) { 11 | let projects = await Project.findAndCountAll({ 12 | order: [["created_at", "DESC"]], 13 | }); 14 | this.sendSuccessResponse(response, projects); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/app/index.ts: -------------------------------------------------------------------------------- 1 | import * as express from "express"; 2 | import bodyParser from "body-parser"; 3 | import * as path from "path"; 4 | const cors = require("cors"); 5 | import { registerRoutes } from "./routes"; 6 | import { Config } from "../config"; 7 | 8 | function getRouter({ config, dependencies }: { config: Config; dependencies: Record }) { 9 | let router = express.Router(); 10 | let apiRouter = express.Router(); 11 | router.use(bodyParser.json()); 12 | router.use(bodyParser.urlencoded({ extended: true })); 13 | router.use(cors()); 14 | apiRouter.use(cors()); 15 | 16 | /* Add routes */ 17 | 18 | router.use(express.static(path.join(__dirname, "../public"))); 19 | 20 | /* Healthcheck endpoint used by device-farm plugin to see if the dashboard plugin is loaded */ 21 | apiRouter.get("/ping", (req, res) => { 22 | res.status(200).send({ 23 | pong: true, 24 | }); 25 | }); 26 | 27 | registerRoutes(apiRouter, config, dependencies); 28 | 29 | router.use("/api", apiRouter); 30 | router.get("*", async (req: express.Request, res: express.Response, next: express.NextFunction) => { 31 | res.status(200).sendFile(path.join(__dirname, "../public/index.html")); 32 | }); 33 | 34 | return router; 35 | } 36 | export { getRouter }; 37 | -------------------------------------------------------------------------------- /src/app/routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { Config } from "../config"; 3 | import { IExpressController } from "../interfaces/express-controller"; 4 | import * as apiControllers from "./controllers/index"; 5 | 6 | export let registerRoutes = (apiRouter: Router, config: Config, dependencies: any) => { 7 | let controllers: [string, IExpressController][] = [ 8 | ["/sessions", new apiControllers.SessionController()], 9 | ["/builds", new apiControllers.BuildController()], 10 | ["/projects", new apiControllers.ProjectController()], 11 | ["/debug", new apiControllers.DebugController(dependencies.debugEventEmitter)], 12 | ]; 13 | 14 | for (let [path, controller] of controllers) { 15 | /* pass the routed to the controller which will map the internal routes with corresponding methods */ 16 | let route = Router(); 17 | controller.initializeRoutes(route, config); 18 | apiRouter.use(path, route); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/app/utils/common-utils.ts: -------------------------------------------------------------------------------- 1 | import { filter } from "lodash"; 2 | import { Op, Sequelize } from "sequelize"; 3 | 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) { 17 | let { start_time, name, os, status, device_udid } = params; 18 | let filters: any = []; 19 | if (start_time) { 20 | filters.push({ start_time: { [Op.gte]: new Date(start_time) } }); 21 | } 22 | if (name) { 23 | filters.push({ 24 | [Op.or]: [ 25 | { 26 | session_id: { 27 | [Op.like]: `%${name.trim()}%`, 28 | }, 29 | }, 30 | { 31 | name: { 32 | [Op.like]: `%${name.trim()}%`, 33 | }, 34 | }, 35 | ], 36 | }); 37 | } 38 | 39 | if (status) { 40 | filters.push({ 41 | session_status: { 42 | [Op.in]: status.split(",").map((entry: string) => entry.toUpperCase()), 43 | }, 44 | }); 45 | } 46 | 47 | if (device_udid) { 48 | filters.push(Sequelize.where(Sequelize.fn("LOWER", Sequelize.col("udid")), device_udid.toLowerCase())); 49 | } 50 | 51 | if (os) { 52 | filters.push({ 53 | platform_name: { 54 | [Op.in]: os.split(",").map((entry: string) => entry.toUpperCase()), 55 | }, 56 | }); 57 | } 58 | return filters; 59 | } 60 | 61 | export { defer, parseSessionFilterParams }; 62 | -------------------------------------------------------------------------------- /src/app/utils/express-utils.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "express"; 2 | 3 | export class ExpressUtils { 4 | public static getUrl(request: Request) { 5 | return request.protocol + "://" + request.get("host") + request.originalUrl.split("?")[0]; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import * as os from "os"; 2 | import * as path from "path"; 3 | let basePath = path.join(os.homedir(), ".cache", "appium-dashboard-plugin"); 4 | 5 | export interface Config { 6 | cacheDir: string; 7 | databasePath: string; 8 | videoSavePath: string; 9 | screenshotSavePath: string; 10 | logFilePath: string; 11 | takeScreenshotsFor: Array; 12 | } 13 | 14 | export let config = { 15 | cacheDir: basePath, 16 | databasePath: `${basePath}`, 17 | videoSavePath: path.join(basePath, "videos"), 18 | screenshotSavePath: path.join(basePath, "screen-shots"), 19 | logFilePath: path.join(basePath, "appium-dashboard-plugin.log"), 20 | takeScreenshotsFor: ["click", "setUrl", "setValue", "performActions"], 21 | }; 22 | -------------------------------------------------------------------------------- /src/database-loader.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize } from "sequelize-typescript"; 2 | import * as models from "./models/index"; 3 | 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 | 23 | /** 24 | * Intialize Sequelize object and load the database models. 25 | */ 26 | export let sequelizeLoader = async ({ dbPath }: { dbPath: string }): Promise => { 27 | const sequelize = new Sequelize({ 28 | dialect: "sqlite", 29 | storage: path.join(dbPath, "database.sqlite"), 30 | logging: false, 31 | /* add all models imported from models package */ 32 | models: Object.keys(models).map((modelName) => { 33 | return (models as any)[modelName]; 34 | }), 35 | }); 36 | 37 | /* check whether the database connection is instantiated */ 38 | await sequelize.authenticate(); 39 | await sanitizeSessionsTable(); 40 | return sequelize; 41 | }; 42 | -------------------------------------------------------------------------------- /src/database-service.ts: -------------------------------------------------------------------------------- 1 | import { Build, Project } from "./models"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | 4 | async function getOrCreateNewProject({ projectName }: { projectName: string }): Promise { 5 | let project = await Project.findOne({ 6 | where: { 7 | name: projectName, 8 | }, 9 | }); 10 | 11 | if (!project) { 12 | project = await Project.create({ 13 | name: projectName, 14 | } as any); 15 | } 16 | return project; 17 | } 18 | 19 | async function getOrCreateNewBuild({ 20 | buildName, 21 | projectId, 22 | }: { 23 | buildName: string; 24 | projectId?: number; 25 | }): Promise { 26 | let existingBuild = await Build.findOne({ 27 | where: { 28 | name: buildName, 29 | project_id: projectId || null, 30 | }, 31 | }); 32 | 33 | if (!existingBuild) { 34 | existingBuild = await Build.create({ 35 | name: buildName, 36 | build_id: uuidv4(), 37 | project_id: projectId || null, 38 | } as any); 39 | } 40 | return existingBuild; 41 | } 42 | 43 | export { getOrCreateNewProject, getOrCreateNewBuild }; 44 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Container } from "typedi"; 2 | import { sequelizeLoader } from "./database-loader"; 3 | import { getRouter } from "./app/index"; 4 | import { AppiumDashboardPlugin } from "./plugin"; 5 | import { config } from "./config"; 6 | import * as fs from "fs"; 7 | import { pluginLogger } from "./loggers/plugin-logger"; 8 | import { EventEmitter } from "events"; 9 | import ADB from "appium-adb"; 10 | 11 | const ffmpeg = require("@ffmpeg-installer/ffmpeg").path; 12 | const DebugEventNotifier = new EventEmitter(); 13 | 14 | async function createVideoDirectoryPath(fullPath: string) { 15 | if (!fs.existsSync(fullPath)) { 16 | fs.mkdirSync(fullPath, { recursive: true }); 17 | pluginLogger.info("Video directory created " + fullPath); 18 | } 19 | } 20 | 21 | Container.set("debugEventEmitter", DebugEventNotifier); 22 | Container.set("expressRouter", getRouter({ config, dependencies: { debugEventEmitter: DebugEventNotifier } })); 23 | Container.set("config", config); 24 | 25 | (async () => { 26 | //Create ADB instance 27 | let adb = null; 28 | try { 29 | adb = await ADB.createADB({}); 30 | } catch (err) { 31 | pluginLogger.error("Unable to create adb instance."); 32 | } 33 | 34 | Container.set("adb", adb); 35 | //Add FFMPEG to path 36 | process.env.PATH = process.env.PATH + ":" + ffmpeg.replace(/ffmpeg$/g, ""); 37 | 38 | //load sequelize database 39 | await sequelizeLoader({ dbPath: config.databasePath }); 40 | 41 | //create directory for videos 42 | await createVideoDirectoryPath(config.videoSavePath); 43 | })(); 44 | 45 | export { AppiumDashboardPlugin }; 46 | -------------------------------------------------------------------------------- /src/interfaces/PluginCliArgs.ts: -------------------------------------------------------------------------------- 1 | export interface PluginCliArgs { 2 | sessionTimeout: number; 3 | } 4 | -------------------------------------------------------------------------------- /src/interfaces/appium-command.ts: -------------------------------------------------------------------------------- 1 | export interface AppiumCommand { 2 | driver: any; 3 | commandName: string; 4 | args: Array; 5 | next: () => Promise; 6 | startTime?: Date; 7 | endTime?: Date; 8 | } 9 | -------------------------------------------------------------------------------- /src/interfaces/custom-column-options.ts: -------------------------------------------------------------------------------- 1 | export interface CustomColumnOption { 2 | json?: boolean; 3 | name: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/interfaces/express-controller.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request } from "express"; 2 | import { Config } from "../config"; 3 | 4 | export interface IExpressController { 5 | initializeRoutes(router: Router, config: Config): void; 6 | } 7 | 8 | export interface IExpressRequest extends Request { 9 | parsedQuery?: any; 10 | } 11 | -------------------------------------------------------------------------------- /src/interfaces/session-info.ts: -------------------------------------------------------------------------------- 1 | export interface SessionInfo { 2 | session_id: string; 3 | platform: string; 4 | platform_name: string; 5 | device_name: string; 6 | browser_name?: string; 7 | platform_version: string; 8 | automation_name: string; 9 | app: string; 10 | udid: string; 11 | capabilities: Record; 12 | is_completed?: boolean; 13 | } 14 | -------------------------------------------------------------------------------- /src/loggers/logger.ts: -------------------------------------------------------------------------------- 1 | import { config } from "../config"; 2 | const winston = require("winston"); 3 | 4 | const logger = winston.createLogger({ 5 | format: winston.format.combine( 6 | winston.format.timestamp(), 7 | winston.format.simple(), 8 | winston.format.printf(({ level, message, timestamp, label }: any) => { 9 | return `${level.toUpperCase()} : ${timestamp} ${message} `; 10 | }) 11 | ), 12 | transports: [new winston.transports.File({ filename: config.logFilePath })], 13 | }); 14 | 15 | export { logger }; 16 | -------------------------------------------------------------------------------- /src/loggers/plugin-logger.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "@appium/support"; 2 | const pluginLogger = logger.getLogger("appium-dashboard"); 3 | export { pluginLogger }; 4 | -------------------------------------------------------------------------------- /src/models/build.ts: -------------------------------------------------------------------------------- 1 | import { Model, Table, AllowNull, Column, HasMany, ForeignKey, BelongsTo } from "sequelize-typescript"; 2 | import { DataTypes } from "sequelize"; 3 | import { Project, Session } from "."; 4 | 5 | @Table({ 6 | tableName: "builds", 7 | timestamps: true, 8 | underscored: true, 9 | createdAt: "created_at", 10 | updatedAt: "updated_at", 11 | }) 12 | class Build extends Model { 13 | @Column({ 14 | primaryKey: true, 15 | autoIncrement: true, 16 | type: DataTypes.INTEGER, 17 | }) 18 | id!: number; 19 | 20 | @AllowNull(false) 21 | @Column({ 22 | type: DataTypes.STRING, 23 | unique: true, 24 | }) 25 | build_id!: string; 26 | 27 | @Column({ 28 | allowNull: true, 29 | type: DataTypes.INTEGER, 30 | }) 31 | @ForeignKey(() => Project) 32 | project_id!: number; 33 | 34 | @AllowNull(true) 35 | @Column({ 36 | type: DataTypes.STRING, 37 | }) 38 | name!: string; 39 | 40 | @HasMany(() => Session, { sourceKey: "build_id" }) 41 | sessions!: Session[]; 42 | 43 | @BelongsTo(() => Project) 44 | project!: Project; 45 | } 46 | 47 | export { Build }; 48 | -------------------------------------------------------------------------------- /src/models/command-logs.ts: -------------------------------------------------------------------------------- 1 | import { Model, Table, AllowNull, Column, ForeignKey, BelongsTo } from "sequelize-typescript"; 2 | import { DataTypes, Op } from "sequelize"; 3 | import { Session } from "./session"; 4 | import { customModelColumn } from "../utils"; 5 | 6 | @Table({ 7 | tableName: "command_logs", 8 | timestamps: true, 9 | underscored: true, 10 | createdAt: "created_at", 11 | updatedAt: "updated_at", 12 | }) 13 | class CommandLogs extends Model { 14 | @Column({ 15 | type: DataTypes.INTEGER, 16 | primaryKey: true, 17 | autoIncrement: true, 18 | }) 19 | log_id!: number; 20 | 21 | @AllowNull(false) 22 | @Column({ 23 | type: DataTypes.STRING, 24 | }) 25 | @ForeignKey(() => Session) 26 | session_id!: string; 27 | 28 | @Column({ 29 | type: DataTypes.STRING, 30 | }) 31 | command_name!: string; 32 | 33 | @Column({ 34 | type: DataTypes.STRING, 35 | }) 36 | title!: string; 37 | 38 | @Column({ 39 | type: DataTypes.STRING, 40 | }) 41 | title_info!: string; 42 | 43 | @Column({ 44 | type: DataTypes.STRING, 45 | ...customModelColumn({ name: "response", json: true }), 46 | }) 47 | response!: string; 48 | 49 | @Column({ 50 | type: DataTypes.STRING, 51 | ...customModelColumn({ name: "params", json: true }), 52 | }) 53 | params!: string; 54 | 55 | @Column({ 56 | type: DataTypes.BOOLEAN, 57 | defaultValue: false, 58 | }) 59 | is_error!: Boolean; 60 | 61 | @AllowNull(true) 62 | @Column({ 63 | type: DataTypes.TEXT, 64 | }) 65 | screen_shot?: string | null; 66 | 67 | @Column({ 68 | type: DataTypes.DATE, 69 | }) 70 | start_time?: Date; 71 | 72 | @AllowNull(true) 73 | @Column({ 74 | type: DataTypes.DATE, 75 | }) 76 | end_time?: Date; 77 | 78 | @BelongsTo(() => Session, { foreignKey: "session_id" }) 79 | session!: Session; 80 | } 81 | 82 | export { CommandLogs }; 83 | -------------------------------------------------------------------------------- /src/models/http-logs.ts: -------------------------------------------------------------------------------- 1 | import { Model, Table, AllowNull, Column, ForeignKey, BelongsTo } from "sequelize-typescript"; 2 | import { DataTypes, Op } from "sequelize"; 3 | import { Session } from "./session"; 4 | import { customModelColumn } from "../utils"; 5 | 6 | @Table({ 7 | tableName: "http_logs", 8 | timestamps: true, 9 | underscored: true, 10 | createdAt: "created_at", 11 | updatedAt: "updated_at", 12 | }) 13 | class HttpLogs extends Model { 14 | @Column({ 15 | type: DataTypes.INTEGER, 16 | primaryKey: true, 17 | autoIncrement: true, 18 | }) 19 | id!: number; 20 | 21 | @AllowNull(false) 22 | @Column({ 23 | type: DataTypes.STRING, 24 | }) 25 | @ForeignKey(() => Session) 26 | session_id!: string; 27 | 28 | @AllowNull(false) 29 | @Column({ 30 | type: DataTypes.STRING, 31 | }) 32 | url!: string; 33 | 34 | @AllowNull(false) 35 | @Column({ 36 | type: DataTypes.TEXT, 37 | }) 38 | method!: string; 39 | 40 | @AllowNull(false) 41 | @Column({ 42 | type: DataTypes.TEXT, 43 | ...customModelColumn({ name: "request_headers", json: true }), 44 | }) 45 | request_headers!: string; 46 | 47 | @Column({ 48 | type: DataTypes.TEXT, 49 | }) 50 | request_post_data!: string; 51 | 52 | @Column({ 53 | type: DataTypes.STRING, 54 | }) 55 | request_content_type!: string; 56 | 57 | @AllowNull(false) 58 | @Column({ 59 | type: DataTypes.TEXT, 60 | }) 61 | request_type?: string; 62 | 63 | @AllowNull(false) 64 | @Column({ 65 | type: DataTypes.TEXT, 66 | }) 67 | context?: string; 68 | 69 | @AllowNull(false) 70 | @Column({ 71 | type: DataTypes.INTEGER, 72 | }) 73 | response_status?: number; 74 | 75 | @AllowNull(true) 76 | @Column({ 77 | type: DataTypes.TEXT, 78 | }) 79 | response_status_text?: string; 80 | 81 | @AllowNull(false) 82 | @Column({ 83 | type: DataTypes.TEXT, 84 | ...customModelColumn({ name: "response_headers", json: true }), 85 | }) 86 | response_headers?: string; 87 | 88 | @AllowNull(true) 89 | @Column({ 90 | type: DataTypes.TEXT, 91 | }) 92 | response_content_type?: string; 93 | 94 | @AllowNull(true) 95 | @Column({ 96 | type: DataTypes.TEXT, 97 | }) 98 | response_body?: string; 99 | 100 | @AllowNull(true) 101 | @Column({ 102 | type: DataTypes.TEXT, 103 | }) 104 | remote_ip_address?: string; 105 | 106 | @Column({ 107 | type: DataTypes.DATE, 108 | }) 109 | start_time?: Date; 110 | 111 | @AllowNull(true) 112 | @Column({ 113 | type: DataTypes.DATE, 114 | }) 115 | end_time?: Date; 116 | 117 | @BelongsTo(() => Session, { foreignKey: "session_id" }) 118 | session!: Session; 119 | } 120 | 121 | export { HttpLogs }; 122 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | export { Session } from "./session"; 2 | export { Logs } from "./logs"; 3 | export { CommandLogs } from "./command-logs"; 4 | export { Build } from "./build"; 5 | export { Project } from "./project"; 6 | export { Profiling } from "./profiling"; 7 | export { HttpLogs } from "./http-logs"; 8 | -------------------------------------------------------------------------------- /src/models/logs.ts: -------------------------------------------------------------------------------- 1 | import { Model, Table, AllowNull, Column, ForeignKey, BelongsTo } from "sequelize-typescript"; 2 | import { DataTypes, Op } from "sequelize"; 3 | import { Session } from "./session"; 4 | import { customModelColumn } from "../utils"; 5 | 6 | @Table({ 7 | tableName: "logs", 8 | timestamps: true, 9 | underscored: true, 10 | createdAt: "created_at", 11 | updatedAt: "updated_at", 12 | }) 13 | class Logs extends Model { 14 | @Column({ 15 | type: DataTypes.INTEGER, 16 | primaryKey: true, 17 | autoIncrement: true, 18 | }) 19 | log_id!: number; 20 | 21 | @AllowNull(false) 22 | @Column({ 23 | type: DataTypes.STRING, 24 | }) 25 | @ForeignKey(() => Session) 26 | session_id!: string; 27 | 28 | @Column({ 29 | type: DataTypes.STRING, 30 | }) 31 | log_type!: string; 32 | 33 | @Column({ 34 | type: DataTypes.STRING, 35 | }) 36 | message!: string; 37 | 38 | @AllowNull(true) 39 | @Column({ 40 | type: DataTypes.STRING, 41 | ...customModelColumn({ name: "args", json: true }), 42 | }) 43 | args!: string; 44 | 45 | @Column({ 46 | type: DataTypes.DATE, 47 | }) 48 | timestamp!: Date; 49 | 50 | @BelongsTo(() => Session, { foreignKey: "session_id" }) 51 | session!: Session; 52 | } 53 | 54 | export { Logs }; 55 | -------------------------------------------------------------------------------- /src/models/profiling.ts: -------------------------------------------------------------------------------- 1 | import { Model, Table, AllowNull, Column, ForeignKey, BelongsTo } from "sequelize-typescript"; 2 | import { DataTypes, Op } from "sequelize"; 3 | import { Session } from "./session"; 4 | import { customModelColumn } from "../utils"; 5 | 6 | @Table({ 7 | tableName: "profiling", 8 | timestamps: true, 9 | underscored: true, 10 | createdAt: "created_at", 11 | updatedAt: "updated_at", 12 | }) 13 | class Profiling extends Model { 14 | @AllowNull(false) 15 | @Column({ 16 | type: DataTypes.STRING, 17 | }) 18 | @ForeignKey(() => Session) 19 | session_id!: string; 20 | 21 | @Column({ 22 | type: DataTypes.INTEGER, 23 | primaryKey: true, 24 | autoIncrement: true, 25 | }) 26 | id!: number; 27 | 28 | @Column({ 29 | type: DataTypes.STRING, 30 | }) 31 | cpu!: string; 32 | 33 | @Column({ 34 | type: DataTypes.STRING, 35 | }) 36 | memory!: string; 37 | 38 | @Column({ 39 | type: DataTypes.STRING, 40 | }) 41 | total_cpu_used!: string; 42 | 43 | @Column({ 44 | type: DataTypes.STRING, 45 | }) 46 | total_memory_used!: string; 47 | 48 | @Column({ 49 | type: DataTypes.STRING, 50 | }) 51 | raw_cpu_log!: string; 52 | 53 | @Column({ 54 | type: DataTypes.STRING, 55 | }) 56 | raw_memory_log!: string; 57 | 58 | @Column({ 59 | type: DataTypes.DATE, 60 | }) 61 | timestamp!: Date; 62 | 63 | @BelongsTo(() => Session, { foreignKey: "session_id" }) 64 | session!: Session; 65 | } 66 | 67 | export { Profiling }; 68 | -------------------------------------------------------------------------------- /src/models/project.ts: -------------------------------------------------------------------------------- 1 | import { Model, Table, AllowNull, Column, HasMany } from "sequelize-typescript"; 2 | import { DataTypes } from "sequelize"; 3 | import { Build, Session } from "."; 4 | 5 | @Table({ 6 | tableName: "projects", 7 | timestamps: true, 8 | underscored: true, 9 | createdAt: "created_at", 10 | updatedAt: "updated_at", 11 | }) 12 | class Project extends Model { 13 | @Column({ 14 | primaryKey: true, 15 | autoIncrement: true, 16 | type: DataTypes.INTEGER, 17 | }) 18 | id!: number; 19 | 20 | @AllowNull(true) 21 | @Column({ 22 | type: DataTypes.STRING, 23 | }) 24 | name!: string; 25 | 26 | @HasMany(() => Session) 27 | sessions!: Session[]; 28 | 29 | @HasMany(() => Build) 30 | builds!: Build[]; 31 | } 32 | 33 | export { Project }; 34 | -------------------------------------------------------------------------------- /src/models/session.ts: -------------------------------------------------------------------------------- 1 | import { Model, Table, AllowNull, Column, ForeignKey, HasMany, HasOne, BelongsTo } from "sequelize-typescript"; 2 | import { DataTypes, Op } from "sequelize"; 3 | import { customModelColumn } from "../utils"; 4 | import { Build } from "./build"; 5 | import { Project } from "."; 6 | 7 | @Table({ 8 | tableName: "session", 9 | timestamps: true, 10 | underscored: true, 11 | createdAt: "created_at", 12 | updatedAt: "updated_at", 13 | }) 14 | class Session extends Model { 15 | @Column({ 16 | primaryKey: true, 17 | autoIncrement: true, 18 | type: DataTypes.INTEGER, 19 | }) 20 | id!: number; 21 | 22 | @AllowNull(false) 23 | @Column({ 24 | type: DataTypes.STRING, 25 | unique: true, 26 | }) 27 | session_id!: string; 28 | 29 | @AllowNull(true) 30 | @Column({ 31 | type: DataTypes.STRING, 32 | }) 33 | @ForeignKey(() => Build) 34 | build_id!: string; 35 | 36 | @AllowNull(true) 37 | @Column({ 38 | type: DataTypes.INTEGER, 39 | }) 40 | @ForeignKey(() => Project) 41 | project_id!: number; 42 | 43 | @AllowNull(true) 44 | @Column({ 45 | type: DataTypes.STRING, 46 | }) 47 | name!: string; 48 | 49 | @AllowNull(false) 50 | @Column({ 51 | type: DataTypes.STRING, 52 | }) 53 | platform!: string; 54 | 55 | @AllowNull(false) 56 | @Column({ 57 | type: DataTypes.STRING, 58 | }) 59 | platform_name!: string; 60 | 61 | @AllowNull(false) 62 | @Column({ 63 | type: DataTypes.STRING, 64 | }) 65 | automation_name!: string; 66 | 67 | @AllowNull(false) 68 | @Column({ 69 | type: DataTypes.STRING, 70 | }) 71 | device_name!: string; 72 | 73 | @AllowNull(false) 74 | @Column({ 75 | type: DataTypes.STRING, 76 | }) 77 | platform_version!: string; 78 | 79 | @AllowNull(true) 80 | @Column({ 81 | type: DataTypes.STRING, 82 | }) 83 | app!: string; 84 | 85 | @AllowNull(true) 86 | @Column({ 87 | type: DataTypes.STRING, 88 | }) 89 | browser_name!: string; 90 | 91 | @AllowNull(true) 92 | @Column({ 93 | type: DataTypes.INTEGER, 94 | }) 95 | live_stream_port!: number; 96 | 97 | @AllowNull(false) 98 | @Column({ 99 | type: DataTypes.STRING, 100 | }) 101 | udid!: string; 102 | 103 | @AllowNull(false) 104 | @Column({ 105 | type: DataTypes.STRING, 106 | ...customModelColumn({ name: "capabilities", json: true }), 107 | }) 108 | capabilities!: any; 109 | 110 | @AllowNull(true) 111 | @Column({ 112 | type: DataTypes.STRING, 113 | ...customModelColumn({ name: "device_info", json: true }), 114 | }) 115 | device_info!: any; 116 | 117 | @AllowNull(false) 118 | @Column({ 119 | type: DataTypes.BOOLEAN, 120 | defaultValue: false, 121 | }) 122 | is_completed?: boolean; 123 | 124 | @AllowNull(false) 125 | @Column({ 126 | type: DataTypes.BOOLEAN, 127 | defaultValue: false, 128 | }) 129 | is_paused?: boolean; 130 | 131 | @AllowNull(false) 132 | @Column({ 133 | type: DataTypes.DATE, 134 | defaultValue: false, 135 | }) 136 | start_time!: Date; 137 | 138 | @AllowNull(true) 139 | @Column({ 140 | type: DataTypes.DATE, 141 | defaultValue: null, 142 | }) 143 | end_time?: Date; 144 | 145 | @AllowNull(true) 146 | @Column({ 147 | type: DataTypes.BOOLEAN, 148 | }) 149 | is_test_passed?: boolean; 150 | 151 | @AllowNull(false) 152 | @Column({ 153 | type: DataTypes.BOOLEAN, 154 | defaultValue: false, 155 | }) 156 | is_profiling_available?: boolean; 157 | 158 | @AllowNull(false) 159 | @Column({ 160 | type: DataTypes.BOOLEAN, 161 | defaultValue: false, 162 | }) 163 | is_http_logs_available?: boolean; 164 | 165 | @AllowNull(false) 166 | @Column({ 167 | type: DataTypes.ENUM, 168 | values: ["PASSED", "FAILED", "TIMEOUT", "RUNNING"], 169 | defaultValue: "RUNNING", 170 | }) 171 | session_status?: "PASSED" | "FAILED" | "TIMEOUT" | "RUNNING"; 172 | 173 | @AllowNull(true) 174 | @Column({ 175 | type: DataTypes.TEXT, 176 | }) 177 | video_path?: string | null; 178 | 179 | @AllowNull(true) 180 | @Column({ 181 | type: DataTypes.TEXT, 182 | }) 183 | session_status_message?: string; 184 | 185 | @BelongsTo(() => Build) 186 | build!: Build; 187 | 188 | @BelongsTo(() => Project) 189 | project!: Project; 190 | } 191 | 192 | export { Session }; 193 | -------------------------------------------------------------------------------- /src/plugin/dashboard-commands.ts: -------------------------------------------------------------------------------- 1 | import { Session, Logs } from "../models/index"; 2 | import { SessionInfo } from "../interfaces/session-info"; 3 | import { logger } from "../loggers/logger"; 4 | export class DashboardCommands { 5 | constructor(private sessionInfo: SessionInfo) {} 6 | 7 | /** 8 | * commandName: dashboard: setTestName 9 | */ 10 | private async setTestName(args: any[]): Promise { 11 | logger.info(`Updating test name for session ${args[0]}`); 12 | await Session.update( 13 | { 14 | name: args[0], 15 | }, 16 | { 17 | where: { session_id: this.sessionInfo.session_id }, 18 | } 19 | ); 20 | } 21 | 22 | /** 23 | * commandName: dashboard: debug 24 | */ 25 | private async debug(args: any[]): Promise { 26 | logger.info(`Adding debug logs for session ${this.sessionInfo.session_id}`); 27 | let props: any = args[0]; 28 | await Logs.create({ 29 | session_id: this.sessionInfo.session_id, 30 | log_type: "DEBUG", 31 | message: props.message, 32 | args: props.args || null, 33 | timestamp: new Date(), 34 | } as any); 35 | } 36 | 37 | /** 38 | * commandName: dashboard: updateStatus 39 | */ 40 | private async updateStatus(args: any[]): Promise { 41 | logger.info(`Updating test status for session ${this.sessionInfo.session_id}`); 42 | let props: any = args[0]; 43 | if (!props.status || !new RegExp(/passed|failed/g).test(props.status.toLowerCase())) { 44 | return; 45 | } 46 | await Session.update( 47 | { 48 | session_status_message: props.message, 49 | session_status: props.status.toUpperCase(), 50 | is_test_passed: props.status.toLowerCase() == "passed", 51 | }, 52 | { 53 | where: { session_id: this.sessionInfo.session_id }, 54 | } 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/plugin/debugger.ts: -------------------------------------------------------------------------------- 1 | import Asynclock from "async-lock"; 2 | import waitUntil from "async-wait-until"; 3 | import sessionDebugMap from "./session-debug-map"; 4 | 5 | const asyncLock = new Asynclock(); 6 | 7 | function getSessionIdFromUr(url: string) { 8 | const SESSION_ID_PATTERN = /\/session\/([^/]+)/; 9 | const match = SESSION_ID_PATTERN.exec(url); 10 | if (match) { 11 | return match[1]; 12 | } 13 | return null; 14 | } 15 | 16 | async function waitForSessionToResume(sessionId: string) { 17 | await asyncLock.acquire(`${sessionId}_debug`, async () => { 18 | await waitUntil( 19 | () => { 20 | return sessionDebugMap.get(sessionId)?.is_paused == false; 21 | }, 22 | { 23 | timeout: 300000, //5 minutes denoted in milliseconds 24 | intervalBetweenAttempts: 2000, 25 | } 26 | ); 27 | }); 28 | } 29 | 30 | function isSessionPaused(sessionId: string) { 31 | return sessionDebugMap.get(sessionId) && sessionDebugMap.get(sessionId)?.is_paused == true; 32 | } 33 | 34 | async function handler(req: any, res: any, next: any) { 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)) { 39 | return next(); 40 | } 41 | 42 | let sessionId = getSessionIdFromUr(req.url); 43 | 44 | if (sessionId && isSessionPaused(sessionId)) { 45 | await waitForSessionToResume(sessionId); 46 | } 47 | return next(); 48 | } 49 | 50 | function registerDebugMiddlware(expressApp: any) { 51 | let index = expressApp._router.stack.findIndex((s: any) => s.route); 52 | expressApp.use("/", handler); 53 | expressApp._router.stack.splice(index, 0, expressApp._router.stack.pop()); 54 | } 55 | 56 | export { registerDebugMiddlware }; 57 | -------------------------------------------------------------------------------- /src/plugin/driver-command-executor.ts: -------------------------------------------------------------------------------- 1 | import { makeGETCall, makePostCall, makeDELETECall } from "./utils/plugin-utils"; 2 | 3 | async function startScreenRecording(driver: any, sessionId: string, videoResolution: string) { 4 | let resolution = "1280:720"; 5 | let size = "1280x720"; 6 | if (videoResolution) { 7 | resolution = videoResolution.replace("x", ":"); 8 | size = videoResolution.replace(":", "x"); 9 | } 10 | return await makePostCall(driver, sessionId, "/appium/start_recording_screen", { 11 | options: { 12 | videoType: "libx264", 13 | videoFps: 10, 14 | /* Force iOS video scale to fix '[ffmpeg] [libx264 @ 0x7fda5f005280] width not divisible by 2 (1125x2436)' */ 15 | videoScale: resolution, 16 | /* Force Android size because some devices cannot record at their native resolution, resulting in error 'Unable to get output buffers (err=-38)' */ 17 | videoSize: size, 18 | /* In android, adb can record only 3 mins of video. below timeLimit is used to take longer video */ 19 | timeLimit: 1800, //in seconds (30 min) 20 | }, 21 | }); 22 | } 23 | 24 | async function takeScreenShot(driver: any, sessionId: string) { 25 | return await makeGETCall(driver, sessionId, "/screenshot"); 26 | } 27 | 28 | async function stopScreenRecording(driver: any, sessionId: string) { 29 | return await makePostCall(driver, sessionId, "/appium/stop_recording_screen", {}); 30 | } 31 | 32 | async function getLogTypes(driver: any, sessionId: string) { 33 | return await makeGETCall(driver, sessionId, "/log/types"); 34 | } 35 | 36 | async function terminateSession(driver: any, sessionId: string) { 37 | return await makeDELETECall(driver, sessionId, ""); 38 | } 39 | 40 | function getLogs(driver: any, sessionId: string, logType: string) { 41 | let session = driver, 42 | logKey: any = { 43 | uiautomator2: "uiautomator2.adb.logcat.logs", 44 | xcuitest: "logs.syslog.logs", 45 | }; 46 | if (driver.sessions) { 47 | session = driver.sessions[sessionId]; 48 | } 49 | 50 | let logs = logKey[session.caps.automationName.toLowerCase()]?.split(".").reduce((acc: any, k: any) => { 51 | return acc[k] || {}; 52 | }, session); 53 | 54 | return Array.isArray(logs) ? logs : []; 55 | } 56 | 57 | export { startScreenRecording, stopScreenRecording, getLogTypes, getLogs, takeScreenShot, terminateSession }; 58 | -------------------------------------------------------------------------------- /src/plugin/http-logger/android-http-logger.ts: -------------------------------------------------------------------------------- 1 | import { IHttpLogger } from "../interfaces/http-logger"; 2 | import _ from "lodash"; 3 | import { exec } from "teen_process"; 4 | import CDP, { Client } from "chrome-remote-interface"; 5 | import { pluginLogger } from "../../loggers/plugin-logger"; 6 | import { NetworkLogsParser } from "./http-log-parser"; 7 | import status from "http-status"; 8 | 9 | export type AndroidNetworkProfilerOptions = { 10 | udid: string; 11 | adb: any; 12 | isWebView?: boolean; 13 | webviewName?: string; 14 | }; 15 | 16 | class AndroidNetworkProfiler implements IHttpLogger { 17 | private started: boolean = false; 18 | private logs: Record = {}; 19 | private remoteDebugger!: Client; 20 | private logParser: NetworkLogsParser; 21 | 22 | constructor(private options: AndroidNetworkProfilerOptions) { 23 | this.logParser = new NetworkLogsParser(this.getContextName() || ""); 24 | } 25 | 26 | async start(): Promise { 27 | const { udid, adb, isWebView = false } = this.options; 28 | try { 29 | if (!this.started) { 30 | this.remoteDebugger = await this.getClient(adb.executable, udid, isWebView); 31 | await this.startListener(this.remoteDebugger); 32 | } 33 | } catch (err) { 34 | pluginLogger.error("Unable to initialize android network profiler"); 35 | pluginLogger.error(err); 36 | this.started = false; 37 | } 38 | } 39 | 40 | async stop(): Promise { 41 | if (this.remoteDebugger && this.started) { 42 | await this.remoteDebugger.close(); 43 | this.started = false; 44 | } 45 | } 46 | 47 | getLogs(): Array { 48 | return this.logParser.getLogs(); 49 | } 50 | 51 | private async startListener(remoteDebugger: Client) { 52 | const { Network } = remoteDebugger; 53 | 54 | Network.on("requestWillBeSent", this.logParser.onRequestRecieved.bind(this.logParser)); 55 | 56 | Network.on("responseReceived", async (params: any) => { 57 | if (params.type.toLowerCase() == "xhr") { 58 | try { 59 | let response = await Network.getResponseBody({ 60 | requestId: params.requestId, 61 | }); 62 | if (this.isResponseBodyValid(params.response.mimeType)) { 63 | params.responseBody = this.parseResponseBody(response); 64 | } 65 | params.response.statusText = !!params.response?.statusText 66 | ? params.response?.statusText 67 | : status[params.response.status]; 68 | } catch (err) {} 69 | } 70 | this.logParser.onResponseRecieved(params); 71 | }); 72 | await Network.enable({}); 73 | } 74 | 75 | private getContextName() { 76 | return this.options.isWebView ? this.options.webviewName : "browser"; 77 | } 78 | 79 | private async getClient(adb: any, udid: string, isWebView: boolean) { 80 | const debuggerPort = await this.getChromeDebuggerPort(adb, udid, isWebView); 81 | if (!!debuggerPort) { 82 | return await CDP({ 83 | local: true, 84 | port: debuggerPort, 85 | }); 86 | } else { 87 | throw new Error(`No debugging port found for device ${udid}`); 88 | } 89 | } 90 | 91 | private async getChromeDebuggerPort(adb: any, udid: string, isWebView: boolean) { 92 | const args = [...adb.defaultArgs, "-s", udid, "forward", "--list"]; 93 | let portSelector = isWebView ? "localabstract:webview_devtools_remote" : "localabstract:chrome_devtools_remote"; 94 | let portList = await exec(adb.path, args); 95 | pluginLogger.info(portList.stdout); 96 | let portEntry = portList.stdout 97 | .split("\n") 98 | .filter((portEntry) => portEntry.includes(portSelector)) 99 | .pop() 100 | ?.match(/tcp:([\d]{0,})/); 101 | return portEntry && portEntry.length > 1 ? parseInt(portEntry[1]) : null; 102 | } 103 | 104 | private isResponseBodyValid(responseType: string) { 105 | return !responseType.includes("javascript"); 106 | } 107 | 108 | private parseResponseBody(response: any) { 109 | return response.base64Encoded ? Buffer.from(response.body, "base64").toString("ascii") : response.body; 110 | } 111 | } 112 | 113 | export { AndroidNetworkProfiler }; 114 | -------------------------------------------------------------------------------- /src/plugin/http-logger/http-log-parser.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | 3 | class NetworkLogsParser { 4 | private logs: Record = {}; 5 | 6 | constructor(private context: string) {} 7 | 8 | public onRequestRecieved(event: any) { 9 | const { requestId } = event; 10 | this.logs[requestId] = { 11 | ...this.getParsedRequest(event), 12 | response_received: false, 13 | }; 14 | } 15 | 16 | public onResponseRecieved(event: any) { 17 | const { requestId } = event; 18 | if (this.logs[requestId]) { 19 | this.logs[requestId] = _.assign(this.logs[requestId], this.getParsedResponse(event), { 20 | response_received: true, 21 | }); 22 | } 23 | } 24 | 25 | public getLogs() { 26 | return Object.values(this.logs).filter((l) => l.response_received); 27 | } 28 | 29 | private getParsedRequest(event: any) { 30 | let { 31 | request: { url, method, headers: request_headers, postData: request_post_data }, 32 | type: request_type, 33 | } = event; 34 | return { 35 | url, 36 | method, 37 | request_headers, 38 | request_post_data, 39 | request_content_type: request_post_data ? request_headers["Content-Type"] : undefined, 40 | request_type, 41 | context: this.context, 42 | start_time: new Date(), 43 | }; 44 | } 45 | 46 | private getParsedResponse(event: any) { 47 | let { 48 | response: { 49 | status: response_status, 50 | statusText: response_status_text, 51 | headers: response_headers, 52 | mimeType: response_content_type, 53 | remoteIPAddress: remote_ip_address, 54 | }, 55 | responseBody: response_body, 56 | } = event; 57 | return { 58 | response_status, 59 | response_status_text, 60 | response_headers, 61 | response_content_type, 62 | response_body, 63 | remote_ip_address, 64 | end_time: new Date(), 65 | }; 66 | } 67 | } 68 | 69 | export { NetworkLogsParser }; 70 | -------------------------------------------------------------------------------- /src/plugin/http-logger/ios-http-logger.ts: -------------------------------------------------------------------------------- 1 | import { IHttpLogger } from "../interfaces/http-logger"; 2 | import { isRealDevice, getSimulator } from "../utils/ios-utils"; 3 | import { createRemoteDebugger } from "appium-remote-debugger"; 4 | import { retryInterval } from "asyncbox"; 5 | import _ from "lodash"; 6 | import { pluginLogger } from "../../loggers/plugin-logger"; 7 | import { logger } from "../../loggers/logger"; 8 | 9 | export type IosNetworkProfilerOptions = { 10 | udid: string; 11 | platformVersion: string; 12 | }; 13 | 14 | interface RemoteDebugger { 15 | connect(): Promise; 16 | appDict: any; 17 | setConnectionKey(): Promise; 18 | disconnect(): Promise; 19 | addClientEventListener(eventName: string, callback: (...args: any[]) => any): Promise; 20 | startTimeline(callback: (...args: any[]) => any): Promise; 21 | startNetwork(callback: (...args: any[]) => any): Promise; 22 | selectApp(args: any): Promise; 23 | selectPage(appId: string, pageId: string): Promise; 24 | } 25 | 26 | const SAFARI_BUNDLE_ID = "com.apple.mobilesafari"; 27 | 28 | /* TODO: Temporary solution. Need a better way to retrieve the socket path */ 29 | const DEFAULT_USBMUXD_SOCKET = "/var/run/usbmuxd"; 30 | const DEFAULT_USBMUXD_PORT = 27015; 31 | const DEFAULT_USBMUXD_HOST = "127.0.0.1"; 32 | 33 | const DEFAULT_DEBUGGER_OPTIONS = { 34 | bundleId: SAFARI_BUNDLE_ID, 35 | isSafari: true, 36 | useNewSafari: true, 37 | pageLoadMs: 1000, 38 | garbageCollectOnExecute: false, 39 | }; 40 | 41 | /** 42 | * TODO: Not working while appium session is active 43 | * Rewrite with new implementation 44 | */ 45 | class IosNetworkProfiler implements IHttpLogger { 46 | private started: boolean = false; 47 | private remoteDebugger!: RemoteDebugger; 48 | private logs: Record = {}; 49 | 50 | constructor(private options: IosNetworkProfilerOptions) {} 51 | 52 | async start(): Promise { 53 | const { udid, platformVersion } = this.options; 54 | if (!this.started) { 55 | this.remoteDebugger = await IosNetworkProfiler.getRemoteDebugger(udid, platformVersion); 56 | try { 57 | await IosNetworkProfiler.waitForRemoteDebugger(this.remoteDebugger); 58 | await this.initializeListeners(); 59 | this.started = true; 60 | } catch (err) { 61 | logger.error("Unable to capture network data for ios device"); 62 | logger.error(err); 63 | await this.stop(); 64 | } 65 | } 66 | } 67 | 68 | async stop(): Promise { 69 | if (this.started && this.remoteDebugger) { 70 | await this.remoteDebugger.disconnect(); 71 | this.started = false; 72 | } 73 | } 74 | 75 | getLogs(): Array { 76 | return []; 77 | } 78 | 79 | private async initializeListeners() { 80 | this.remoteDebugger.addClientEventListener("NetworkEvent", (err: any, event: any) => { 81 | pluginLogger.info(event); 82 | this.logs[event.requestId] = event; 83 | }); 84 | this.remoteDebugger.addClientEventListener("Network.responseReceived", async (err: any, event: any) => { 85 | pluginLogger.info(event); 86 | this.logs[event.requestId] = Object.assign({}, this.logs[event.requestId], { 87 | response: event, 88 | }); 89 | }); 90 | const page = _.find(await this.remoteDebugger.selectApp("http://0.0.0.0:4723/welcome"), (page) => { 91 | return page.url == "http://0.0.0.0:4723/welcome"; 92 | }); 93 | const [appIdKey, pageIdKey] = page.id.split(".").map((id: string) => parseInt(id, 10)); 94 | pluginLogger.info(`AppId: ${appIdKey} and pageIdKey: ${pageIdKey}`); 95 | //await this.remoteDebugger.selectPage(appIdKey, pageIdKey); 96 | } 97 | 98 | private static async waitForRemoteDebugger(remoteDebugger: RemoteDebugger) { 99 | await remoteDebugger.connect(); 100 | await retryInterval(30, 1000, async () => { 101 | if (!_.isEmpty(remoteDebugger.appDict)) { 102 | return remoteDebugger.appDict; 103 | } 104 | await remoteDebugger.setConnectionKey(); 105 | throw new Error("No apps connected"); 106 | }); 107 | } 108 | 109 | private static async getRemoteDebugger(deviceUUID: string, platformVersion: string) { 110 | if (await isRealDevice(deviceUUID)) { 111 | let options = { 112 | ...DEFAULT_DEBUGGER_OPTIONS, 113 | isSimulator: false, 114 | socketPath: DEFAULT_USBMUXD_SOCKET, 115 | uuid: deviceUUID, 116 | platformVersion, 117 | }; 118 | return createRemoteDebugger(options, true); 119 | } else { 120 | let simulator = await getSimulator(deviceUUID); 121 | let options = { 122 | ...DEFAULT_DEBUGGER_OPTIONS, 123 | isSimulator: true, 124 | socketPath: await simulator.getWebInspectorSocket(), 125 | uuid: deviceUUID, 126 | platformVersion, 127 | }; 128 | return createRemoteDebugger(options, false); 129 | } 130 | } 131 | } 132 | 133 | export { IosNetworkProfiler }; 134 | -------------------------------------------------------------------------------- /src/plugin/index.ts: -------------------------------------------------------------------------------- 1 | import { Container } from "typedi"; 2 | import BasePlugin from "@appium/base-plugin"; 3 | import { CommandParser } from "./command-parser"; 4 | import { SessionManager } from "./session-manager"; 5 | import { getSessionDetails, routeToCommand } from "./utils/plugin-utils"; 6 | import { pluginLogger } from "../loggers/plugin-logger"; 7 | import { logger } from "../loggers/logger"; 8 | import { PluginCliArgs } from "../interfaces/PluginCliArgs"; 9 | import * as express from "express"; 10 | import { registerDebugMiddlware } from "./debugger"; 11 | import _ from "lodash"; 12 | import getPort from "get-port"; 13 | 14 | const sessionMap: Map = new Map(); 15 | const IGNORED_COMMANDS = ["getScreenshot", "stopRecordingScreen", "startRecordingScreen"]; 16 | const CUSTOM_CAPABILITIES = [ 17 | "newCommandTimeout", 18 | "dashboard:project", 19 | "dashboard:build", 20 | "dashboard:name", 21 | "dashboard:videoResolution", 22 | "dashboard:enableLiveVideo", 23 | "dashboard:enableVideoRecording", 24 | ]; 25 | 26 | class AppiumDashboardPlugin extends BasePlugin { 27 | constructor(pluginName: string) { 28 | super(pluginName); 29 | } 30 | 31 | static get argsConstraints() { 32 | return { 33 | sessionTimeout: { 34 | isNumber: true, 35 | }, 36 | }; 37 | } 38 | 39 | public static async updateServer(expressApp: express.Application) { 40 | registerDebugMiddlware(expressApp); 41 | expressApp.use("/dashboard", Container.get("expressRouter") as any); 42 | pluginLogger.info("Dashboard plugin is enabled and will be served at http://localhost:4723/dashboard"); 43 | pluginLogger.info( 44 | "If the appium server is started with different port other than 4723, then use the correct port number to access the device farm dashboard" 45 | ); 46 | logger.info("Dashboard plugin enabled.."); 47 | } 48 | 49 | async handle(next: () => Promise, driver: any, commandName: string, ...args: any) { 50 | let appiumCommand = { 51 | driver, 52 | commandName, 53 | next, 54 | args, 55 | }; 56 | 57 | let originalCommandName: string = commandName == "proxyReqRes" ? routeToCommand(args).commandName : commandName; 58 | 59 | if (IGNORED_COMMANDS.indexOf(originalCommandName) >= 0) { 60 | logger.info(`Skipped parsing command for ${originalCommandName}`); 61 | return await next(); 62 | } 63 | 64 | if (commandName == "createSession") { 65 | /** 66 | * Append additional log capabilities to payload 67 | */ 68 | let rawCapabilities = Object.assign({}, args[2].firstMatch[0], args[2].alwaysMatch); 69 | await this.constructDesiredCapabilities(args); 70 | var response = await next(); 71 | if (response.error) { 72 | return response; 73 | } else { 74 | let sessionInfo = getSessionDetails(rawCapabilities, response); 75 | let sessionManager = new SessionManager({ 76 | sessionInfo, 77 | commandParser: new CommandParser(sessionInfo), 78 | sessionResponse: response, 79 | cliArgs: this.cliArgs, 80 | adb: Container.get("adb"), 81 | }); 82 | sessionMap.set(sessionInfo.session_id, sessionManager); 83 | await sessionManager.onCommandRecieved(appiumCommand); 84 | logger.info(`New Session created with session id ${sessionInfo.session_id}`); 85 | return response; 86 | } 87 | } 88 | 89 | let sessionId = args[args.length - 1]; 90 | if (sessionMap.has(sessionId)) { 91 | return await this.getSessionManager(sessionId)?.onCommandRecieved(appiumCommand); 92 | } else { 93 | return await next(); 94 | } 95 | } 96 | 97 | private getSessionManager(sessionId: string) { 98 | return sessionMap.get(sessionId); 99 | } 100 | 101 | private async constructDesiredCapabilities(args: any) { 102 | if (!args[2].alwaysMatch) { 103 | return; 104 | } 105 | let rawCapabilities = Object.assign({}, args[2].firstMatch[0], args[2].alwaysMatch); 106 | const enableLiveVideo = _.isNil(rawCapabilities["dashboard:enableLiveVideo"]) 107 | ? true 108 | : rawCapabilities["dashboard:enableLiveVideo"]; 109 | CUSTOM_CAPABILITIES.forEach((capability) => { 110 | delete rawCapabilities[capability]; 111 | }); 112 | 113 | let newCapabilities: Record = { 114 | "appium:clearDeviceLogsOnStart": true, 115 | "appium:nativeWebScreenshot": true, //to make screenshot endpoint work in android webview tests, 116 | }; 117 | 118 | if ( 119 | rawCapabilities?.["platformName"].toLowerCase() == "android" && 120 | !rawCapabilities?.["appium:mjpegServerPort"] && 121 | enableLiveVideo 122 | ) { 123 | newCapabilities["appium:mjpegServerPort"] = await getPort(); 124 | } 125 | 126 | Object.keys(newCapabilities).forEach((k) => { 127 | args[2].alwaysMatch[k] = newCapabilities[k]; 128 | }); 129 | } 130 | } 131 | 132 | export { AppiumDashboardPlugin }; 133 | -------------------------------------------------------------------------------- /src/plugin/interfaces/http-logger.ts: -------------------------------------------------------------------------------- 1 | export interface IHttpLogger { 2 | start(): Promise; 3 | stop(): Promise; 4 | getLogs(): Array; 5 | } 6 | -------------------------------------------------------------------------------- /src/plugin/locator-factory.ts: -------------------------------------------------------------------------------- 1 | import { response } from "express"; 2 | import loki from "lokijs"; 3 | 4 | const locatorCollection = new loki("locator.db").addCollection("locators"); 5 | 6 | function getElementId(responseObj: any) { 7 | return ( 8 | responseObj["ELEMENT"] || responseObj[Object.keys(responseObj).find((k: string) => k.startsWith("element")) || 0] 9 | ); 10 | } 11 | 12 | async function saveLocator(strategy: any, elementResponse: any[]) { 13 | elementResponse.forEach((e, i, arr) => { 14 | let obj = { 15 | using: strategy.using, 16 | value: strategy.value, 17 | id: getElementId(e), 18 | index: null, 19 | } as any; 20 | 21 | if (arr.length > 1) { 22 | obj.index = i; 23 | } 24 | 25 | locatorCollection.insert(obj); 26 | }); 27 | } 28 | 29 | async function getLocatorStrategy(elementId: string) { 30 | return locatorCollection.find({ 31 | id: elementId, 32 | })[0]; 33 | } 34 | 35 | export { saveLocator, getLocatorStrategy }; 36 | -------------------------------------------------------------------------------- /src/plugin/script-executor/executor.ts: -------------------------------------------------------------------------------- 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 }; 79 | -------------------------------------------------------------------------------- /src/plugin/script-executor/script.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import B from "bluebird"; 3 | import { NodeVM } from "vm2"; 4 | import { logger, util } from "@appium/support"; 5 | import { attach } from "webdriverio"; 6 | 7 | const log = logger.getLogger("ExecuteDriver Child"); 8 | let send: any; 9 | 10 | async function runScript(driverOpts: any, script: string, timeoutMs: number) { 11 | if (!_.isNumber(timeoutMs)) { 12 | throw new TypeError("Timeout parameter must be a number"); 13 | } 14 | 15 | // set up fake logger 16 | const logLevels = ["error", "warn", "log"]; 17 | const logs: any = []; 18 | const consoleFns: any = {}; 19 | for (const level of logLevels) { 20 | consoleFns[level] = (...logMsgs: any[]) => logs.push({ level, message: [...logMsgs].map(parseLogMessage) }); 21 | } 22 | 23 | const driver = await attach(driverOpts); 24 | 25 | const fullScript = buildScript(script); 26 | 27 | log.info("Running driver script in Node vm"); 28 | 29 | const vmCtx = new NodeVM({ timeout: timeoutMs }); 30 | const vmFn = vmCtx.run(fullScript); 31 | 32 | // run the driver script, giving user access to the driver object, a fake 33 | // console logger, and a promise library 34 | let result = await vmFn(driver, consoleFns, B); 35 | //result = coerceScriptResult(result); 36 | log.info("Successfully ensured driver script result is appropriate type for return"); 37 | return { result, logs }; 38 | } 39 | 40 | function parseLogMessage(log: any) { 41 | if (log instanceof Error) { 42 | return { 43 | message: log.message, 44 | stack: log.stack, 45 | }; 46 | } 47 | return log; 48 | } 49 | 50 | /** 51 | * Embed a user-generated script inside a method which takes only the 52 | * predetermined objects we specify 53 | * 54 | * @param {string} script - the javascript to execute 55 | * 56 | * @return {string} - the full script to execute 57 | */ 58 | function buildScript(script: string) { 59 | return `module.exports = async function execute (driver, console, Promise) { 60 | ${script} 61 | }`; 62 | } 63 | 64 | async function main(driverOpts: any, script: string, timeoutMs: number) { 65 | let res; 66 | try { 67 | let { result, logs }: any = await runScript(driverOpts, script, timeoutMs); 68 | if (result instanceof Error) { 69 | result = { error: true, message: result.message, stack: result.stack }; 70 | } 71 | res = { result, logs }; 72 | } catch (error: any) { 73 | console.log(error); 74 | res = { result: { error: true, message: error.message, stack: error.stack } }; 75 | } 76 | await send({ 77 | data: res, 78 | }); 79 | } 80 | 81 | // ensure we're running this script in IPC mode 82 | if (require.main === module && _.isFunction(process.send)) { 83 | send = B.promisify(process.send, { context: process }); 84 | log.info("Running driver execution in child process"); 85 | process.on("message", ({ driverOpts, script, timeoutMs }) => { 86 | log.info("Parameters received from parent process"); 87 | main(driverOpts, script, timeoutMs); 88 | }); 89 | } 90 | -------------------------------------------------------------------------------- /src/plugin/session-debug-map.ts: -------------------------------------------------------------------------------- 1 | export interface ISessionMapDetails { 2 | is_paused: boolean; 3 | } 4 | 5 | export class SessionDebugMap { 6 | private sessionMap: Map = new Map(); 7 | 8 | public createNewSession(sessionId: string) { 9 | if (!this.sessionMap.get(sessionId)) { 10 | this.sessionMap.set(sessionId, { is_paused: false }); 11 | } 12 | } 13 | 14 | public get(sessionId: string) { 15 | return this.sessionMap.get(sessionId); 16 | } 17 | 18 | public set(sessionId: string, data: Partial) { 19 | let session = this.get(sessionId); 20 | if (session) { 21 | this.sessionMap.set(sessionId, { 22 | ...session, 23 | ...data, 24 | }); 25 | } 26 | } 27 | } 28 | 29 | export default new SessionDebugMap(); 30 | -------------------------------------------------------------------------------- /src/plugin/session-timeout-tracker.ts: -------------------------------------------------------------------------------- 1 | export class SessionTimeoutTracker { 2 | private lastCommandRecievedTime!: Date; 3 | private timer: any; 4 | 5 | constructor( 6 | private options: { 7 | timeout: number; 8 | pollingInterval: number; 9 | timeoutCallback: (timeout: number) => any; 10 | } 11 | ) {} 12 | 13 | public start() { 14 | if (!this.timer) { 15 | this.lastCommandRecievedTime = new Date(); 16 | this.timer = setInterval(() => { 17 | this.checkForSessionTimeout(); 18 | }, this.options.pollingInterval); 19 | } 20 | } 21 | 22 | public tick() { 23 | this.lastCommandRecievedTime = new Date(); 24 | } 25 | 26 | public stop() { 27 | if (this.timer) { 28 | clearInterval(this.timer); 29 | this.timer = null; 30 | } 31 | } 32 | 33 | private checkForSessionTimeout() { 34 | const timediffInSeconds = Math.round((new Date().getTime() - this.lastCommandRecievedTime.getTime()) / 1000); 35 | if (timediffInSeconds > this.options.timeout) { 36 | this.stop(); 37 | this.options.timeoutCallback(this.options.timeout); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/plugin/utils/ios-utils.ts: -------------------------------------------------------------------------------- 1 | import IosDevice from "appium-ios-device"; 2 | import { getSimulator as _getSimulator } from "appium-ios-simulator"; 3 | async function isRealDevice(deviceUUID: string) { 4 | try { 5 | await IosDevice.getDeviceVersion(deviceUUID); 6 | return true; 7 | } catch (err) { 8 | return false; 9 | } 10 | } 11 | 12 | async function getSimulator(deviceUUID: string) { 13 | return await _getSimulator(deviceUUID); 14 | } 15 | 16 | export { isRealDevice, getSimulator }; 17 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { CustomColumnOption } from "./interfaces/custom-column-options"; 2 | 3 | export function customModelColumn(options: CustomColumnOption) { 4 | let result: any = {}; 5 | result["set"] = function (value: any) { 6 | if (options.json) { 7 | if ((value != null || value != undefined) && typeof value === "object") { 8 | value = JSON.stringify(value); 9 | } 10 | } 11 | this.setDataValue(options.name, value); 12 | }; 13 | 14 | result["get"] = function () { 15 | let value = this.getDataValue(options.name); 16 | if (value == null) { 17 | return value; 18 | } 19 | if (options.json) { 20 | try { 21 | value = JSON.parse(value); 22 | } catch (e) { 23 | //ignore 24 | } 25 | } 26 | return value; 27 | }; 28 | return result; 29 | } 30 | -------------------------------------------------------------------------------- /tmp/cdp.js: -------------------------------------------------------------------------------- 1 | const CDP = require("chrome-remote-interface"); 2 | 3 | async function example() { 4 | let client; 5 | try { 6 | // connect to endpoint 7 | client = await CDP({ 8 | port: 60234, 9 | local: true, 10 | //protocol: require("/Users/sudharsanselvaraj/Documents/git/personal/appium-dashboard-plugin/node_modules/chrome-remote-interface/lib/protocol.json"), 11 | }); 12 | // extract domains 13 | const { Network, Page } = client; 14 | // setup handlers 15 | Network.requestWillBeSent((params) => { 16 | console.log(params); 17 | }); 18 | // enable events then start! 19 | await Network.enable(); 20 | // await Page.enable(); 21 | //await Page.navigate({ url: "https://github.com" }); 22 | // await Page.loadEventFired(); 23 | await new Promise((res) => { 24 | setTimeout(res, 10000); 25 | }); 26 | } catch (err) { 27 | console.error(err); 28 | } finally { 29 | if (client) { 30 | await client.close(); 31 | } 32 | console.log("finaly"); 33 | } 34 | } 35 | 36 | (async () => { 37 | await example(); 38 | })(); 39 | -------------------------------------------------------------------------------- /tmp/command-parser.js: -------------------------------------------------------------------------------- 1 | var commands = require("./commands.js").commands; 2 | var fs = require("fs"); 3 | 4 | var set = new Set(); 5 | Object.keys(commands).forEach((route) => { 6 | ["GET", "POST", "DELETE"].forEach((method) => { 7 | if (commands[route][method]) { 8 | set.add(commands[route][method].command); 9 | } 10 | }); 11 | }); 12 | 13 | let methodDefs = [...set] 14 | .filter(Boolean) 15 | .map(function (method) { 16 | return `//TODO 17 | pubic async function ${method}(driver: any, args: any[], response: any) { 18 | return { 19 | title: "${method}", 20 | title_info: null, 21 | response: null, 22 | params: null, 23 | }; 24 | }`; 25 | }) 26 | .join("\n\n"); 27 | 28 | fs.writeFileSync( 29 | "/Users/sselvar4/Documents/git/personal/appium-dashboard-plugin/src/command-parser-new.ts", 30 | `${methodDefs}` 31 | ); 32 | -------------------------------------------------------------------------------- /tmp/ios-cdp.js: -------------------------------------------------------------------------------- 1 | const profiler = require("../lib/plugin/network-profiler/ios-network-profiler-backup").IosNetworkProfiler; 2 | const pr = new profiler({ 3 | uuid: "573694D2-2B49-4307-B344-26C4CAE74A37", 4 | }); 5 | 6 | (async () => { 7 | console.log(await pr.start()); 8 | })(); 9 | -------------------------------------------------------------------------------- /tmp/ios-network-profiler-backup.ts: -------------------------------------------------------------------------------- 1 | import IosDevice from "appium-ios-device"; 2 | import { getSimulator } from "appium-ios-simulator"; 3 | import { createRemoteDebugger } from "appium-remote-debugger"; 4 | import { retryInterval, retry } from "asyncbox"; 5 | import _ from "lodash"; 6 | import { IHttpLogger } from "../src/plugin/interfaces/http-logger"; 7 | 8 | export class IosNetworkProfiler implements IHttpLogger { 9 | private remoteDebugInstance: any; 10 | constructor(private opts: { uuid: string }) {} 11 | 12 | public async start(): Promise { 13 | let sim = await this.getSimulator(); 14 | console.log(await sim.getWebInspectorSocket()); 15 | await sim.openUrl("https://www.google.com"); 16 | if (await this.isRealDevice()) { 17 | this.remoteDebugInstance = createRemoteDebugger( 18 | { 19 | bundleId: "com.apple.mobilesafari", 20 | isSafari: true, 21 | useNewSafari: true, 22 | pageLoadMs: 1000, 23 | //socketPath: await sim.getWebInspectorSocket(), 24 | garbageCollectOnExecute: false, 25 | isSimulator: true, 26 | platformVersion: "15.1", 27 | logAllCommunication: false, 28 | logAllCommunicationHexDump: false, 29 | }, 30 | false 31 | ); 32 | } else { 33 | let sim = await this.getSimulator(); 34 | this.remoteDebugInstance = createRemoteDebugger( 35 | { 36 | bundleId: "com.apple.mobilesafari", 37 | isSafari: true, 38 | useNewSafari: true, 39 | pageLoadMs: 1000, 40 | platformVersion: "15.1", 41 | socketPath: await sim.getWebInspectorSocket(), 42 | garbageCollectOnExecute: false, 43 | isSimulator: true, 44 | logAllCommunicationHexDump: false, 45 | }, 46 | false 47 | ); 48 | } 49 | 50 | await this.remoteDebugInstance.connect(); 51 | await retryInterval(30, 1000, async () => { 52 | if (!_.isEmpty(this.remoteDebugInstance.appDict)) { 53 | return this.remoteDebugInstance.appDict; 54 | } 55 | await this.remoteDebugInstance.setConnectionKey(); 56 | throw new Error("No apps connected"); 57 | }); 58 | 59 | this.remoteDebugInstance.addClientEventListener("Network", (event: any, a: any) => { 60 | console.log(a.initiator.stackTrace); 61 | }); 62 | this.remoteDebugInstance.addClientEventListener("Network", async (event: any, a: any) => { 63 | console.log(a.requestId); 64 | }); 65 | 66 | const page = _.find(await this.remoteDebugInstance.selectApp(""), (page) => { 67 | return page.url.indexOf("google") >= 1; 68 | }); 69 | const [appIdKey, pageIdKey] = page.id.split(".").map((id: string) => parseInt(id, 10)); 70 | await this.remoteDebugInstance.selectPage(appIdKey, pageIdKey); 71 | } 72 | 73 | async stop(): Promise {} 74 | 75 | getLogs(): any[] { 76 | return []; 77 | } 78 | 79 | private async isRealDevice() { 80 | try { 81 | await IosDevice.getOSVersion(this.opts.uuid); 82 | return true; 83 | } catch (err) { 84 | return false; 85 | } 86 | } 87 | 88 | private async getSimulator() { 89 | return await getSimulator(this.opts.uuid); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /tmp/sample responses.txt: -------------------------------------------------------------------------------- 1 | 2 | ========================== APP AUTOMATION ============================== 3 | 4 | __________________[ ANDROID ]_________________________________ 5 | [{"platformName":"Android","appium:orientation":"PORTRAIT","appium:automationName":"UiAutomator2","appium:app":"/Users/sselvar4/Documents/git/playground/appium-boilerplate/apps/Android-NativeDemoApp-0.4.0.apk","appium:appWaitActivity":"com.wdiodemoapp.MainActivity","appium:noReset":true,"appium:newCommandTimeout":240},null,{"alwaysMatch":{"platformName":"Android","appium:orientation":"PORTRAIT","appium:automationName":"UiAutomator2","appium:app":"/Users/sselvar4/Documents/git/playground/appium-boilerplate/apps/Android-NativeDemoApp-0.4.0.apk","appium:appWaitActivity":"com.wdiodemoapp.MainActivity","appium:noReset":true,"appium:newCommandTimeout":240},"firstMatch":[{"appium:clearDeviceLogsOnStart":true}],"appium:clearDeviceLogsOnStart":true}] 6 | {"protocol":"W3C","value":["aa75edf1-af0e-4154-9237-65f8e8e4ba07",{"platform":"LINUX","webStorageEnabled":false,"takesScreenshot":true,"javascriptEnabled":true,"databaseEnabled":false,"networkConnectionEnabled":true,"locationContextEnabled":false,"warnings":{},"desired":{"platformName":"Android","orientation":"PORTRAIT","automationName":"UiAutomator2","app":"/Users/sselvar4/Documents/git/playground/appium-boilerplate/apps/Android-NativeDemoApp-0.4.0.apk","appWaitActivity":"com.wdiodemoapp.MainActivity","noReset":true,"newCommandTimeout":240,"clearDeviceLogsOnStart":true},"platformName":"Android","orientation":"PORTRAIT","automationName":"UiAutomator2","app":"/Users/sselvar4/Documents/git/playground/appium-boilerplate/apps/Android-NativeDemoApp-0.4.0.apk","appWaitActivity":"com.wdiodemoapp.MainActivity","noReset":true,"newCommandTimeout":240,"clearDeviceLogsOnStart":true,"deviceName":"emulator-5554","deviceUDID":"emulator-5554","appPackage":"com.wdiodemoapp","deviceApiLevel":30,"platformVersion":"11","deviceScreenSize":"1080x2280","deviceScreenDensity":440,"deviceModel":"Android SDK built for x86","deviceManufacturer":"unknown","pixelRatio":2.75,"statBarHeight":135,"viewportRect":{"left":0,"top":135,"width":1080,"height":1842}},"W3C"]} 7 | 8 | __________________[ IOS ]_________________________________ 9 | [{"platformName":"iOS","appium:deviceName":"iPhone 13","appium:platformVersion":"15.0","appium:orientation":"PORTRAIT","appium:automationName":"XCUITest","appium:app":"/Users/sselvar4/Documents/git/playground/appium-boilerplate/apps/iOS-Simulator-NativeDemoApp-0.4.0.app.zip","appium:noReset":true,"appium:newCommandTimeout":240},null,{"alwaysMatch":{"platformName":"iOS","appium:deviceName":"iPhone 13","appium:platformVersion":"15.0","appium:orientation":"PORTRAIT","appium:automationName":"XCUITest","appium:app":"/Users/sselvar4/Documents/git/playground/appium-boilerplate/apps/iOS-Simulator-NativeDemoApp-0.4.0.app.zip","appium:noReset":true,"appium:newCommandTimeout":240},"firstMatch":[{"appium:clearDeviceLogsOnStart":true}],"appium:clearDeviceLogsOnStart":true}] 10 | {"protocol":"W3C","value":["ee4bbf1b-ffcb-4038-8bbf-711b0b965141",{"webStorageEnabled":false,"locationContextEnabled":false,"browserName":"","platform":"MAC","javascriptEnabled":true,"databaseEnabled":false,"takesScreenshot":true,"networkConnectionEnabled":false,"platformName":"iOS","deviceName":"iPhone 13","platformVersion":"15.0","orientation":"PORTRAIT","automationName":"XCUITest","app":"/Users/sselvar4/Documents/git/playground/appium-boilerplate/apps/iOS-Simulator-NativeDemoApp-0.4.0.app.zip","noReset":true,"newCommandTimeout":240,"clearDeviceLogsOnStart":true,"udid":"482A12B0-7D01-4B84-BF44-F9D8AD108ED6"},"W3C"]} 11 | 12 | ========================== APP AUTOMATION ============================== 13 | 14 | 15 | ========================== BROWSER AUTOMATION ============================== 16 | 17 | __________________[ IOS ]_________________________________ 18 | [{"browserName":"safari","platformName":"iOS","appium:deviceName":"iPhone 13","appium:platformVersion":"15.0","appium:orientation":"PORTRAIT","appium:automationName":"XCUITest","appium:newCommandTimeout":240},null,{"alwaysMatch":{"browserName":"safari","platformName":"iOS","appium:deviceName":"iPhone 13","appium:platformVersion":"15.0","appium:orientation":"PORTRAIT","appium:automationName":"XCUITest","appium:newCommandTimeout":240},"firstMatch":[{"appium:clearDeviceLogsOnStart":true}],"appium:clearDeviceLogsOnStart":true}] 19 | {"protocol":"W3C","value":["9d6acb78-de69-42e1-ac60-efb34cb7e4d0",{"webStorageEnabled":false,"locationContextEnabled":false,"browserName":"safari","platform":"MAC","javascriptEnabled":true,"databaseEnabled":false,"takesScreenshot":true,"networkConnectionEnabled":false,"platformName":"iOS","deviceName":"iPhone 13","platformVersion":"15.0","orientation":"PORTRAIT","automationName":"XCUITest","newCommandTimeout":240,"clearDeviceLogsOnStart":true,"udid":"482A12B0-7D01-4B84-BF44-F9D8AD108ED6"},"W3C"]} 20 | -------------------------------------------------------------------------------- /tmp/vm2.js: -------------------------------------------------------------------------------- 1 | var cp = require("child_process"); 2 | var B = require("bluebird"); 3 | 4 | const childScript = require.resolve( 5 | "/Users/sudharsanselvaraj/Documents/git/personal/appium-dashboard-plugin/lib/plugin/script-executor/script.js" 6 | ); 7 | 8 | let driverOptions = { 9 | sessionId: "6f695ab1-5770-4821-86ca-98beddbde207", 10 | protocol: "http", 11 | hostname: "localhost", 12 | port: 4723, 13 | path: "/wd-internal", 14 | isW3C: true, 15 | isMobile: true, 16 | capabilities: { 17 | platformName: "Android", 18 | maxInstances: 1, 19 | platformVersion: "10", 20 | orientation: "PORTRAIT", 21 | automationName: "UiAutomator2", 22 | uiautomator2ServerInstallTimeout: 50000, 23 | app: "/Users/sudharsanselvaraj/Documents/git/personal/oss/appium-boilerplate/apps/Android-NativeDemoApp-0.4.0.apk", 24 | appWaitActivity: "com.wdiodemoapp.MainActivity", 25 | noReset: true, 26 | newCommandTimeout: 240, 27 | ensureWebviewsHavePages: true, 28 | nativeWebScreenshot: true, 29 | connectHardwareKeyboard: true, 30 | }, 31 | }; 32 | 33 | const scriptProc = cp.fork(childScript); 34 | scriptProc.send({ driverOpts: driverOptions, script: "throw new Error()", timeoutMs: 50000 }); 35 | 36 | const waitForResult = async () => { 37 | const res = await new B((res) => { 38 | scriptProc.on("message", (data) => { 39 | res(data); 40 | }); // this is node IPC 41 | }); 42 | 43 | if (res.error) { 44 | throw new Error(res.error.message); 45 | } 46 | 47 | return res.success; 48 | }; 49 | 50 | (async () => { 51 | try { 52 | console.log(await waitForResult()); 53 | } catch (err) { 54 | console.log(err); 55 | } 56 | })(); 57 | -------------------------------------------------------------------------------- /typings/appium-adb/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "appium-adb"; 2 | declare module "appium-adb/lib/logcat"; 3 | -------------------------------------------------------------------------------- /typings/appium-base-driver/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "appium-base-driver"; 2 | -------------------------------------------------------------------------------- /typings/appium-ios-device/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "appium-ios-device"; 2 | -------------------------------------------------------------------------------- /typings/appium-ios-simulator copy/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "appium-ios-simulator"; 2 | -------------------------------------------------------------------------------- /typings/appium-remote-debugger/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "appium-remote-debugger"; 2 | -------------------------------------------------------------------------------- /typings/appium-support/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@appium/support"; 2 | -------------------------------------------------------------------------------- /typings/asyncbox/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "asyncbox"; 2 | -------------------------------------------------------------------------------- /typings/base-plugin/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@appium/base-plugin'; 2 | -------------------------------------------------------------------------------- /typings/mjpeg-proxy/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "mjpeg-proxy"; 2 | -------------------------------------------------------------------------------- /typings/node-fetch/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "node-fetch"; 2 | -------------------------------------------------------------------------------- /web/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "plugin:react/recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:cypress/recommended", 10 | "plugin:prettier/recommended" 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "parserOptions": { 14 | "ecmaFeatures": { 15 | "jsx": true 16 | }, 17 | "ecmaVersion": 13, 18 | "sourceType": "module" 19 | }, 20 | "plugins": ["react", "@typescript-eslint", "prettier"], 21 | "rules": { 22 | "@typescript-eslint/no-explicit-any": 0, 23 | "@typescript-eslint/explicit-module-boundary-types": 0 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /web/.prettierignore: -------------------------------------------------------------------------------- 1 | build -------------------------------------------------------------------------------- /web/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": false, 7 | "trailingComma": "all", 8 | "parser": "typescript", 9 | "arrowParens": "always" 10 | } 11 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `yarn start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `yarn test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `yarn build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `yarn eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /web/craco.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "appium-dashboard-web", 3 | "version": "2.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "@blueprintjs/core": "^3.51.3", 7 | "@blueprintjs/popover2": "^0.12.8", 8 | "@codemirror/lang-javascript": "^0.19.7", 9 | "@emotion/react": "^11.7.0", 10 | "@emotion/styled": "^11.6.0", 11 | "@material-ui/core": "^4.12.3", 12 | "@material-ui/icons": "^4.11.2", 13 | "@mui/material": "^5.2.1", 14 | "@mui/styled-engine-sc": "^5.1.0", 15 | "@reduxjs/toolkit": "^1.6.2", 16 | "@types/lodash": "^4.14.177", 17 | "@types/redux-logger": "^3.0.9", 18 | "@uiw/react-codemirror": "^4.4.3", 19 | "axios": "^0.24.0", 20 | "chart.js": "^3.7.0", 21 | "chroma-js": "^2.1.2", 22 | "jquery": "^3.6.0", 23 | "js-beautify": "^1.14.0", 24 | "lodash": "^4.17.21", 25 | "lottie-react": "^2.1.0", 26 | "moment": "^2.29.1", 27 | "moment-timezone": "^0.5.33", 28 | "popper.js": "^1.16.1", 29 | "prism-react-renderer": "^1.2.1", 30 | "react": "^17.0.2", 31 | "react-chartjs-2": "^4.0.1", 32 | "react-datepicker": "^4.3.0", 33 | "react-dom": "^17.0.2", 34 | "react-icons": "^4.3.1", 35 | "react-lazy-load-image-component": "^1.5.1", 36 | "react-moment": "^1.1.1", 37 | "react-redux": "^7.2.6", 38 | "react-router-dom": "^5.3.0", 39 | "react-toastify": "^8.2.0", 40 | "redux": "^4.1.2", 41 | "redux-devtools-extension": "^2.13.9", 42 | "redux-logger": "^3.0.6", 43 | "redux-saga": "^1.1.3", 44 | "styled-components": "^5.3.3", 45 | "typescript": "^4.1.2", 46 | "web-vitals": "^1.0.1" 47 | }, 48 | "scripts": { 49 | "start": "craco start", 50 | "build": "craco build", 51 | "test": "craco test", 52 | "eject": "react-scripts eject" 53 | }, 54 | "eslintConfig": { 55 | "extends": [ 56 | "react-app", 57 | "react-app/jest" 58 | ] 59 | }, 60 | "browserslist": { 61 | "production": [ 62 | ">0.2%", 63 | "not dead", 64 | "not op_mini all" 65 | ], 66 | "development": [ 67 | "last 1 chrome version", 68 | "last 1 firefox version", 69 | "last 1 safari version" 70 | ] 71 | }, 72 | "homepage": "/dashboard", 73 | "devDependencies": { 74 | "@craco/craco": "^6.4.0", 75 | "@testing-library/jest-dom": "^5.11.4", 76 | "@testing-library/react": "^11.1.0", 77 | "@testing-library/user-event": "^12.1.10", 78 | "@types/chroma-js": "^2.1.3", 79 | "@types/jest": "^26.0.15", 80 | "@types/jquery": "^3.5.8", 81 | "@types/js-beautify": "^1.13.3", 82 | "@types/node": "^12.0.0", 83 | "@types/react": "^17.0.0", 84 | "@types/react-dom": "^17.0.0", 85 | "@types/react-lazy-load-image-component": "^1.5.2", 86 | "@types/react-router-dom": "^5.3.1", 87 | "@types/styled-components": "^5.1.15", 88 | "@typescript-eslint/eslint-plugin": "^5.3.1", 89 | "@typescript-eslint/parser": "^5.3.1", 90 | "eslint-config-prettier": "^8.3.0", 91 | "eslint-plugin-cypress": "^2.12.1", 92 | "eslint-plugin-import": "^2.25.3", 93 | "eslint-plugin-jsx-a11y": "^6.5.1", 94 | "eslint-plugin-prettier": "^4.0.0", 95 | "eslint-plugin-react": "^7.27.0", 96 | "prettier": "2.4.1", 97 | "react-scripts": "4.0.3", 98 | "redux-devtools": "^3.7.0" 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sudharsan-selvaraj/appium-dashboard-plugin/f3e042b51e978a40c1db793c97920d5d3415b25d/web/public/favicon.ico -------------------------------------------------------------------------------- /web/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | Appium Dashboard 28 | 29 | 30 | 31 |

32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /web/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /web/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /web/src/App-router.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useEffect } from "react"; 3 | import { connect, useDispatch } from "react-redux"; 4 | import { BrowserRouter as Router, Switch, Route } from "react-router-dom"; 5 | import styled, { ThemeProvider } from "styled-components"; 6 | import Dashboard from "./components/pages/dashboard"; 7 | import PageNotFound from "./components/pages/page-not-found"; 8 | import AppLoader from "./components/UI/molecules/app-loader"; 9 | import { BASE_URL } from "./constants/routes"; 10 | import { ThemeConfig } from "./constants/themes"; 11 | import { AppState } from "./store"; 12 | import ReduxActionTypes from "./store/redux-action-types"; 13 | import { getSelectedTheme } from "./store/selectors/ui/theme-selector"; 14 | 15 | const Container = styled.div` 16 | font-size: ${(props) => props.theme.fonts.size.L}; 17 | color: ${(props) => props.theme.colors.greyscale[0]}; 18 | background: ${(props) => props.theme.colors.greyscale[6]}; 19 | `; 20 | 21 | type PropsType = { 22 | theme: ThemeConfig; 23 | isAppInitialised: boolean; 24 | }; 25 | 26 | function AppRouter(props: PropsType) { 27 | const { theme, isAppInitialised } = props; 28 | const dispatch = useDispatch(); 29 | 30 | useEffect(() => { 31 | dispatch({ 32 | type: ReduxActionTypes.INIT_APP, 33 | }); 34 | }, []); 35 | return ( 36 | 37 | 38 | {isAppInitialised ? ( 39 | 40 | 41 | 42 | 43 | 44 | 45 | ) : ( 46 | 47 | )} 48 | 49 | 50 | ); 51 | } 52 | 53 | export default connect((state: AppState) => { 54 | return { 55 | theme: getSelectedTheme(state), 56 | isAppInitialised: state.ui.appInitialised, 57 | }; 58 | })(AppRouter); 59 | -------------------------------------------------------------------------------- /web/src/App.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | .app__container { 4 | margin: auto; 5 | height: 100vh; 6 | width: 100vw; 7 | display: flex; 8 | flex-direction: column; 9 | position: fixed; 10 | } 11 | 12 | .app__wrapper { 13 | display: flex; 14 | height: 100%; 15 | width: 100%; 16 | flex-direction: column; 17 | } 18 | 19 | .app__loading { 20 | display: flex; 21 | flex-direction: column; 22 | align-items: center; 23 | margin: auto; 24 | } 25 | 26 | .app__loading_message { 27 | font-size: 25px; 28 | } 29 | 30 | .app__main_content { 31 | margin: auto; 32 | margin-top: 20px; 33 | height: 100%; 34 | width: 99%; 35 | border: 1px solid #ced8e1; 36 | display: flex; 37 | } 38 | 39 | .session-details__message_container { 40 | display: flex; 41 | align-items: center; 42 | width: auto; 43 | height: 100%; 44 | margin: auto; 45 | padding-left: 20px; 46 | padding-right: 20px; 47 | } 48 | 49 | .session-details__message { 50 | font-size: 15px; 51 | font-weight: bold; 52 | color: #2d2d2d; 53 | } 54 | 55 | 56 | -------------------------------------------------------------------------------- /web/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen } from "@testing-library/react"; 3 | import App from "./App"; 4 | 5 | test("renders learn react link", () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /web/src/api/index.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestConfig } from "axios"; 2 | import qs from "qs"; 3 | 4 | const defaultConfig = { 5 | baseURL: `${process.env.REACT_APP_API_BASE_URL}/api`, 6 | }; 7 | 8 | function paramsSerializer(params: any) { 9 | return qs.stringify(params, { arrayFormat: "comma" }); 10 | } 11 | 12 | export default class Api { 13 | static base_url = process.env.REACT_APP_API_BASE_URL; 14 | 15 | static get( 16 | url: string, 17 | params?: any, 18 | config: Partial = {}, 19 | ) { 20 | return axios 21 | .get(url, { 22 | ...defaultConfig, 23 | ...config, 24 | params, 25 | paramsSerializer, 26 | }) 27 | .then((response) => response.data) 28 | .catch((err) => err.response.data); 29 | } 30 | 31 | static post( 32 | url: string, 33 | payload: any = {}, 34 | params?: any, 35 | config: Partial = {}, 36 | ) { 37 | return axios 38 | .post(url, payload, { 39 | ...defaultConfig, 40 | ...config, 41 | params, 42 | paramsSerializer, 43 | }) 44 | .then((response) => response.data) 45 | .catch((err) => err.response.data); 46 | } 47 | 48 | static delete( 49 | url: string, 50 | params?: any, 51 | config: Partial = {}, 52 | ) { 53 | return axios 54 | .delete(url, { 55 | ...defaultConfig, 56 | ...config, 57 | params, 58 | paramsSerializer, 59 | }) 60 | .then((response) => response.data) 61 | .catch((err) => err.response.data); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /web/src/api/sessions.ts: -------------------------------------------------------------------------------- 1 | import Api from "./index"; 2 | 3 | type ListResponse = { 4 | result: { 5 | count: number; 6 | rows: any[]; 7 | }; 8 | }; 9 | 10 | export default class SessionApi { 11 | public static getAllSessions( 12 | filterParams?: Record, 13 | ): Promise { 14 | return Api.get("/sessions", filterParams || {}); 15 | } 16 | 17 | public static getTextLogsForSession( 18 | sessionId: string, 19 | ): Promise { 20 | return Api.get(`/sessions/${sessionId}/logs/text`, {}); 21 | } 22 | 23 | public static getDeviceLogsForSession( 24 | sessionId: string, 25 | ): Promise { 26 | return Api.get(`/sessions/${sessionId}/logs/device`, {}); 27 | } 28 | 29 | public static getDebugLogsForSession( 30 | sessionId: string, 31 | ): Promise { 32 | return Api.get(`/sessions/${sessionId}/logs/debug`, {}); 33 | } 34 | 35 | public static getSessionById(sessionId: string) { 36 | return Api.get(`/sessions/${sessionId}`, {}); 37 | } 38 | 39 | public static deleteSessionById(sessionId: string) { 40 | return Api.delete(`/sessions/${sessionId}`); 41 | } 42 | 43 | public static deleteAllSessions() { 44 | return Api.delete(`/sessions`); 45 | } 46 | 47 | public static getSessionTextLogs(sessionId: string) { 48 | return Api.get(`/sessions/${sessionId}/logs/text`, {}); 49 | } 50 | 51 | public static pauseSession(sessionId: string) { 52 | return Api.post(`/debug/${sessionId}/pause`, {}); 53 | } 54 | 55 | public static resumeSession(sessionId: string) { 56 | return Api.post(`/debug/${sessionId}/play`, {}); 57 | } 58 | 59 | public static getAppProfilingForSession(sessionId: string) { 60 | return Api.get(`/sessions/${sessionId}/profiling_data`); 61 | } 62 | 63 | public static getHttpLogsForSession(sessionId: string) { 64 | return Api.get(`/sessions/${sessionId}/http_logs`); 65 | } 66 | 67 | public static runDriverScript({ 68 | sessionId, 69 | script, 70 | timeoutMs, 71 | }: { 72 | sessionId: string; 73 | script: string; 74 | timeoutMs?: number; 75 | }) { 76 | return Api.post(`/debug/${sessionId}/execute_driver_script`, { 77 | script, 78 | timeout: timeoutMs, 79 | }); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /web/src/assets/android.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/assets/ios.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/assets/ios.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sudharsan-selvaraj/appium-dashboard-plugin/f3e042b51e978a40c1db793c97920d5d3415b25d/web/src/assets/ios.zip -------------------------------------------------------------------------------- /web/src/assets/lottie/arrow.json: -------------------------------------------------------------------------------- 1 | {"v":"5.7.1","fr":30,"ip":0,"op":60,"w":1920,"h":1080,"nm":"Comp 6","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 2","parent":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":87.897,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"sr","sy":2,"d":1,"pt":{"a":0,"k":3,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"or":{"a":0,"k":100,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.733333333333,0.882352941176,0.980392156863,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":0,"op":310,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":3,"nm":"Trace Shape Layer 1: Path 1 [1.1]","cl":"1","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\nvar pathToTrace = thisComp.layer('Shape Layer 1')('ADBE Root Vectors Group')(1)('ADBE Vectors Group')(1)('ADBE Vector Shape');\nvar progress = $bm_div(thisLayer.effect('Pseudo/ADBE Trace Path')('Pseudo/ADBE Trace Path-0001'), 100);\nvar pathTan = pathToTrace.tangentOnPath(progress);\n$bm_rt = radiansToDegrees(Math.atan2(pathTan[1], pathTan[0]));"},"p":{"a":0,"k":[960,540,0],"ix":2,"x":"var $bm_rt;\nvar pathLayer = thisComp.layer('Shape Layer 1');\nvar progress = $bm_div(thisLayer.effect('Pseudo/ADBE Trace Path')('Pseudo/ADBE Trace Path-0001'), 100);\nvar pathToTrace = pathLayer('ADBE Root Vectors Group')(1)('ADBE Vectors Group')(1)('ADBE Vector Shape');\n$bm_rt = pathLayer.toComp(pathToTrace.pointOnPath(progress));"},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ef":[{"ty":5,"nm":"Trace Path","np":4,"mn":"Pseudo/ADBE Trace Path","ix":1,"en":1,"ef":[{"ty":0,"nm":"Progress","mn":"Pseudo/ADBE Trace Path-0001","ix":1,"v":{"a":1,"k":[{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.5],"y":[0]},"t":0,"s":[0]},{"t":39,"s":[100]}],"ix":1,"x":"var $bm_rt;\nif (thisProperty.propertyGroup(1)('Pseudo/ADBE Trace Path-0002') == true && thisProperty.numKeys > 1) {\n $bm_rt = thisProperty.loopOut('cycle');\n} else {\n $bm_rt = value;\n}"}},{"ty":7,"nm":"Loop","mn":"Pseudo/ADBE Trace Path-0002","ix":2,"v":{"a":0,"k":0,"ix":2}}]}],"ip":0,"op":310,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[856,540,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-323.186,189.871],[161.695,17.609],[90.661,-138.711],[-428.001,204],[-170,4]],"o":[[0,0],[160,-94],[-133.356,-14.523],[-200,306],[373.051,-177.81],[137.147,-3.227]],"v":[[-622,86],[144,110],[101.356,-205.477],[-258,-4],[312.001,298],[830,106]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.161538008148,0.248468989952,0.407842987659,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":30,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":0,"k":100,"ix":2,"x":"var $bm_rt;\n$bm_rt = thisComp.layer('Trace Shape Layer 1: Path 1 [1.1]').effect('Trace Path')('Progress');"},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":310,"st":0,"bm":0}],"markers":[]} -------------------------------------------------------------------------------- /web/src/components/UI/atoms/animation.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Lottie from "lottie-react"; 3 | import styled from "styled-components"; 4 | import spinner from "../../../assets/lottie/spinner.json"; 5 | 6 | const Container = styled.div<{ width?: string }>` 7 | width: ${(props) => props.width}; 8 | `; 9 | 10 | type PropsType = { 11 | name: string; 12 | width?: string; 13 | }; 14 | 15 | export default function Animation(props: PropsType) { 16 | const { name, width } = props; 17 | let animationData; 18 | 19 | switch (name) { 20 | case "spinner": 21 | animationData = spinner; 22 | break; 23 | default: 24 | animationData = {}; 25 | break; 26 | } 27 | 28 | return ( 29 | 30 | 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /web/src/components/UI/atoms/button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import ButtonComponent, { ButtonTypeMap } from "@mui/material/Button"; 4 | import chroma from "chroma-js"; 5 | 6 | const StyledButtonComponent = styled(ButtonComponent)` 7 | && { 8 | display: inline-block; 9 | width: auto; 10 | padding: ${(props) => 11 | props.variant === "contained" ? "3px 15px" : "2px 15px"}; 12 | background-color: ${(props) => 13 | props.variant === "contained" && props.theme.colors.primary}; 14 | color: ${(props) => 15 | (props.variant === "text" || props.variant === "outlined") && 16 | props.theme.colors.primary}; 17 | border-color: ${(props) => 18 | props.variant === "outlined" && props.theme.colors.primary}; 19 | 20 | &:hover { 21 | background-color: ${(props) => 22 | props.variant === "contained" && 23 | chroma(props.theme.colors.primary).brighten(1).hex()}; 24 | border-color: ${(props) => 25 | props.variant === "outlined" && props.theme.colors.primary}; 26 | } 27 | } 28 | `; 29 | 30 | type PropsType = { 31 | variant?: ButtonTypeMap["props"]["variant"]; 32 | onClick?: () => void; 33 | children: string; 34 | className?: string; 35 | }; 36 | 37 | export default function Button(props: PropsType) { 38 | const { variant = "contained", onClick, children, className } = props; 39 | return ( 40 | 45 | {children} 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /web/src/components/UI/atoms/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { Checkbox, Classes } from "@blueprintjs/core"; 4 | 5 | const Container = styled.div` 6 | & .${Classes.CONTROL_INDICATOR} { 7 | width: 10px; 8 | display: inline-block; 9 | } 10 | 11 | & input { 12 | position: relative; 13 | top: 2px; 14 | } 15 | `; 16 | 17 | type PropsType = { 18 | checked: boolean; 19 | label: string; 20 | onChange?: (isChecked: boolean) => void; 21 | }; 22 | 23 | export default function CheckboxComponent(props: PropsType) { 24 | const { checked, label, onChange } = props; 25 | 26 | return ( 27 | 28 | ) => 32 | onChange && onChange(event.target.checked) 33 | } 34 | /> 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /web/src/components/UI/atoms/code-viewer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import Highlight, { defaultProps, Language } from "prism-react-renderer"; 4 | import githubTheme from "prism-react-renderer/themes/github"; 5 | import nightOwlTheme from "prism-react-renderer/themes/nightOwl"; 6 | 7 | const themes = { 8 | github: githubTheme, 9 | nightOwl: nightOwlTheme, 10 | }; 11 | 12 | export const LineNo = styled.span` 13 | display: inline-block; 14 | width: 2em; 15 | user-select: none; 16 | opacity: 0.3; 17 | `; 18 | 19 | const Pre = styled.pre` 20 | text-align: left; 21 | margin: 5px 0; 22 | background: transparent !important; 23 | 24 | & { 25 | .token-line { 26 | white-space: break-spaces; 27 | word-break: break-all; 28 | } 29 | } 30 | `; 31 | 32 | const Container = styled(Highlight)``; 33 | 34 | type PropsType = { 35 | code: string; 36 | language: Language; 37 | theme?: "github" | "nightOwl"; 38 | lineNumber?: boolean; 39 | }; 40 | 41 | export default function CodeViewer(props: PropsType) { 42 | const { code, language, theme = "github", lineNumber = false } = props; 43 | 44 | return ( 45 | 51 | {({ className, style, tokens, getLineProps, getTokenProps }) => ( 52 |
53 |           {tokens.map((line, i) => (
54 |             
55 | {!!lineNumber && {i + 1}} 56 | {line.map((token, key) => ( 57 | 58 | ))} 59 |
60 | ))} 61 |
62 | )} 63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /web/src/components/UI/atoms/dropdown.tsx: -------------------------------------------------------------------------------- 1 | import Popover, { PopoverOrigin } from "@mui/material/Popover"; 2 | import React from "react"; 3 | import styled from "styled-components"; 4 | 5 | const Container = styled.div``; 6 | 7 | const Trigger = styled.div` 8 | cursor: pointer; 9 | `; 10 | 11 | const Content = styled.div` 12 | padding: 10px; 13 | `; 14 | 15 | type PropsType = { 16 | children: [JSX.Element, JSX.Element]; 17 | vertical?: PopoverOrigin["vertical"]; 18 | horizontal?: PopoverOrigin["horizontal"]; 19 | id?: string; 20 | open?: boolean; 21 | controlled?: boolean; 22 | onOpen?: () => void; 23 | onClose?: () => void; 24 | }; 25 | 26 | export default function Dropdown(props: PropsType) { 27 | const { 28 | children, 29 | id = "simple-popover", 30 | vertical = "bottom", 31 | horizontal = "left", 32 | controlled, 33 | open, 34 | onOpen, 35 | onClose, 36 | } = props; 37 | const anchorEl = React.useRef(null); 38 | const [isOpen, setIsOpen] = React.useState(false); 39 | 40 | const handleClick = () => { 41 | setIsOpen(true); 42 | onOpen && onOpen(); 43 | }; 44 | 45 | const handleClose = () => { 46 | setIsOpen(false); 47 | onClose && onClose(); 48 | }; 49 | 50 | return ( 51 | 52 | 53 | {children[0]} 54 | 55 | 65 | {children[1]} 66 | 67 | 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /web/src/components/UI/atoms/input.tsx: -------------------------------------------------------------------------------- 1 | import { Classes, IconName, InputGroup } from "@blueprintjs/core"; 2 | import React from "react"; 3 | import styled from "styled-components"; 4 | 5 | const DEFAULT_HEIGHT = "20px"; 6 | const DEFAULT_WIDTH = "100%"; 7 | const ICON_WIDTH = "20px"; 8 | 9 | const Container = styled.div<{ 10 | height: string; 11 | width: string; 12 | hasIcon: boolean; 13 | }>` 14 | background: ${(props) => props.theme.colors.controls.background}; 15 | width: ${(props) => props.width}; 16 | 17 | & .${Classes.INPUT_GROUP} { 18 | border: none; 19 | span { 20 | display: inline-block; 21 | width: ${ICON_WIDTH}; 22 | } 23 | input { 24 | display: inline-block; 25 | height: ${(props) => props.height}; 26 | width: ${(props) => 27 | props.hasIcon ? `calc(100% - ${ICON_WIDTH})` : `100%`}; 28 | vertical-align: super; 29 | border: 1px solid ${(props) => props.theme.colors.border}; 30 | border-radius: ${(props) => props.theme.borderRadius.M}; 31 | font-size: 12px; 32 | padding: 14px 8px; 33 | 34 | &:focus { 35 | outline: none; 36 | } 37 | } 38 | } 39 | `; 40 | 41 | type PropsType = { 42 | name?: string; 43 | value?: string; 44 | type?: string; 45 | leftIcon?: IconName; 46 | placeholder?: string; 47 | rightElement?: JSX.Element; 48 | onChange?: (e: any) => void; 49 | height?: string; 50 | width?: string; 51 | className?: string; 52 | }; 53 | 54 | export default function Input(props: PropsType) { 55 | const { 56 | className, 57 | name, 58 | value, 59 | type = "text", 60 | leftIcon, 61 | placeholder, 62 | rightElement, 63 | onChange, 64 | height, 65 | width, 66 | } = props; 67 | 68 | return ( 69 | 75 | 84 | 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /web/src/components/UI/atoms/select.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from "react"; 2 | import styled from "styled-components"; 3 | import SelectComponent from "@mui/material/Select"; 4 | import MenuItem from "@mui/material/MenuItem"; 5 | import { useMemo } from "react"; 6 | import CommonUtils from "../../../utils/common-utils"; 7 | import _ from "lodash"; 8 | 9 | const Container = styled.div``; 10 | 11 | const StyledSelect = styled(SelectComponent)` 12 | width: 100%; 13 | font-size: 12px !important; 14 | 15 | & > .MuiSelect-select { 16 | padding: 7px 10px; 17 | } 18 | 19 | .MuiMenuItem-root { 20 | font-size: 12px !important; 21 | } 22 | `; 23 | 24 | type Option = { 25 | label: string; 26 | value: string; 27 | }; 28 | 29 | type PropsType = { 30 | options: Array