├── .npmrc
├── .eslintignore
├── env
├── .env-dev.js
├── .env-nsis.js
└── .env.js
├── docs
├── images
│ ├── screen-logs.png
│ ├── screen-inspector.png
│ ├── screen-new-session.png
│ ├── screen-start-simple.png
│ ├── screen-recorder-empty.png
│ ├── screen-start-advanced.png
│ ├── screen-start-presets.png
│ ├── screen-recorder-detail.png
│ ├── screen-inspector-and-logs.png
│ └── screen-recorder-boilerplate.png
└── style.md
├── app
├── renderer
│ ├── images
│ │ ├── appium_logo.png
│ │ ├── appium_icon_big.png
│ │ ├── appium_small_light.png
│ │ ├── appium_small_magenta.png
│ │ ├── sauce_icon.svg
│ │ └── headspin_logo.svg
│ ├── components
│ │ ├── StartServer
│ │ │ ├── SimpleTab.css
│ │ │ ├── StartButton.css
│ │ │ ├── DeletePresetButton.js
│ │ │ ├── SavePresetButton.js
│ │ │ ├── StartServer.css
│ │ │ ├── SimpleTab.js
│ │ │ ├── StartButton.js
│ │ │ ├── shared.js
│ │ │ ├── AdvancedTab.css
│ │ │ ├── StartServer.js
│ │ │ ├── PresetsTab.css
│ │ │ └── PresetsTab.js
│ │ ├── Config
│ │ │ ├── Config.css
│ │ │ └── Config.js
│ │ ├── Stub.js
│ │ ├── WrongFolder
│ │ │ ├── WrongFolderStyles.css
│ │ │ └── WrongFolder.js
│ │ ├── Session
│ │ │ ├── ServerTabAutomatic.js
│ │ │ ├── ServerTabBitbar.js
│ │ │ ├── ServerTabBrowserstack.js
│ │ │ ├── AdvancedServerParams.js
│ │ │ ├── ServerTabHeadspin.js
│ │ │ ├── ServerTabTestobject.js
│ │ │ ├── ServerTabCustom.js
│ │ │ ├── AttachToSession.js
│ │ │ ├── FormattedCaps.js
│ │ │ ├── ServerTabSauce.js
│ │ │ ├── Session.css
│ │ │ ├── SavedSessions.js
│ │ │ ├── Session.js
│ │ │ └── NewSessionForm.js
│ │ ├── Inspector
│ │ │ ├── LocatorTestModal.js
│ │ │ ├── shared.js
│ │ │ ├── ElementLocator.js
│ │ │ ├── HighlighterRect.js
│ │ │ ├── SourceScrollButtons.js
│ │ │ ├── Source.js
│ │ │ ├── LocatedElements.js
│ │ │ ├── RecordedActions.js
│ │ │ ├── SelectedElement.js
│ │ │ ├── HighlighterRects.js
│ │ │ └── Screenshot.js
│ │ └── ServerMonitor
│ │ │ └── ServerMonitor.css
│ ├── .eslintrc
│ ├── actions
│ │ ├── Updater.js
│ │ ├── Stub.js
│ │ ├── Config.js
│ │ ├── shared.js
│ │ ├── ServerMonitor.js
│ │ └── StartServer.js
│ ├── store
│ │ ├── configureStore.js
│ │ ├── configureStore.production.js
│ │ └── configureStore.development.js
│ ├── containers
│ │ ├── App.js
│ │ ├── ConfigPage.js
│ │ ├── SessionPage.js
│ │ ├── InspectorPage.js
│ │ ├── StartServerPage.js
│ │ └── ServerMonitorPage.js
│ ├── reducers
│ │ ├── Updater.js
│ │ ├── index.js
│ │ ├── Stub.js
│ │ ├── Config.js
│ │ ├── StartServer.js
│ │ └── ServerMonitor.js
│ ├── lib
│ │ └── client-frameworks
│ │ │ ├── index.js
│ │ │ ├── ruby.js
│ │ │ ├── js-wd.js
│ │ │ ├── python.js
│ │ │ ├── js-wdio.js
│ │ │ ├── robot.js
│ │ │ ├── framework.js
│ │ │ └── java.js
│ ├── routes.js
│ ├── index.html
│ ├── styles
│ │ ├── github-gist-theme.global.css
│ │ └── app.global.css
│ ├── index.js
│ └── util.js
├── env.js
├── shared
│ └── cloud-providers
│ │ ├── config.js
│ │ └── index.js
├── main
│ ├── auto-updater
│ │ ├── update-checker.js
│ │ ├── config.js
│ │ └── index.js
│ ├── helpers.js
│ └── main.development.js
└── settings.js
├── .gitignore
├── webpack.config.node.js
├── .vscode
└── launch.json
├── appveyor.yml
├── test
├── e2e
│ ├── pages
│ │ ├── base-page-object.js
│ │ ├── main-page-object.js
│ │ └── inspector-page-object.js
│ ├── main-e2e.test.js
│ └── inspector-e2e.test.js
├── integration
│ ├── update-checker.integration-test.js
│ └── appium-method-handler.integration-test.js
└── unit
│ └── inspector-specs.js
├── .eslintrc
├── .babelrc
├── LICENSE
├── .github
└── ISSUE_TEMPLATE.md
├── server.js
├── webpack.config.base.js
├── webpack.config.electron.js
├── webpack.config.development.js
├── webpack.config.production.js
├── .travis.yml
├── package.js
└── CONDUCT.md
/.npmrc:
--------------------------------------------------------------------------------
1 | package-lock=true
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | main.js
2 |
--------------------------------------------------------------------------------
/env/.env-dev.js:
--------------------------------------------------------------------------------
1 | export default {
2 |
3 | };
--------------------------------------------------------------------------------
/docs/images/screen-logs.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ml-archive/appium-desktop/master/docs/images/screen-logs.png
--------------------------------------------------------------------------------
/docs/images/screen-inspector.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ml-archive/appium-desktop/master/docs/images/screen-inspector.png
--------------------------------------------------------------------------------
/app/renderer/images/appium_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ml-archive/appium-desktop/master/app/renderer/images/appium_logo.png
--------------------------------------------------------------------------------
/docs/images/screen-new-session.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ml-archive/appium-desktop/master/docs/images/screen-new-session.png
--------------------------------------------------------------------------------
/docs/images/screen-start-simple.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ml-archive/appium-desktop/master/docs/images/screen-start-simple.png
--------------------------------------------------------------------------------
/docs/images/screen-recorder-empty.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ml-archive/appium-desktop/master/docs/images/screen-recorder-empty.png
--------------------------------------------------------------------------------
/docs/images/screen-start-advanced.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ml-archive/appium-desktop/master/docs/images/screen-start-advanced.png
--------------------------------------------------------------------------------
/docs/images/screen-start-presets.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ml-archive/appium-desktop/master/docs/images/screen-start-presets.png
--------------------------------------------------------------------------------
/env/.env-nsis.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Env configuration for NSIS target
3 | */
4 | module.exports = {
5 | NO_AUTO_UPDATE: true
6 | };
7 |
8 |
--------------------------------------------------------------------------------
/app/renderer/images/appium_icon_big.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ml-archive/appium-desktop/master/app/renderer/images/appium_icon_big.png
--------------------------------------------------------------------------------
/docs/images/screen-recorder-detail.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ml-archive/appium-desktop/master/docs/images/screen-recorder-detail.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | dist
4 | release
5 | main.js
6 | main.js.map
7 |
8 | *.log
9 | .DS_Store
10 |
11 | Certificates.p12
12 |
--------------------------------------------------------------------------------
/app/renderer/images/appium_small_light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ml-archive/appium-desktop/master/app/renderer/images/appium_small_light.png
--------------------------------------------------------------------------------
/docs/images/screen-inspector-and-logs.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ml-archive/appium-desktop/master/docs/images/screen-inspector-and-logs.png
--------------------------------------------------------------------------------
/env/.env.js:
--------------------------------------------------------------------------------
1 | let env = {};
2 |
3 | if (process.env.TARGET) {
4 | env = require(`./.env-${process.env.TARGET}`);
5 | }
6 |
7 | export default env;
--------------------------------------------------------------------------------
/app/renderer/images/appium_small_magenta.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ml-archive/appium-desktop/master/app/renderer/images/appium_small_magenta.png
--------------------------------------------------------------------------------
/docs/images/screen-recorder-boilerplate.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ml-archive/appium-desktop/master/docs/images/screen-recorder-boilerplate.png
--------------------------------------------------------------------------------
/app/renderer/components/StartServer/SimpleTab.css:
--------------------------------------------------------------------------------
1 | .form {
2 | width: 300px;
3 | margin: 0px auto;
4 | }
5 |
6 | .form :global(.ant-input-wrapper) {
7 | margin: 15px auto;
8 | }
9 |
--------------------------------------------------------------------------------
/app/renderer/components/Config/Config.css:
--------------------------------------------------------------------------------
1 | .container {
2 | position: absolute;
3 | height: 100%;
4 | width: 100%;
5 | padding: 4em 2em 2em 2em;
6 | }
7 |
8 | .row {
9 | margin-bottom: 16px;
10 | }
--------------------------------------------------------------------------------
/app/renderer/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | "no-unused-vars": ["error", {"varsIgnorePattern": "React"}],
4 | "react/jsx-uses-vars": 1
5 | },
6 | "globals": {
7 | "document": "false"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/app/renderer/actions/Updater.js:
--------------------------------------------------------------------------------
1 | export const SET_UPDATE_STATE = 'SET_UPDATE_STATE';
2 |
3 | export function setUpdateState (updateState) {
4 | return (dispatch) => {
5 | dispatch({type: SET_UPDATE_STATE, updateState});
6 | };
7 | }
--------------------------------------------------------------------------------
/app/renderer/actions/Stub.js:
--------------------------------------------------------------------------------
1 | export const NAME_OF_ACTION_1 = 'NAME_OF_ACTION_1';
2 | export const NAME_OF_ACTION_2 = 'NAME_OF_ACTION_2';
3 |
4 | export function someAction () {
5 | return async (/*dispatch, getState*/) => {
6 |
7 | };
8 | }
--------------------------------------------------------------------------------
/app/renderer/components/Stub.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | export default class Stub extends Component {
4 |
5 | render () {
6 | const { someProp } = this.props;
7 |
8 | return
9 | Hello {someProp}!
10 |
;
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/app/renderer/store/configureStore.js:
--------------------------------------------------------------------------------
1 | if (process.env.NODE_ENV === 'production') {
2 | module.exports = require('./configureStore.production'); // eslint-disable-line global-require
3 | } else {
4 | module.exports = require('./configureStore.development'); // eslint-disable-line global-require
5 | }
6 |
--------------------------------------------------------------------------------
/app/renderer/containers/App.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 |
3 | export default class App extends Component {
4 | static propTypes = {
5 | children: PropTypes.element.isRequired
6 | };
7 |
8 | render () {
9 | return this.props.children;
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/app/env.js:
--------------------------------------------------------------------------------
1 | let env = {};
2 |
3 | // DefinePlugin doesn't work in dev so if _ENV_ is undefined, assume it's a development environment
4 | if (typeof(_ENV_) === "undefined") {
5 | env = require('../env/.env-dev');
6 | } else {
7 | env = _ENV_; // eslint-disable-line no-undef
8 | }
9 |
10 | export default env;
--------------------------------------------------------------------------------
/app/shared/cloud-providers/config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | saucelabs: {
3 | label: 'Sauce Labs',
4 | },
5 | testobject: {
6 | label: 'TestObject'
7 | },
8 | browserstack: {
9 | label: 'BrowserStack',
10 | },
11 | headspin: {
12 | label: 'HeadSpin',
13 | },
14 | bitbar: {
15 | label: 'Bitbar'
16 | }
17 | };
--------------------------------------------------------------------------------
/webpack.config.node.js:
--------------------------------------------------------------------------------
1 | // for babel-plugin-webpack-loaders
2 | require('babel-register');
3 | const devConfigs = require('./webpack.config.development');
4 |
5 | module.exports = {
6 | output: {
7 | libraryTarget: 'commonjs2'
8 | },
9 | module: {
10 | loaders: devConfigs.module.loaders.slice(1) // remove babel-loader
11 | }
12 | };
13 |
--------------------------------------------------------------------------------
/app/renderer/reducers/Updater.js:
--------------------------------------------------------------------------------
1 | import { SET_UPDATE_STATE } from '../actions/Updater';
2 |
3 | const INITIAL_STATE = {};
4 |
5 | export default function session (state = INITIAL_STATE, action) {
6 | switch (action.type) {
7 | case SET_UPDATE_STATE:
8 | return {...action.updateState};
9 | default:
10 | return {...state};
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/app/main/auto-updater/update-checker.js:
--------------------------------------------------------------------------------
1 | import request from 'request-promise';
2 | import { getFeedUrl } from './config';
3 |
4 | export async function checkUpdate (currentVersion) {
5 | try {
6 | const res = await request.get(getFeedUrl(currentVersion));
7 | if (res) {
8 | return JSON.parse(res);
9 | }
10 | } catch (ign) { }
11 |
12 | return false;
13 | }
--------------------------------------------------------------------------------
/app/main/auto-updater/config.js:
--------------------------------------------------------------------------------
1 | const config = {
2 | baseFeedUrl: `https://hazel-server-pxufsrwofl.now.sh`,
3 | getFeedUrl (version) {
4 | let platform = process.platform;
5 | if (platform.toLowerCase() === 'linux') {
6 | platform = 'AppImage';
7 | }
8 | return `${config.baseFeedUrl}/update/${platform}/${version}`;
9 | }
10 | };
11 |
12 | export default config;
--------------------------------------------------------------------------------
/app/renderer/components/StartServer/StartButton.css:
--------------------------------------------------------------------------------
1 | .startButton {
2 | width: 100%;
3 | margin-top: 10px;
4 | padding: 10px !important;
5 | height: auto;
6 | font-size: 1.3em !important;
7 | }
8 |
9 | .configButton {
10 | width: 100%;
11 | margin-top: 10px;
12 | padding: 10px !important;
13 | height: auto;
14 | font-size: 1.3em !important;
15 | }
16 |
--------------------------------------------------------------------------------
/app/renderer/components/WrongFolder/WrongFolderStyles.css:
--------------------------------------------------------------------------------
1 | .wrong-folder {
2 | display: flex;
3 | flex-direction: column;
4 | padding: 1em;
5 | justify-content: center;
6 | height: 100%;
7 | }
8 |
9 | .wrong-folder div {
10 | flex-grow: 1;
11 | text-align: center;
12 | margin-bottom: 1em;
13 | font-size: 16px;
14 | color: red;
15 | }
16 |
17 | .wrong-folder div button {
18 | width: 75%;
19 | margin-bottom: 1em;
20 | }
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "name": "dev",
6 | "type": "node",
7 | "request": "launch",
8 | "cwd": "${workspaceRoot}",
9 | "runtimeExecutable": "npm",
10 | "env": {
11 | "NODE_ENV": "development"
12 | },
13 | "runtimeArgs": [
14 | "run-script", "dev"
15 | ],
16 | "port": 5858
17 | }
18 | ]
19 | }
--------------------------------------------------------------------------------
/appveyor.yml:
--------------------------------------------------------------------------------
1 | environment:
2 | nodejs_version: "9"
3 | GH_TOKEN:
4 | secure: fyB6CRcrHbroxaBvzN6aPHUEhHGc3ljbbTPtEqruaDVe/iO8/dZn4LOVNS/fAlSX
5 | install:
6 | - ps: Install-Product node $env:nodejs_version
7 | - npm install -g npm
8 | - npm ci
9 |
10 | test_script:
11 | - node --version
12 | - npm --version
13 | - npm run package-ci
14 | - npm run package-nsis
15 |
16 | build: off
17 |
18 | artifacts:
19 | - path: 'release\Appium*.exe'
20 | - path: 'release\*.yml'
21 |
22 |
--------------------------------------------------------------------------------
/test/e2e/pages/base-page-object.js:
--------------------------------------------------------------------------------
1 | export default class BasePage {
2 |
3 | constructor (client) {
4 | this.client = client;
5 | this.originalUrl = client.url();
6 | }
7 |
8 | async open (path) {
9 | const url = await this.client.url();
10 | this.originalUrl = url.value;
11 | await this.client.url(`${this.originalUrl}${path}`);
12 | }
13 |
14 | async goHome () {
15 | if (this.originalUrl) {
16 | await this.client.url(this.originalUrl);
17 | }
18 | }
19 |
20 | }
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "extends": "appium",
4 | "plugins": [
5 | "import",
6 | "react"
7 | ],
8 | "env": {
9 | "browser": true,
10 | "mocha": true,
11 | "node": true
12 | },
13 | "ecmafeatures": {
14 | "jsx": true
15 | },
16 | "settings": {
17 | "import/resolver": "webpack"
18 | },
19 | "rules": {
20 | "import/no-unresolved": [2, {"ignore": ["electron"]}],
21 | "babel/arrow-parens": 0,
22 | "require-await": 2
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/app/renderer/containers/ConfigPage.js:
--------------------------------------------------------------------------------
1 | import { bindActionCreators } from 'redux';
2 | import { connect } from 'react-redux';
3 | import * as ConfigActions from '../actions/Config';
4 | import ConfigPage from '../components/Config/Config';
5 |
6 | function mapStateToProps (state) {
7 | return state.config;
8 | }
9 |
10 | function mapDispatchToProps (dispatch) {
11 | return bindActionCreators(ConfigActions, dispatch);
12 | }
13 |
14 | export default connect(mapStateToProps, mapDispatchToProps)(ConfigPage);
15 |
--------------------------------------------------------------------------------
/app/renderer/containers/SessionPage.js:
--------------------------------------------------------------------------------
1 | import { bindActionCreators } from 'redux';
2 | import { connect } from 'react-redux';
3 | import * as SessionActions from '../actions/Session';
4 | import Session from '../components/Session/Session';
5 |
6 | function mapStateToProps (state) {
7 | return state.session;
8 | }
9 |
10 | function mapDispatchToProps (dispatch) {
11 | return bindActionCreators(SessionActions, dispatch);
12 | }
13 |
14 | export default connect(mapStateToProps, mapDispatchToProps)(Session);
15 |
--------------------------------------------------------------------------------
/app/renderer/store/configureStore.production.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware } from 'redux';
2 | import thunk from 'redux-thunk';
3 | import { hashHistory } from 'react-router';
4 | import { routerMiddleware } from 'react-router-redux';
5 | import rootReducer from '../reducers';
6 |
7 | const router = routerMiddleware(hashHistory);
8 |
9 | const enhancer = applyMiddleware(thunk, router);
10 |
11 | export default function configureStore (initialState) {
12 | return createStore(rootReducer, initialState, enhancer);
13 | }
14 |
--------------------------------------------------------------------------------
/app/renderer/containers/InspectorPage.js:
--------------------------------------------------------------------------------
1 | import { bindActionCreators } from 'redux';
2 | import { connect } from 'react-redux';
3 | import * as InspectorActions from '../actions/Inspector';
4 | import InspectorPage from '../components/Inspector/Inspector';
5 |
6 | function mapStateToProps (state) {
7 | return state.inspector;
8 | }
9 |
10 | function mapDispatchToProps (dispatch) {
11 | return bindActionCreators(InspectorActions, dispatch);
12 | }
13 |
14 | export default connect(mapStateToProps, mapDispatchToProps)(InspectorPage);
15 |
--------------------------------------------------------------------------------
/app/renderer/lib/client-frameworks/index.js:
--------------------------------------------------------------------------------
1 | import JsWdFramework from './js-wd';
2 | import JsWdIoFramework from './js-wdio';
3 | import JavaFramework from './java';
4 | import PythonFramework from './python';
5 | import RubyFramework from './ruby';
6 | import RobotFramework from './robot';
7 |
8 | const frameworks = {
9 | jsWd: JsWdFramework,
10 | jsWdIo: JsWdIoFramework,
11 | java: JavaFramework,
12 | python: PythonFramework,
13 | ruby: RubyFramework,
14 | robot: RobotFramework,
15 | };
16 |
17 | export default frameworks;
18 |
--------------------------------------------------------------------------------
/app/renderer/containers/StartServerPage.js:
--------------------------------------------------------------------------------
1 | import { bindActionCreators } from 'redux';
2 | import { connect } from 'react-redux';
3 | import * as StartServerActions from '../actions/StartServer';
4 | import StartServer from '../components/StartServer/StartServer';
5 |
6 | function mapStateToProps (state) {
7 | return state.startServer;
8 | }
9 |
10 | function mapDispatchToProps (dispatch) {
11 | return bindActionCreators(StartServerActions, dispatch);
12 | }
13 |
14 | export default connect(mapStateToProps, mapDispatchToProps)(StartServer);
15 |
--------------------------------------------------------------------------------
/app/renderer/containers/ServerMonitorPage.js:
--------------------------------------------------------------------------------
1 | import { bindActionCreators } from 'redux';
2 | import { connect } from 'react-redux';
3 | import * as ServerActions from '../actions/ServerMonitor';
4 | import ServerMonitor from '../components/ServerMonitor/ServerMonitor';
5 |
6 | function mapStateToProps (state) {
7 | return state.serverMonitor;
8 | }
9 |
10 | function mapDispatchToProps (dispatch) {
11 | return bindActionCreators(ServerActions, dispatch);
12 | }
13 |
14 | export default connect(mapStateToProps, mapDispatchToProps)(ServerMonitor);
15 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["es2015", "stage-0", "react"],
3 | "plugins": ["add-module-exports"],
4 | "env": {
5 | "production": {
6 | "presets": ["react-optimize"],
7 | "plugins": [
8 | "babel-plugin-transform-remove-console",
9 | "babel-plugin-transform-remove-debugger",
10 | "babel-plugin-dev-expression"
11 | ]
12 | },
13 | "development": {
14 | "presets": ["react-hmre"]
15 | },
16 | "test": {
17 | "plugins": [
18 | ["webpack-loaders", { "config": "webpack.config.node.js", "verbose": false }]
19 | ]
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/app/renderer/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import { routerReducer as routing } from 'react-router-redux';
3 | import startServer from './StartServer';
4 | import serverMonitor from './ServerMonitor';
5 | import session from './Session';
6 | import inspector from './Inspector';
7 | import updater from './Updater';
8 | import config from './Config';
9 |
10 | // create our root reducer
11 | const rootReducer = combineReducers({
12 | routing,
13 | startServer,
14 | serverMonitor,
15 | session,
16 | inspector,
17 | updater,
18 | config,
19 | });
20 |
21 | export default rootReducer;
22 |
--------------------------------------------------------------------------------
/app/renderer/reducers/Stub.js:
--------------------------------------------------------------------------------
1 | import {
2 | NAME_OF_ACTION_1, NAME_OF_ACTION_2
3 | } from '../actions/Stub';
4 |
5 |
6 | const INITIAL_STATE = {
7 | someVariable: false,
8 | someOtherVariable: 'hello',
9 | };
10 |
11 | export default function inspector (state=INITIAL_STATE, action) {
12 | switch (action.type) {
13 | case NAME_OF_ACTION_1:
14 | return {
15 | ...state,
16 | someVariable: true
17 | };
18 |
19 | case NAME_OF_ACTION_2:
20 | return {
21 | ...state,
22 | someOtherVariable: 'world',
23 | };
24 |
25 | default:
26 | return {...state};
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2012-2016 JS Foundation and other contributors
2 |
3 | Licensed under the Apache License, Version 2.0 (the "License");
4 | you may not use this file except in compliance with the License.
5 | You may obtain a copy of the License at
6 |
7 | http://www.apache.org/licenses/LICENSE-2.0
8 |
9 | Unless required by applicable law or agreed to in writing, software
10 | distributed under the License is distributed on an "AS IS" BASIS,
11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | See the License for the specific language governing permissions and
13 | limitations under the License.
14 |
--------------------------------------------------------------------------------
/test/e2e/pages/main-page-object.js:
--------------------------------------------------------------------------------
1 | import BasePage from './base-page-object';
2 |
3 | export default class MainPage extends BasePage {
4 |
5 | static selectors = {
6 | startServerButton: '#startServerBtn',
7 | startNewSessionButton: '#startNewSessionBtn',
8 | serverMonitorContainer: '#serverMonitorContainer',
9 | }
10 |
11 | constructor (client) {
12 | super(client);
13 | Object.assign(this, MainPage.selectors);
14 | }
15 |
16 | async startServer () {
17 | await this.client.click(this.startServerButton);
18 | }
19 |
20 | async startNewSession () {
21 | await this.client.click(this.startNewSessionButton);
22 | }
23 | }
--------------------------------------------------------------------------------
/app/settings.js:
--------------------------------------------------------------------------------
1 | import settings from 'electron-settings';
2 | import { PRESETS } from './renderer/actions/StartServer';
3 | import { SAVED_SESSIONS, SERVER_ARGS, SESSION_SERVER_PARAMS,
4 | SESSION_SERVER_TYPE } from './renderer/actions/Session';
5 | import { SAVED_FRAMEWORK } from './renderer/actions/Inspector';
6 |
7 | // set default persistent settings
8 | // do it here because settings are kind of like state!
9 | settings.defaults({
10 | [PRESETS]: {},
11 | [SAVED_SESSIONS]: [],
12 | [SERVER_ARGS]: null,
13 | [SESSION_SERVER_PARAMS]: null,
14 | [SESSION_SERVER_TYPE]: null,
15 | [SAVED_FRAMEWORK]: 'java',
16 | });
17 |
18 | export default settings;
19 |
--------------------------------------------------------------------------------
/app/renderer/components/Session/ServerTabAutomatic.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Form, Card } from 'antd';
3 | import SessionStyles from './Session.css';
4 |
5 | const FormItem = Form.Item;
6 |
7 | export default class ServerTabSauce extends Component {
8 |
9 | render () {
10 |
11 | const {server} = this.props;
12 |
13 | return ;
22 | }
23 | }
24 |
25 |
--------------------------------------------------------------------------------
/app/renderer/components/StartServer/DeletePresetButton.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { Button } from 'antd';
3 |
4 | import styles from './StartButton.css';
5 |
6 | export default class DeletePresetButton extends Component {
7 | static propTypes = {
8 | presetDeleting: PropTypes.bool.isRequired,
9 | deletePreset: PropTypes.func.isRequired,
10 | }
11 |
12 | render () {
13 | const {presetDeleting, deletePreset} = this.props;
14 |
15 | return (
16 |
17 |
21 |
22 | );
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/app/renderer/components/Session/ServerTabBitbar.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Form, Input, Row, Col } from 'antd';
3 |
4 | const FormItem = Form.Item;
5 |
6 | export default class ServerTabBitbar extends Component {
7 |
8 | render () {
9 |
10 | const { server, setServerParam } = this.props;
11 |
12 | return ;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/app/renderer/components/StartServer/SavePresetButton.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { Button } from 'antd';
3 |
4 | import styles from './StartButton.css';
5 |
6 | export default class SavePresetButton extends Component {
7 | static propTypes = {
8 | presetSaving: PropTypes.bool.isRequired,
9 | savePreset: PropTypes.func.isRequired,
10 | }
11 |
12 | render () {
13 | const {presetSaving, savePreset} = this.props;
14 |
15 | return (
16 |
17 |
21 |
22 |
23 | );
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/app/renderer/routes.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Route, IndexRoute } from 'react-router';
3 | import App from './containers/App';
4 | import StartServerPage from './containers/StartServerPage';
5 | import ServerMonitorPage from './containers/ServerMonitorPage';
6 | import SessionPage from './containers/SessionPage';
7 | import InspectorPage from './containers/InspectorPage';
8 | import ConfigPage from './containers/ConfigPage';
9 |
10 | export default (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | );
19 |
--------------------------------------------------------------------------------
/app/renderer/components/WrongFolder/WrongFolder.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Button } from 'antd';
3 | import { ipcRenderer } from 'electron';
4 | import WrongFolderStyles from './WrongFolderStyles.css';
5 |
6 | export default class WrongFolder extends Component {
7 | // This tells the main thread to move this to applications folder which will cause the app to close and restart
8 | moveToApplicationsFolder () {
9 | ipcRenderer.send('appium-move-to-applications-folder');
10 | }
11 |
12 | render () {
13 | return
14 |
15 |
Appium Desktop should be run from the Applications folder
16 |
17 |
18 |
;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/app/renderer/reducers/Config.js:
--------------------------------------------------------------------------------
1 | import {
2 | SET_ENVIRONMENT_VARIABLE, SET_ENVIRONMENT_VARIABLES
3 | } from '../actions/Config';
4 |
5 |
6 | const INITIAL_STATE = {
7 | environmentVariables: {},
8 | defaultEnvironmentVariables: {},
9 | };
10 |
11 | export default function inspector (state=INITIAL_STATE, action) {
12 | switch (action.type) {
13 | case SET_ENVIRONMENT_VARIABLE:
14 | return {
15 | ...state,
16 | environmentVariables: {
17 | ...state.environmentVariables,
18 | [action.name]: action.value,
19 | }
20 | };
21 |
22 | case SET_ENVIRONMENT_VARIABLES:
23 | return {
24 | ...state,
25 | environmentVariables: action.savedEnvironmentVariables || {},
26 | defaultEnvironmentVariables: action.defaultEnvironmentVariables,
27 | };
28 |
29 | default:
30 | return {...state};
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ## Appium or Appium Desktop?
2 |
3 | You are reporting an issue at the Appium Desktop repository. Appium Desktop is a wrapper around Appium. If you are having trouble running tests, it is much more likely that the problem you are encountering is not a problem with Appium Desktop but with Appium. For that reason we require you to have tried your testcase with Appium CLI before reporting issues. Check the checkbox below to confirm that you have proven your issue does _not_ reproduce on Appium itself:
4 |
5 | - [ ] I have verified that my issue does not occur with Appium and should be investigated as an Appium Desktop issue
6 |
7 | ## The problem
8 |
9 | _Replace this section with a description of your issue_
10 |
11 | ## Environment
12 |
13 | - I am running Appium Desktop version __.
14 | - I am on (pick one):
15 | - [ ] Mac
16 | - [ ] Windows
17 | - [ ] Linux
18 |
--------------------------------------------------------------------------------
/app/renderer/components/StartServer/StartServer.css:
--------------------------------------------------------------------------------
1 | .container {
2 | position: absolute;
3 | height: 100%;
4 | width: 100%;
5 | display: flex;
6 | align-items: center;
7 | justify-content: center;
8 | }
9 |
10 | .tabs {
11 | width: 100%;
12 | border-bottom: 1px solid #662d91;
13 | margin-bottom: 20px;
14 | display: flex;
15 | justify-content: center;
16 | }
17 |
18 | .tabButtons {
19 | position: relative;
20 | top: 12px;
21 | }
22 |
23 | .formAndLogo {
24 | width: 100%;
25 | display: flex;
26 | flex-direction: column;
27 | justify-content: center;
28 | }
29 |
30 | .logo {
31 | width: 300px;
32 | margin-bottom: 15px;
33 | margin: 0px auto;
34 | }
35 |
36 | .failureMsg {
37 | width: 100%;
38 | padding: 5px;
39 | border: 2px solid #e47777;
40 | background-color: #d89494;
41 | color: #fff;
42 | font-weight: bold;
43 | margin: 10px 0px;
44 | }
45 |
--------------------------------------------------------------------------------
/app/renderer/actions/Config.js:
--------------------------------------------------------------------------------
1 | import { ipcRenderer } from 'electron';
2 |
3 | export const SET_ENVIRONMENT_VARIABLE = 'SET_ENVIRONMENT_VARIABLE';
4 | export const SET_ENVIRONMENT_VARIABLES = 'SET_ENVIRONMENT_VARIABLES';
5 | export const GET_ENVIRONMENT_VARIABLES = 'GET_ENVIRONMENT_VARIABLES';
6 | export const SAVE_ENVIRONMENT_VARIABLES = 'SAVE_ENVIRONMENT_VARIABLES';
7 |
8 | export function setEnvironmentVariable (name, value) {
9 | return (dispatch) => {
10 | dispatch({type: SET_ENVIRONMENT_VARIABLE, name, value});
11 | };
12 | }
13 |
14 | export function getEnvironmentVariables () {
15 | return (dispatch) => {
16 | dispatch({type: GET_ENVIRONMENT_VARIABLES});
17 | ipcRenderer.send('appium-get-env');
18 | ipcRenderer.once('appium-env', (evt, env) => {
19 | const {defaultEnvironmentVariables, savedEnvironmentVariables} = env;
20 | dispatch({type: SET_ENVIRONMENT_VARIABLES, defaultEnvironmentVariables, savedEnvironmentVariables});
21 | });
22 | };
23 | }
--------------------------------------------------------------------------------
/app/renderer/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Appium
6 |
7 |
8 |
18 |
19 |
20 |
21 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/app/renderer/store/configureStore.development.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware, compose } from 'redux';
2 | import thunk from 'redux-thunk';
3 | import createLogger from 'redux-logger';
4 | import { hashHistory } from 'react-router';
5 | import { routerMiddleware } from 'react-router-redux';
6 | import rootReducer from '../reducers';
7 |
8 | const logger = createLogger({
9 | level: 'info',
10 | collapsed: true,
11 | });
12 |
13 | const router = routerMiddleware(hashHistory);
14 |
15 | const enhancer = compose(
16 | applyMiddleware(thunk, router, logger),
17 | window.devToolsExtension ? window.devToolsExtension() : (noop) => noop
18 | );
19 |
20 | export default function configureStore (initialState) {
21 | const store = createStore(rootReducer, initialState, enhancer);
22 |
23 | if (module.hot) {
24 | module.hot.accept('../reducers', () =>
25 | store.replaceReducer(require('../reducers')) // eslint-disable-line global-require
26 | );
27 | }
28 |
29 | return store;
30 | }
31 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | /* eslint no-console: 0 */
2 | /* eslint-disable promise/prefer-await-to-callbacks */
3 |
4 | import express from 'express';
5 | import webpack from 'webpack';
6 | import webpackDevMiddleware from 'webpack-dev-middleware';
7 | import webpackHotMiddleware from 'webpack-hot-middleware';
8 |
9 | import config from './webpack.config.development';
10 |
11 | const app = express();
12 | const compiler = webpack(config);
13 | const PORT = 3000;
14 |
15 | const wdm = webpackDevMiddleware(compiler, {
16 | publicPath: config.output.publicPath,
17 | stats: {
18 | colors: true
19 | }
20 | });
21 |
22 | app.use(wdm);
23 |
24 | app.use(webpackHotMiddleware(compiler));
25 |
26 | const server = app.listen(PORT, 'localhost', (err) => {
27 | if (err) {
28 | console.error(err);
29 | return;
30 | }
31 |
32 | console.log(`Listening at http://localhost:${PORT}`);
33 | });
34 |
35 | process.on('SIGTERM', () => {
36 | console.log('Stopping dev server');
37 | wdm.close();
38 | server.close(() => {
39 | process.exit(0);
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/webpack.config.base.js:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 |
3 | export default {
4 | module: {
5 | loaders: [{
6 | test: /\.jsx?$/,
7 | loaders: ['babel-loader'],
8 | exclude: /node_modules/
9 | }, {
10 | test: /\.json$/,
11 | loader: 'json-loader'
12 | }, {
13 | test: /\.(jpe?g|png|gif|svg)$/i,
14 | loaders: [
15 | 'file?hash=sha512&digest=hex&name=[hash].[ext]',
16 | 'image-webpack?bypassOnDebug&optimizationLevel=7&interlaced=false'
17 | ]
18 | }, {
19 | test: [/node_modules[\\\/](?:electron-settings|key-path-helpers)[\\\/]lib[\\\/](?:.+).js/],
20 | loaders: ['babel-loader']
21 | }]
22 | },
23 | output: {
24 | path: path.join(__dirname, 'dist'),
25 | filename: 'bundle.js',
26 | libraryTarget: 'commonjs2'
27 | },
28 | resolve: {
29 | extensions: ['', '.js', '.jsx', '.json'],
30 | packageMains: ['webpack', 'browser', 'web', 'browserify', ['jam', 'main'], 'main']
31 | },
32 | node: {
33 | __dirname: false,
34 | __filename: false
35 | },
36 | plugins: [
37 |
38 | ],
39 | externals: [
40 |
41 | ]
42 | };
43 |
--------------------------------------------------------------------------------
/app/shared/cloud-providers/index.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash';
2 | import settings from 'electron-settings';
3 | import config from './config';
4 |
5 | class CloudProvider {
6 |
7 | constructor (providerName, providerData) {
8 | const {label} = providerData;
9 | this.name = providerName;
10 | this.label = label;
11 | }
12 |
13 | getSettingsKey (keyName) {
14 | return `Providers.${this.name}.${keyName}`;
15 | }
16 |
17 | async isVisible () {
18 | const visibilityKey = this.getSettingsKey('visible');
19 | const isVisible = await settings.get(visibilityKey);
20 | if (!_.isBoolean(isVisible)) {
21 | await this.setVisible(true);
22 | return true;
23 | }
24 | return isVisible;
25 | }
26 |
27 | async setVisible (isVisible = true) {
28 | const visibilityKey = this.getSettingsKey('visible');
29 | await settings.set(visibilityKey, isVisible);
30 | }
31 |
32 | }
33 |
34 |
35 | const providers = {};
36 | for (let [providerName, providerData] of _.toPairs(config)) {
37 | providers[providerName] = new CloudProvider(providerName, providerData);
38 | }
39 |
40 | export default providers;
--------------------------------------------------------------------------------
/app/renderer/images/sauce_icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
19 |
--------------------------------------------------------------------------------
/app/renderer/components/StartServer/SimpleTab.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Input } from 'antd';
3 |
4 | import { propTypes, updateArg } from './shared';
5 | import StartButton from './StartButton';
6 | import styles from './SimpleTab.css';
7 |
8 | export default class SimpleTab extends Component {
9 | static propTypes = {...propTypes}
10 |
11 | render () {
12 | const {startServer, serverArgs, serverStarting, serverVersion} = this.props;
13 |
14 | return (
15 |
31 | );
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/app/renderer/styles/github-gist-theme.global.css:
--------------------------------------------------------------------------------
1 | /**
2 | * GitHub Gist Theme
3 | * Author : Louis Barranqueiro - https://github.com/LouisBarranqueiro
4 | */
5 |
6 | .hljs {
7 | display: block;
8 | background: white;
9 | padding: 0.5em;
10 | color: #333333;
11 | overflow-x: auto;
12 | }
13 |
14 | .hljs-comment,
15 | .hljs-meta {
16 | color: #969896;
17 | }
18 |
19 | .hljs-string,
20 | .hljs-variable,
21 | .hljs-template-variable,
22 | .hljs-strong,
23 | .hljs-emphasis,
24 | .hljs-quote {
25 | color: #df5000;
26 | }
27 |
28 | .hljs-keyword,
29 | .hljs-selector-tag,
30 | .hljs-type {
31 | color: #a71d5d;
32 | }
33 |
34 | .hljs-literal,
35 | .hljs-symbol,
36 | .hljs-bullet,
37 | .hljs-attribute {
38 | color: #0086b3;
39 | }
40 |
41 | .hljs-section,
42 | .hljs-name {
43 | color: #63a35c;
44 | }
45 |
46 | .hljs-tag {
47 | color: #333333;
48 | }
49 |
50 | .hljs-title,
51 | .hljs-attr,
52 | .hljs-selector-id,
53 | .hljs-selector-class,
54 | .hljs-selector-attr,
55 | .hljs-selector-pseudo {
56 | color: #795da3;
57 | }
58 |
59 | .hljs-addition {
60 | color: #55a532;
61 | background-color: #eaffea;
62 | }
63 |
64 | .hljs-deletion {
65 | color: #bd2c00;
66 | background-color: #ffecec;
67 | }
68 |
69 | .hljs-link {
70 | text-decoration: underline;
71 | }
72 |
--------------------------------------------------------------------------------
/app/renderer/components/Session/ServerTabBrowserstack.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Form, Input, Row, Col } from 'antd';
3 |
4 | const FormItem = Form.Item;
5 |
6 | export default class ServerTabBrowserstack extends Component {
7 |
8 | render () {
9 |
10 | const {server, setServerParam} = this.props;
11 |
12 | return ;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/webpack.config.electron.js:
--------------------------------------------------------------------------------
1 | import webpack from 'webpack';
2 | import baseConfig from './webpack.config.base';
3 | import path from 'path';
4 |
5 | export default {
6 | ...baseConfig,
7 |
8 | devtool: 'source-map',
9 |
10 | entry: ['babel-polyfill', './app/main/main.development'],
11 |
12 | output: {
13 | ...baseConfig.output,
14 | path: __dirname,
15 | filename: './main.js'
16 | },
17 |
18 | plugins: [
19 | new webpack.EnvironmentPlugin([
20 | 'TARGET'
21 | ]),
22 | new webpack.BannerPlugin(
23 | 'require("source-map-support").install();',
24 | { raw: true, entryOnly: false }
25 | ),
26 | new webpack.DefinePlugin({
27 | _ENV_: process.env.TARGET ? require(`./env/.env-${process.env.TARGET}`) : require('./env/.env')
28 | })
29 | ],
30 |
31 | target: 'electron-main',
32 |
33 | node: {
34 | __dirname: false,
35 | __filename: false
36 | },
37 |
38 | resolve: {
39 | packageAlias: 'main',
40 | alias: {
41 | env: path.resolve(__dirname, 'env', process.env.TARGET ? `.env-${process.env.TARGET}` : '.env'),
42 | },
43 | },
44 |
45 | externals: [
46 | ...baseConfig.externals,
47 | 'font-awesome',
48 | 'source-map-support',
49 | 'appium',
50 | 'teen_process'
51 | ]
52 | };
53 |
--------------------------------------------------------------------------------
/app/renderer/components/Session/AdvancedServerParams.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Collapse, Form, Checkbox, Col, Input } from 'antd';
3 |
4 | const {Panel} = Collapse;
5 | const FormItem = Form.Item;
6 |
7 | export default class AdvancedServerParams extends Component {
8 |
9 |
10 | render () {
11 | const {server, setServerParam} = this.props;
12 |
13 | return
14 |
15 |
16 |
17 | setServerParam('allowUnauthorized', e.target.checked, 'advanced')}>Allow Unauthorized Certificates
18 |
19 |
20 |
21 |
22 | setServerParam('useProxy', e.target.checked, 'advanced')}>Use Proxy
23 |
24 |
25 |
26 |
27 | setServerParam('proxy', e.target.value, 'advanced')} placeholder="Proxy URL" value={server.advanced.proxy} />
28 |
29 |
30 |
31 | ;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/app/renderer/styles/app.global.css:
--------------------------------------------------------------------------------
1 | body {
2 | color: #222 !important;
3 | box-sizing: border-box;
4 | }
5 |
6 | #root {
7 | height: 100%;
8 | background: linear-gradient(180deg, #f9f9f9, #f2f2f2, 5%, #f2f2f2, 95%, #d9d9d9);
9 | }
10 |
11 | #root .ant-spin-nested-loading > div > .ant-spin {
12 | max-height: initial;
13 | }
14 |
15 | .window {
16 | background-color: #cde4f5 !important;
17 | width: 100%;
18 | height: 100%;
19 | }
20 |
21 | .list-group-item.active {
22 | background-color: #662d91 !important;
23 | }
24 |
25 | .ant-spin-nested-loading, .ant-spin-container {
26 | height: 100%;
27 | }
28 |
29 | .ant-notification {
30 | width: 80%;
31 | margin-right: 10%;
32 | }
33 |
34 | .inner-button {
35 | position: absolute;
36 | top: 0;
37 | right: 4px;
38 | z-index: 1000;
39 | }
40 |
41 | body::-webkit-scrollbar {
42 | width: 0px;
43 | background: transparent;
44 | }
45 |
46 | body::-webkit-scrollbar-corner {
47 | background: transparent;
48 | }
49 |
50 | .ant-input-group .ant-select {
51 | height: 100%;
52 | }
53 |
54 | .ant-input-group .ant-select-selection {
55 | border-top-left-radius: 0;
56 | border-bottom-left-radius: 0;
57 | height: 100%;
58 | padding-top: 0;
59 | }
60 |
61 | .ant-input-group .ant-select-selection .ant-select-selection-selected-value {
62 | padding-top: 2px;
63 | }
64 |
65 | .ant-input-group .select-container {
66 | height: 32px;
67 | }
--------------------------------------------------------------------------------
/webpack.config.development.js:
--------------------------------------------------------------------------------
1 | /* eslint max-len: 0 */
2 | import webpack from 'webpack';
3 | import baseConfig from './webpack.config.base';
4 |
5 | const config = {
6 | ...baseConfig,
7 |
8 | debug: true,
9 |
10 | devtool: 'cheap-module-eval-source-map',
11 |
12 | entry: [
13 | 'webpack-hot-middleware/client?path=http://localhost:3000/__webpack_hmr',
14 | 'babel-polyfill',
15 | './app/renderer/index'
16 | ],
17 |
18 | output: {
19 | ...baseConfig.output,
20 | publicPath: 'http://localhost:3000/dist/'
21 | },
22 |
23 | module: {
24 | ...baseConfig.module,
25 | loaders: [
26 | ...baseConfig.module.loaders,
27 |
28 | {
29 | test: /\.global\.css$/,
30 | loaders: [
31 | 'style-loader',
32 | 'css-loader?sourceMap'
33 | ]
34 | },
35 |
36 | {
37 | test: /^((?!\.global).)*\.css$/,
38 | loaders: [
39 | 'style-loader',
40 | 'css-loader?modules&sourceMap&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]'
41 | ]
42 | }
43 | ]
44 | },
45 |
46 | plugins: [
47 | ...baseConfig.plugins,
48 | new webpack.HotModuleReplacementPlugin(),
49 | new webpack.NoErrorsPlugin(),
50 | new webpack.DefinePlugin({
51 | 'process.env.NODE_ENV': JSON.stringify('development')
52 | })
53 | ],
54 |
55 | target: 'electron-renderer'
56 | };
57 |
58 | export default config;
59 |
--------------------------------------------------------------------------------
/app/renderer/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from 'react-dom';
3 | import { Provider } from 'react-redux';
4 | import { Router, hashHistory } from 'react-router';
5 | import { syncHistoryWithStore } from 'react-router-redux';
6 | import electron from 'electron';
7 | import WrongFolder from './components/WrongFolder/WrongFolder';
8 | import routes from './routes';
9 | import configureStore from './store/configureStore';
10 | import './styles/app.global.css';
11 | import './styles/github-gist-theme.global.css';
12 |
13 | const {app} = electron.remote;
14 | const isDev = process.env.NODE_ENV === 'development';
15 | const store = configureStore();
16 | const history = syncHistoryWithStore(hashHistory, store);
17 |
18 | function shouldShowWrongFolderComponent () {
19 | // If we set an ENV to require it to NOT be shown don't show it
20 | if (process.env.FORCE_NO_WRONG_FOLDER) {
21 | return false;
22 | }
23 |
24 | // Note: app.isInApplicationsFolder is undefined if it's not a Mac
25 | if (app.isInApplicationsFolder && !app.isInApplicationsFolder() && !isDev) {
26 | return true;
27 | } else if (isDev && process.env.WRONG_FOLDER) {
28 | return true;
29 | }
30 | }
31 |
32 | render(
33 |
34 | {shouldShowWrongFolderComponent() ?
35 | :
36 |
37 | }
38 | ,
39 | document.getElementById('root')
40 | );
41 |
--------------------------------------------------------------------------------
/app/renderer/components/Inspector/LocatorTestModal.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { Modal } from 'antd';
4 | import LocatedElements from './LocatedElements';
5 | import ElementLocator from './ElementLocator';
6 |
7 |
8 | export default class LocatorTestModal extends Component {
9 |
10 | onSubmit () {
11 | const {locatedElements, locatorTestStrategy, locatorTestValue, searchForElement, clearSearchResults, hideLocatorTestModal} = this.props;
12 | if (locatedElements) {
13 | hideLocatorTestModal();
14 | clearSearchResults();
15 | } else {
16 | searchForElement(locatorTestStrategy, locatorTestValue);
17 | }
18 | }
19 |
20 | onCancel () {
21 | const {hideLocatorTestModal, clearSearchResults} = this.props;
22 | hideLocatorTestModal();
23 | clearSearchResults();
24 | }
25 |
26 | render () {
27 | const {isLocatorTestModalVisible, isSearchingForElements, locatedElements} = this.props;
28 |
29 | return
36 | {!locatedElements && }
37 | {locatedElements && }
38 | ;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/app/renderer/components/Session/ServerTabHeadspin.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Form, Row, Col, Input } from 'antd';
3 | import SessionStyles from './Session.css';
4 |
5 | const FormItem = Form.Item;
6 |
7 | export default class ServerTabHeadspin extends Component {
8 |
9 | render () {
10 |
11 | const {server, setServerParam} = this.props;
12 |
13 | return ;
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/webpack.config.production.js:
--------------------------------------------------------------------------------
1 | import webpack from 'webpack';
2 | import ExtractTextPlugin from 'extract-text-webpack-plugin';
3 | import baseConfig from './webpack.config.base';
4 |
5 | const config = {
6 | ...baseConfig,
7 |
8 | devtool: 'source-map',
9 |
10 | entry: ['babel-polyfill', './app/renderer/index'],
11 |
12 | output: {
13 | ...baseConfig.output,
14 |
15 | publicPath: '../dist/'
16 | },
17 |
18 | module: {
19 | ...baseConfig.module,
20 |
21 | loaders: [
22 | ...baseConfig.module.loaders,
23 |
24 | {
25 | test: /\.global\.css$/,
26 | loader: ExtractTextPlugin.extract(
27 | 'style-loader',
28 | 'css-loader'
29 | )
30 | },
31 |
32 | {
33 | test: /^((?!\.global).)*\.css$/,
34 | loader: ExtractTextPlugin.extract(
35 | 'style-loader',
36 | 'css-loader?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]'
37 | )
38 | }
39 | ]
40 | },
41 |
42 | plugins: [
43 | ...baseConfig.plugins,
44 | new webpack.optimize.OccurrenceOrderPlugin(),
45 | new webpack.DefinePlugin({
46 | 'process.env.NODE_ENV': JSON.stringify('production')
47 | }),
48 | new webpack.optimize.UglifyJsPlugin({
49 | compressor: {
50 | screw_ie8: true,
51 | warnings: false
52 | }
53 | }),
54 | new ExtractTextPlugin('style.css', { allChunks: true })
55 | ],
56 |
57 | target: 'electron-renderer',
58 | };
59 |
60 | export default config;
61 |
--------------------------------------------------------------------------------
/app/renderer/components/Inspector/shared.js:
--------------------------------------------------------------------------------
1 | import { DOMParser } from 'xmldom';
2 | import xpath from 'xpath';
3 |
4 | export function parseCoordinates (element) {
5 | let {bounds, x, y, width, height} = element.attributes || {};
6 |
7 | if (bounds) {
8 | let boundsArray = bounds.split(/\[|\]|,/).filter((str) => str !== '');
9 | return {x1: boundsArray[0], y1: boundsArray[1], x2: boundsArray[2], y2: boundsArray[3]};
10 | } else if (x) {
11 | x = parseInt(x, 10);
12 | y = parseInt(y, 10);
13 | width = parseInt(width, 10);
14 | height = parseInt(height, 10);
15 | return {x1: x, y1: y, x2: x + width, y2: y + height};
16 | } else {
17 | return {};
18 | }
19 | }
20 |
21 | export function isUnique (attrName, attrValue, sourceXML) {
22 | // If no sourceXML provided, assume it's unique
23 | if (!sourceXML) {
24 | return true;
25 | }
26 | const doc = new DOMParser().parseFromString(sourceXML);
27 | return xpath.select(`//*[@${attrName}="${attrValue}"]`, doc).length < 2;
28 | }
29 |
30 | // Map of the optimal strategies.
31 | const STRATEGY_MAPPINGS = [
32 | ['name', 'accessibility id'],
33 | ['content-desc', 'accessibility id'],
34 | ['id', 'id'],
35 | ['resource-id', 'id'],
36 | ];
37 |
38 | export function getLocators (attributes, sourceXML) {
39 | const res = {};
40 | for (let [strategyAlias, strategy] of STRATEGY_MAPPINGS) {
41 | const value = attributes[strategyAlias];
42 | if (value && isUnique(strategyAlias, value, sourceXML)) {
43 | res[strategy] = attributes[strategyAlias];
44 | }
45 | }
46 | return res;
47 | }
--------------------------------------------------------------------------------
/app/renderer/components/StartServer/StartButton.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { Button, Icon } from 'antd';
3 | import { ipcRenderer } from 'electron';
4 |
5 | import styles from './StartButton.css';
6 |
7 | export default class StartButton extends Component {
8 | static propTypes = {
9 | serverStarting: PropTypes.bool.isRequired,
10 | startServer: PropTypes.func.isRequired,
11 | disabledOverride: PropTypes.bool,
12 | }
13 |
14 | isEnabled () {
15 | return !(this.props.serverStarting || this.props.disabledOverride);
16 | }
17 |
18 | noop (evt) {
19 | evt.preventDefault();
20 | }
21 |
22 | openConfig () {
23 | ipcRenderer.send('appium-open-config');
24 | }
25 |
26 | render () {
27 | const {startServer, serverStarting, serverVersion} = this.props;
28 | const buttonProps = {};
29 | if (!this.isEnabled()) {
30 | buttonProps.disabled = true;
31 | }
32 |
33 | return (
34 |
35 |
42 |
43 |
48 |
49 | );
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/app/renderer/components/Session/ServerTabTestobject.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Form, Input, Select, Row, Col, Checkbox } from 'antd';
3 |
4 | const FormItem = Form.Item;
5 |
6 | export default class ServerTabTestobject extends Component {
7 |
8 | render () {
9 |
10 | const {server, setServerParam} = this.props;
11 |
12 | return ;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/app/renderer/components/StartServer/shared.js:
--------------------------------------------------------------------------------
1 | import { PropTypes } from 'react';
2 | import { DEFAULT_ARGS } from '../../reducers/StartServer';
3 |
4 | export const propTypes = {
5 | serverArgs: PropTypes.object.isRequired,
6 | serverStarting: PropTypes.bool.isRequired,
7 | startServer: PropTypes.func.isRequired,
8 | updateArgs: PropTypes.func.isRequired,
9 | savePreset: PropTypes.func.isRequired,
10 | presetSaving: PropTypes.bool.isRequired,
11 | deletePreset: PropTypes.func.isRequired,
12 | presetDeleting: PropTypes.bool.isRequired,
13 | };
14 |
15 | export function updateArg (evt) {
16 | const {updateArgs} = this.props;
17 | let argName = evt.target.name;
18 | let newVal;
19 | switch (evt.target.type) {
20 | case "checkbox":
21 | newVal = evt.target.checked;
22 | break;
23 | default:
24 | newVal = evt.target.value;
25 | // if we have a string type, sometimes Appium's default value is actually
26 | // null, but our users can only make it an empty string, so convert it
27 | if (newVal === "" && DEFAULT_ARGS[argName] === null) {
28 | newVal = null;
29 | }
30 | // likewise if we have a string type, but Appium's defult value is
31 | // actually a number, convert it. For now assume that will be an integer
32 | // since Appium currently doesn't accept any non-integer numeric
33 | // arguments.
34 | if (typeof newVal === "string" &&
35 | typeof DEFAULT_ARGS[argName] === "number") {
36 | newVal = parseInt(newVal, 10);
37 | }
38 | break;
39 | }
40 | updateArgs({[argName]: newVal});
41 | }
42 |
--------------------------------------------------------------------------------
/test/integration/update-checker.integration-test.js:
--------------------------------------------------------------------------------
1 | import chai from 'chai';
2 | import chaiAsPromised from 'chai-as-promised';
3 | import sinon from 'sinon';
4 | import request from 'request-promise';
5 | import { checkUpdate } from '../../app/main/auto-updater/update-checker';
6 |
7 | chai.should();
8 | chai.use(chaiAsPromised);
9 |
10 | describe('updateChecker', function () {
11 | let latestVersion;
12 |
13 | before(async function () {
14 | const latestReleaseUrl = `https://api.github.com/repos/appium/appium-desktop/releases/latest?access_token=${process.env.GITHUB_TOKEN}`;
15 | const res = JSON.parse(await request.get(latestReleaseUrl, {headers: {'user-agent': 'node.js'}}));
16 | latestVersion = res.name;
17 | });
18 |
19 | describe('.checkUpdate', function () {
20 | it('not find anything if latest release is same as current release', async function () {
21 | await checkUpdate(latestVersion).should.eventually.equal(false);
22 | });
23 | it('should find something if latest release is different from current release', async function () {
24 | const {name, notes, pub_date, url} = await checkUpdate('v0.0.0');
25 | name.should.be.a.string;
26 | notes.should.be.a.string;
27 | pub_date.should.be.a.string;
28 | url.should.be.a.string;
29 | });
30 | it('should return false if request for update throws error', async function () {
31 | let promiseStub = sinon.stub(request, 'get', () => { throw new Error(`Failed Request`); });
32 | await checkUpdate('v0.0.0').should.eventually.be.false;
33 | promiseStub.restore();
34 | });
35 | });
36 | });
--------------------------------------------------------------------------------
/app/renderer/actions/shared.js:
--------------------------------------------------------------------------------
1 | import { ipcRenderer } from 'electron';
2 | import UUID from 'uuid';
3 | import Promise from 'bluebird';
4 |
5 | const clientMethodPromises = {};
6 |
7 | export function bindClient () {
8 | /**
9 | * When we hear back from the main process, resolve the promise
10 | */
11 | ipcRenderer.on('appium-client-command-response', (evt, resp) => {
12 | // Rename 'id' to 'elementId'
13 | resp.elementId = resp.id;
14 | let promise = clientMethodPromises[resp.uuid];
15 | if (promise) {
16 | promise.resolve(resp);
17 | delete clientMethodPromises[resp.uuid];
18 | }
19 | });
20 |
21 | /**
22 | * If we hear back with an error, reject the promise
23 | */
24 | ipcRenderer.on('appium-client-command-response-error', (evt, resp) => {
25 | const {e, uuid} = resp;
26 | let promise = clientMethodPromises[uuid];
27 | if (promise) {
28 | promise.reject(new Error(e));
29 | delete clientMethodPromises[uuid];
30 | }
31 | });
32 | }
33 |
34 | export function unbindClient () {
35 | ipcRenderer.removeAllListeners('appium-client-command-response');
36 | ipcRenderer.removeAllListeners('appium-client-command-response-error');
37 | }
38 |
39 | export function callClientMethod (params) {
40 | if (!ipcRenderer) {
41 | throw new Error('Cannot call ipcRenderer from main context');
42 | }
43 | let uuid = UUID.v4();
44 | let promise = new Promise((resolve, reject) => clientMethodPromises[uuid] = {resolve, reject});
45 | ipcRenderer.send('appium-client-command-request', {
46 | ...params,
47 | uuid,
48 | });
49 | return promise;
50 | }
--------------------------------------------------------------------------------
/app/renderer/components/Session/ServerTabCustom.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Form, Col, Input, Checkbox } from 'antd';
3 | import SessionStyles from './Session.css';
4 |
5 | const FormItem = Form.Item;
6 |
7 | export default class ServerTabCustom extends Component {
8 |
9 | render () {
10 |
11 | const {server, setServerParam} = this.props;
12 |
13 | return ;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/renderer/components/Session/AttachToSession.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Form, Card, Select, Button } from 'antd';
3 | import SessionCSS from './Session.css';
4 |
5 | const FormItem = Form.Item;
6 |
7 | function formatCaps (caps) {
8 | let importantCaps = [caps.app, caps.platformName, caps.deviceName];
9 | if (caps.automationName) {
10 | importantCaps.push(caps.automationName);
11 | }
12 | return importantCaps.join(', ').trim();
13 | }
14 |
15 | export default class AttachToSession extends Component {
16 |
17 | render () {
18 | let {attachSessId, setAttachSessId, runningAppiumSessions, getRunningSessions} = this.props;
19 | return ();
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/app/renderer/components/StartServer/AdvancedTab.css:
--------------------------------------------------------------------------------
1 | .advancedForm {
2 | width: 100%;
3 | }
4 |
5 | .inputSection {
6 | height: 360px;
7 | border-bottom: 1px solid #662d91;
8 | overflow: scroll;
9 | padding: 0px 10px;
10 | }
11 |
12 | .inputSection::-webkit-scrollbar {
13 | height: 0px;
14 | width: 0px;
15 | }
16 |
17 | .actions {
18 | display: flex;
19 | justify-content: center;
20 | align-items: center;
21 | flex-direction: row;
22 | flex-wrap: wrap;
23 | flex-flow: row wrap;
24 | }
25 |
26 | .actions div {
27 | width: 220px;
28 | margin: 10px;
29 | }
30 |
31 | .input {
32 | width: 300px;
33 | margin: 5px 15px;
34 | margin-left: 0px;
35 | display: flex;
36 | flex-direction: row;
37 | justify-content: center;
38 | }
39 |
40 | .advancedForm :global(.ant-checkbox-wrapper) {
41 | width: 300px;
42 | }
43 |
44 | .secTitle {
45 | font-size: 1.2em;
46 | color: #333;
47 | margin-bottom: 12px;
48 | width: 300px;
49 | }
50 |
51 | .secBody {
52 | display: flex;
53 | justify-content: flex-start;
54 | align-items: center;
55 | flex-direction: row;
56 | flex-wrap: wrap;
57 | flex-flow: row wrap;
58 | margin-bottom: 15px;
59 | }
60 |
61 | .secBody:after {
62 | width: 300px;
63 | margin-right: 15px;
64 | content: "";
65 | }
66 |
67 | .presetActions {
68 | margin-top: 10px;
69 | display: flex;
70 | flex-direction: row;
71 | align-content: center;
72 | justify-content: center;
73 | }
74 |
75 | .presetActions button {
76 | margin: 5px;
77 | }
78 |
79 | .savePresetSuccess {
80 | width: 100%;
81 | font-size: 5em;
82 | font-style: italic;
83 | text-align: center;
84 | color: #999;
85 | }
86 |
87 | .comingSoon {
88 | font-style: italic;
89 | color: #666;
90 | }
91 |
92 | :global(.ant-modal) {
93 | width: auto;
94 | margin: auto;
95 | }
96 |
--------------------------------------------------------------------------------
/app/renderer/components/Session/FormattedCaps.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import formatJSON from 'format-json';
3 | import SessionCSS from './Session.css';
4 | import { Card, Button, Alert, Tooltip } from 'antd';
5 | import { getCapsObject } from '../../actions/Session.js';
6 |
7 | export default class NewSessionForm extends Component {
8 |
9 | getFormattedJSON (caps) {
10 | return formatJSON.plain(getCapsObject(caps));
11 | }
12 |
13 | render () {
14 | const {caps, title, isEditingDesiredCaps, startDesiredCapsEditor, abortDesiredCapsEditor, saveRawDesiredCaps, setRawDesiredCaps, rawDesiredCaps,
15 | isValidCapsJson, invalidCapsJsonReason} = this.props;
16 | return caps &&
18 |
19 | {isEditingDesiredCaps &&
20 |
21 | }
22 | {isEditingDesiredCaps &&
23 |
24 | }
25 | {!isEditingDesiredCaps &&
26 |
27 | }
28 |
29 | {isEditingDesiredCaps && }
33 | {!isEditingDesiredCaps &&
34 |
{this.getFormattedJSON(caps)}
35 |
}
36 | ;
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/app/main/helpers.js:
--------------------------------------------------------------------------------
1 | import { BrowserWindow, Menu } from 'electron';
2 | import settings from '../settings';
3 | import path from 'path';
4 |
5 | const isDev = process.env.NODE_ENV === 'development';
6 |
7 | export function openBrowserWindow (route, opts) {
8 | const defaultOpts = {
9 | width: 1080,
10 | minWidth: 1080,
11 | height: 570,
12 | minHeight: 570,
13 | backgroundColor: "#f2f2f2",
14 | frame: "customButtonsOnHover",
15 | webPreferences: {
16 | devTools: true
17 | }
18 | };
19 |
20 | let win = new BrowserWindow({
21 | ...defaultOpts,
22 | ...opts,
23 | });
24 |
25 | // note that __dirname changes based on whether we're in dev or prod;
26 | // in dev it's the actual dirname of the file, in prod it's the root
27 | // of the project (where main.js is built), so switch accordingly
28 | let htmlPath = path.resolve(__dirname, isDev ? '..' : 'app', 'renderer', 'index.html');
29 |
30 | // on Windows we'll get backslashes, but we don't want these for a browser URL, so replace
31 | htmlPath = htmlPath.replace("\\", "/");
32 | htmlPath += `#/${route}`;
33 | win.loadURL(`file://${htmlPath}`);
34 | win.show();
35 |
36 | // If it's dev, go ahead and open up the dev tools automatically
37 | if (isDev) {
38 | win.openDevTools();
39 | }
40 |
41 | // Make 'devTools' available on right click
42 | win.webContents.on('context-menu', (e, props) => {
43 | const {x, y} = props;
44 |
45 | Menu.buildFromTemplate([{
46 | label: 'Inspect element',
47 | click () {
48 | win.inspectElement(x, y);
49 | }
50 | }]).popup(win);
51 | });
52 |
53 | return win;
54 | }
55 |
56 |
57 | // Sets the environment variables to a combination of process.env and whatever
58 | // the user saved
59 | export async function setSavedEnv () {
60 | const savedEnv = await settings.get('ENV');
61 | process.env = {
62 | ...process.env,
63 | ...savedEnv || {},
64 | };
65 | }
--------------------------------------------------------------------------------
/app/renderer/reducers/StartServer.js:
--------------------------------------------------------------------------------
1 | import { SERVER_START_REQ, SERVER_START_OK, SERVER_START_ERR, GET_PRESETS,
2 | UPDATE_ARGS, SWITCH_TAB, PRESET_SAVE_REQ, PRESET_SAVE_OK,
3 | PRESET_DELETE_REQ, PRESET_DELETE_OK, SET_LOGFILE_PATH,
4 | } from '../actions/StartServer';
5 |
6 | import { ipcRenderer } from 'electron';
7 | import { version as SERVER_VERSION } from 'appium/package.json';
8 |
9 | export const DEFAULT_ARGS = ipcRenderer.sendSync('get-default-args');
10 | export const ARG_DATA = ipcRenderer.sendSync('get-args-metadata');
11 |
12 | const initialState = {
13 | serverArgs: {...DEFAULT_ARGS},
14 | serverVersion: SERVER_VERSION,
15 | serverStarting: false,
16 | serverFailMsg: "",
17 | tabId: 0,
18 | presetSaving: false,
19 | presetDeleting: false,
20 | presets: {},
21 | };
22 |
23 | export default function startServer (state = initialState, action) {
24 | switch (action.type) {
25 | case SERVER_START_REQ:
26 | return {...state, serverStarting: true};
27 | case SERVER_START_OK:
28 | case SERVER_START_ERR:
29 | return {
30 | ...state,
31 | serverStarting: false,
32 | };
33 | case UPDATE_ARGS:
34 | return {
35 | ...state,
36 | serverArgs: Object.assign({}, state.serverArgs, action.args)
37 | };
38 | case SWITCH_TAB:
39 | return {
40 | ...state,
41 | tabId: action.tabId
42 | };
43 | case GET_PRESETS:
44 | return {...state, presets: action.presets};
45 | case PRESET_SAVE_REQ:
46 | return {...state, presetSaving: true};
47 | case PRESET_SAVE_OK:
48 | return {...state, presetSaving: false, presets: action.presets};
49 | case PRESET_DELETE_REQ:
50 | return {...state, presetDeleting: true};
51 | case PRESET_DELETE_OK:
52 | return {...state, presetDeleting: false, presets: action.presets};
53 | case SET_LOGFILE_PATH:
54 | return {...state, logfilePath: action.logfilePath};
55 | default:
56 | return state;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/app/renderer/components/StartServer/StartServer.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { Button } from 'antd';
3 |
4 | import { propTypes } from './shared';
5 | import SimpleTab from './SimpleTab';
6 | import AdvancedTab from './AdvancedTab';
7 | import PresetsTab from './PresetsTab';
8 | import styles from './StartServer.css';
9 |
10 | const TAB_SIMPLE = 0, TAB_ADVANCED = 1, TAB_PRESETS = 2;
11 |
12 | export default class StartServer extends Component {
13 | static propTypes = {
14 | ...propTypes,
15 | tabId: PropTypes.number.isRequired,
16 | switchTab: PropTypes.func.isRequired,
17 | }
18 |
19 | displayTab () {
20 | switch (this.props.tabId) {
21 | case TAB_SIMPLE:
22 | return ;
23 | case TAB_ADVANCED:
24 | return ;
25 | case TAB_PRESETS:
26 | return ;
27 | default:
28 | throw new Error("Invalid tab id");
29 | }
30 | }
31 |
32 | render () {
33 | const {tabId, switchTab} = this.props;
34 | return (
35 |
36 |
37 |

38 |
39 |
40 |
43 |
46 |
49 |
50 |
51 | {this.displayTab()}
52 |
53 |
54 | );
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/app/renderer/components/Session/ServerTabSauce.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Form, Row, Col, Input, Checkbox } from 'antd';
3 | const FormItem = Form.Item;
4 |
5 | export default class ServerTabSauce extends Component {
6 |
7 | render () {
8 |
9 | const {server, setServerParam} = this.props;
10 |
11 | return ;
42 | }
43 | }
44 |
45 |
--------------------------------------------------------------------------------
/app/renderer/components/Config/Config.js:
--------------------------------------------------------------------------------
1 | import { ipcRenderer, remote } from 'electron';
2 | import React, { Component } from 'react';
3 | import { Input, Row, Col, Button } from 'antd';
4 | import styles from './Config.css';
5 |
6 | const ENV_VARIABLE_NAMES = [
7 | 'ANDROID_HOME', 'JAVA_HOME'
8 | ];
9 |
10 | const {app, dialog, getCurrentWindow} = remote;
11 |
12 | export default class Config extends Component {
13 |
14 | componentWillMount () {
15 | this.props.getEnvironmentVariables();
16 | }
17 |
18 | saveAndRestart () {
19 | const {environmentVariables} = this.props;
20 | ipcRenderer.send('appium-save-env', environmentVariables);
21 | ipcRenderer.once('appium-save-env-done', () => {
22 | const message = `Application must be restarted for changes to take effect`;
23 | const dialogOptions = {type: 'info', buttons: ['Restart Now', 'Restart Later'], message};
24 | dialog.showMessageBox(dialogOptions, (response) => {
25 | if (response === 0) {
26 | // If 'Restart Now' restart the application
27 | app.relaunch();
28 | app.exit();
29 | } else {
30 | // ...otherwise, just close the current window
31 | getCurrentWindow().close();
32 | }
33 | });
34 | });
35 | }
36 |
37 | render () {
38 | const {setEnvironmentVariable, environmentVariables, defaultEnvironmentVariables} = this.props;
39 |
40 | return
41 |
Environment Variables
42 | {ENV_VARIABLE_NAMES.map((ENV_NAME) => (
43 |
44 |
45 | setEnvironmentVariable(ENV_NAME, evt.target.value)}
48 | value={environmentVariables[ENV_NAME]} />
49 |
50 |
51 | ))}
52 |
53 |
54 |
55 |
56 |
57 | ;
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/app/renderer/components/Inspector/ElementLocator.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { Input, Select, Row, Col } from 'antd';
4 | import InspectorStyles from './Inspector.css';
5 |
6 | const { Option } = Select;
7 |
8 | export default class ElementLocator extends Component {
9 |
10 | onSubmit () {
11 | const {locatedElements, locatorTestStrategy, locatorTestValue, searchForElement, clearSearchResults, hideLocatorTestModal} = this.props;
12 | if (locatedElements) {
13 | hideLocatorTestModal();
14 | clearSearchResults();
15 | } else {
16 | searchForElement(locatorTestStrategy, locatorTestValue);
17 | }
18 | }
19 |
20 | onCancel () {
21 | const {hideLocatorTestModal, clearSearchResults} = this.props;
22 | hideLocatorTestModal();
23 | clearSearchResults();
24 | }
25 |
26 | render () {
27 | const {setLocatorTestValue, locatorTestValue, setLocatorTestStrategy, locatorTestStrategy} = this.props;
28 |
29 | const locatorStrategies = [
30 | ['id', 'Id'],
31 | ['xpath', 'XPath'],
32 | ['name', 'Name'],
33 | ['class name', 'Class Name'],
34 | ['accessibility id', 'Accessibility ID'],
35 | ['-android uiautomator', 'UIAutomator Selector (Android)'],
36 | ['-ios predicate string', 'Predicate String (iOS)'],
37 | ['-ios class chain', 'Class Chain (iOS)'],
38 | ];
39 |
40 | return
41 |
42 |
43 | Locator Strategy:
44 |
51 |
52 |
53 | Selector:
54 |
55 | setLocatorTestValue(e.target.value)} value={locatorTestValue} />
56 |
57 |
58 |
;
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | sudo: required
3 | node:
4 | os:
5 | - linux
6 | - osx
7 | node_js:
8 | - '9'
9 | before_install:
10 | - nvm install 9
11 | - npm install -g npm@5.10
12 | - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then sudo apt-get install --no-install-recommends
13 | -y icnsutils graphicsmagick xz-utils; fi
14 | addons:
15 | apt:
16 | packages:
17 | - xvfb
18 | before_script:
19 | - greenkeeper-lockfile-update
20 | - node --version
21 | - npm --version
22 | - travis_wait 30 npm run package-ci # This command takes a long time, so increased the time limit to 30 minutes (default is 10 minutes)
23 | - greenkeeper-lockfile-upload
24 | cache:
25 | directories:
26 | - node_modules
27 | - "$HOME/.electron"
28 | - "$HOME/.cache"
29 | install:
30 | - npm ci
31 | - export DISPLAY=':99.0'
32 | - Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
33 | dist: trusty
34 | env:
35 | global:
36 | - secure: Jz/k1Z1LJGOfBiIC6yUOLCetNgn8IWwU8BBPREBgXhmhRdImpU1FeVY+zEYIdqdUHrIu7d0DwtvDjA0VC+xPjv+w2Vt2AEhEjXiCvqYOM4YjxJgWNbwyjSN4BEGx5QVdbTe8di/lmiy+vDCXyxyuJDjzJNYfNXh2Z3EpfJrQtnNNK41hmT5wZIOqyXctG3wKa9UcgEzFogC61IFSN0RRNPa7Z4MtjBwfdKsXymOSn9v5XF5x7vwVAZFsgmlttPq5TAtHMMtsZeT4AGIBy3HOAWjvhLSFRIL1wCnwulrxIOL1etXYqzM7dM53trpdfa4NWZWgtLUb2AsfMP+7aHlFfCkxLlZ6KWUuUYiJwOUS+mnHKc2ybmDRw+cHmmFaENw+RAvVQ8fIptsnFcCHv7LJXhIu4ZWctfxJEfvPsBMJBdZOBlEabx2xMJilKozTit9pqzdfU/AK+2JrWdj/2sGkUfqoqc/YY+ImFL7NabHq1pwt/0xttnxBVNUNl45pgJPK6Vu9u6iJxa2ucjW3IzEWUi7HCT6JQ6gFD8HNydvFofh0Ot3IPTKsfFtsugZ1PbI/p6H/sV+vy+WpQWYBST5MZyT8mt6hk2OMNVkj9K8eNEW4RA2+6ubX/iHCx0I8vpOw8SA0DPlqKVl7/nUcfTQWep5Eg0wDgNqRMGiy+1NP5nI=
37 | - secure: LpcHyGX9WP7Ca/5N+RYY9I5eF/hpDhoUTtQ628V2damSui4I0Mq/8J+np02i/pyAy0UsESqEAyekc4r+8PiUYE+vMhSwGi+jQ40XsXjRH9EIRhHeyty5P5cUSq8ZTg/QHUhWMurkJahjIEHKiKXhh9lAxulwdznySja6VRqaem9X+fLZT7bbdsDYnLSojkCLtHfXXObKHbQzJVNltmhDpooWQOidPR0zMuzBrxGcq9Rb+PtgbYrwewNhosGzdn6aj50Vsxf0XaiVCG8OxfYK3ma+R6yi1YMyJ4uciiRaiS1fXWrhxrDCXUfhjHuV1eNPZNX8JWYkQ12qa7IgKh9l4eHyRISXy+b+53KkC/uXKPtvp6x53DK9kKEQvi2Yabwp9gGz91BUg+vW5ny208+6IOlznEXcU06JrJqVZT+cECAxm0Cdcs/prmqHnUiaWBH4KwFQIdVc1mkDQL7k0hl7aEYlOkLynXikuxiHzr7ig4UUmw8jblPirmTF0OtBo/YzcD1NmMGryoaFTRDi54z8lRHaDYfSdhSZSOeo4s1rAFtl5GR5F5SQHCchVkwRt+aLdjPv7Az6m1hRIw3hMR8ZAE1iO12MWOTPJRF9cFzarO0nAX5Mq6GcirEBrVTt9c0f2JbU5qCfwyGL9ScoSzX9aRs2yk1MmUalfPjkvdjES88=
38 |
--------------------------------------------------------------------------------
/app/renderer/reducers/ServerMonitor.js:
--------------------------------------------------------------------------------
1 | import moment from 'moment';
2 | import { SERVER_STOP_REQ, SERVER_STOP_OK, SERVER_STOP_FAIL, SET_SERVER_ARGS,
3 | START_SESSION_REQUEST,
4 | LOGS_RECEIVED, LOGS_CLEARED, MONITOR_CLOSED } from '../actions/ServerMonitor';
5 |
6 | export const STATUS_RUNNING = "running";
7 | export const STATUS_STOPPED = "stopped";
8 | export const STATUS_STOPPING = "stopping";
9 |
10 | // Maximum amount of logs to keep in memory
11 | const MAX_LOG_LINES = 10000;
12 |
13 | const initialState = {
14 | logLines: [],
15 | serverStatus: STATUS_STOPPED,
16 | serverFailMsg: "",
17 | serverArgs: {},
18 | };
19 |
20 | export default function serverMonitor (state = initialState, action) {
21 | let logLines;
22 | switch (action.type) {
23 | case SERVER_STOP_REQ:
24 | return {...state, serverStatus: STATUS_STOPPING};
25 | case SERVER_STOP_OK:
26 | return {
27 | ...state,
28 | serverStatus: STATUS_STOPPED,
29 | serverFailMsg: ""
30 | };
31 | case SERVER_STOP_FAIL:
32 | return {
33 | ...state,
34 | serverStopping: false,
35 | serverFailMsg: action.reason
36 | };
37 | case START_SESSION_REQUEST:
38 | return {
39 | ...state,
40 | sessionStart: true,
41 | sessionId: action.sessionUUID,
42 | };
43 | case LOGS_RECEIVED:
44 | logLines = state.logLines.concat(action.logs.map((l) => {
45 | // attach a timestamp to each log line here when it comes in
46 | l.timestamp = moment().format('YYYY-MM-DD hh:mm:ss');
47 | return l;
48 | }));
49 |
50 | // Don't log more than MAX_LOG_LINES
51 | if (logLines.length > MAX_LOG_LINES) {
52 | logLines = logLines.slice(logLines.length - MAX_LOG_LINES);
53 | }
54 |
55 | return {
56 | ...state,
57 | logLines,
58 | serverStatus: STATUS_RUNNING
59 | };
60 | case LOGS_CLEARED:
61 | return {
62 | ...state,
63 | logLines: []
64 | };
65 | case SET_SERVER_ARGS:
66 | return {
67 | ...state,
68 | serverArgs: action.args
69 | };
70 | case MONITOR_CLOSED:
71 | return {...initialState};
72 | default:
73 | return state;
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/app/renderer/util.js:
--------------------------------------------------------------------------------
1 | import XPath from 'xpath';
2 |
3 | /**
4 | * Get an optimal XPath for a DOMNode
5 | * @param {*} domNode {DOMNode}
6 | * @param {*} uniqueAttributes {array[string]} Attributes we know are unique (defaults to just 'id')
7 | */
8 | export function getOptimalXPath (doc, domNode, uniqueAttributes = ['id']) {
9 | try {
10 | // BASE CASE #1: If this isn't an element, we're above the root, return empty string
11 | if (!domNode.tagName || domNode.nodeType !== 1) {
12 | return '';
13 | }
14 |
15 | // BASE CASE #2: If this node has a unique attribute, return an absolute XPath with that attribute
16 | for (let attrName of uniqueAttributes) {
17 | const attrValue = domNode.getAttribute(attrName);
18 | if (attrValue) {
19 | let xpath = `//${domNode.tagName || '*'}[@${attrName}="${attrValue}"]`;
20 | let othersWithAttr;
21 |
22 | // If the XPath does not parse, move to the next unique attribute
23 | try {
24 | othersWithAttr = XPath.select(xpath, doc);
25 | } catch (ign) {
26 | continue;
27 | }
28 |
29 | // If the attribute isn't actually unique, get it's index too
30 | if (othersWithAttr.length > 1) {
31 | let index = othersWithAttr.indexOf(domNode);
32 | xpath = `(${xpath})[${index + 1}]`;
33 | }
34 | return xpath;
35 | }
36 | }
37 |
38 |
39 | // Get the relative xpath of this node using tagName
40 | let xpath = `/${domNode.tagName}`;
41 |
42 | // If this node has siblings of the same tagName, get the index of this node
43 | if (domNode.parentNode) {
44 | // Get the siblings
45 | const childNodes = [...domNode.parentNode.childNodes].filter((childNode) => (
46 | childNode.nodeType === 1 && childNode.tagName === domNode.tagName
47 | ));
48 |
49 | // If there's more than one sibling, append the index
50 | if (childNodes.length > 1) {
51 | let index = childNodes.indexOf(domNode);
52 | xpath += `[${index + 1}]`;
53 | }
54 | }
55 |
56 | // Make a recursive call to this nodes parents and prepend it to this xpath
57 | return getOptimalXPath(doc, domNode.parentNode, uniqueAttributes) + xpath;
58 | } catch (ign) {
59 | // If there's an unexpected exception, abort and don't get an XPath
60 | return null;
61 | }
62 | }
--------------------------------------------------------------------------------
/app/renderer/components/Inspector/HighlighterRect.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import ReactDOM from 'react-dom';
3 | import InspectorCSS from './Inspector.css';
4 | import { parseCoordinates } from './shared';
5 |
6 | /**
7 | * Absolute positioned divs that overlay the app screenshot and highlight the bounding
8 | * boxes of the elements in the app
9 | */
10 | export default class HighlighterRect extends Component {
11 |
12 | render () {
13 | const {selectedElement = {}, selectHoveredElement, unselectHoveredElement, hoveredElement = {}, selectElement, unselectElement, element,
14 | zIndex, scaleRatio, xOffset, elLocation, elSize} = this.props;
15 | const {path: hoveredPath} = hoveredElement;
16 | const {path: selectedPath} = selectedElement;
17 |
18 | let width, height, left, top, highlighterClasses, key;
19 | highlighterClasses = [InspectorCSS['highlighter-box']];
20 |
21 | if (element) {
22 | // Calculate left, top, width and height coordinates
23 | const {x1, y1, x2, y2} = parseCoordinates(element);
24 | left = x1 / scaleRatio + xOffset;
25 | top = y1 / scaleRatio;
26 | width = (x2 - x1) / scaleRatio;
27 | height = (y2 - y1) / scaleRatio;
28 |
29 | // Add class + special classes to hovered and selected elements
30 | if (hoveredPath === element.path) {
31 | highlighterClasses.push(InspectorCSS['hovered-element-box']);
32 | }
33 | if (selectedPath === element.path) {
34 | highlighterClasses.push(InspectorCSS['inspected-element-box']);
35 | }
36 | key = element.path;
37 | } else if (elLocation && elSize) {
38 | width = elSize.width / scaleRatio;
39 | height = elSize.height / scaleRatio;
40 | top = elLocation.y / scaleRatio;
41 | left = elLocation.x / scaleRatio + xOffset;
42 | key = 'searchedForElement';
43 | highlighterClasses.push(InspectorCSS['inspected-element-box']);
44 | }
45 |
46 | return selectHoveredElement(key)}
48 | onMouseOut={unselectHoveredElement}
49 | onClick={() => key === selectedPath ? unselectElement() : selectElement(key)}
50 | key={key}
51 | style={{zIndex, left: (left || 0), top: (top || 0), width: (width || 0), height: (height || 0)}}>
52 |
53 |
;
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/app/renderer/components/StartServer/PresetsTab.css:
--------------------------------------------------------------------------------
1 | .presetsSection {
2 | height: 360px;
3 | border-bottom: 1px solid #662d91;
4 | display: flex;
5 | align-items: center;
6 | justify-content: center;
7 | flex-direction: row;
8 | align-content: stretch;
9 | margin-top: -20px;
10 | }
11 |
12 | .presetsList {
13 | overflow: scroll;
14 | height: 100%;
15 | width: 100%;
16 | }
17 |
18 | .presetsList::-webkit-scrollbar {
19 | width: 0px;
20 | height: 0px;
21 | }
22 |
23 | .presetLink {
24 | display: block;
25 | border-top: 1px dotted #662d91 !important;
26 | color: inherit;
27 | }
28 |
29 | .presetLink:first-child {
30 | border-top: 0px !important;
31 | }
32 |
33 | .presetLink:hover {
34 | background-color: #dff1ff;
35 | color: inherit;
36 | }
37 |
38 | li.preset {
39 | padding: 10px;
40 | }
41 |
42 | .presetItemTitle {
43 | font-weight: bold;
44 | font-size: 1.3em;
45 | }
46 |
47 | .presetItemDesc {
48 | font-size: 0.9em;
49 | color: #666;
50 | }
51 |
52 | .presetItemActive {
53 | background-color: #d9cfe0; /*#662d91;*/
54 | }
55 |
56 | .presetsDetail {
57 | height: 100%;
58 | width: 100%;
59 | overflow: scroll;
60 | border-left: 1px solid #662d91;
61 | padding-top: 15px;
62 | }
63 |
64 | .presetsDetail :global(th),
65 | .presetsDetail :global(thead),
66 | .presetsDetail :global(tbody) {
67 | background: none !important;
68 | }
69 |
70 | .presetsDetail :global(th),
71 | .presetsDetail :global(td) {
72 | border-color: #662d91 !important;
73 | }
74 |
75 | .presetsDetail :global(.ant-table-small) {
76 | border: none;
77 | }
78 |
79 | .presetsDetail::-webkit-scrollbar {
80 | width: 0px;
81 | height: 0px;
82 | }
83 |
84 | .presetsDetail h4 {
85 | margin-top: 0px;
86 | padding-left: 8px;
87 | }
88 |
89 | .noPresetsContainer {
90 | height: 100%;
91 | display: flex;
92 | justify-content: center;
93 | align-items: center;
94 | flex-direction: row;
95 | }
96 |
97 | .noPresetsMessage {
98 | font-size: 2em;
99 | font-style: italic;
100 | text-align: center;
101 | color: #999;
102 | }
103 |
104 | .presetData {
105 | font-family: "Inconsolata", "Lucida console", Monaco, monospace;
106 | color: #ccc;
107 | border-top: 1px dotted #999;
108 | padding: 2px 8px;
109 | }
110 |
111 | :global(.ant-modal) {
112 | width: 340px !important;
113 | margin: auto;
114 | }
115 |
--------------------------------------------------------------------------------
/test/unit/inspector-specs.js:
--------------------------------------------------------------------------------
1 | import chai from 'chai';
2 | import chaiAsPromised from 'chai-as-promised';
3 | import { getLocators, isUnique } from '../../app/renderer/components/Inspector/shared';
4 |
5 | const should = chai.should();
6 | chai.use(chaiAsPromised);
7 |
8 | describe('inspector', function () {
9 | describe('.getLocators', function () {
10 | it('should find ID', function () {
11 | getLocators({'resource-id': 'Resource ID'}).id.should.equal('Resource ID');
12 | getLocators({'id': 'ID'}).id.should.equal('ID');
13 | getLocators({'id': 'ID', 'resource-id': 'Resource ID'}).id.should.equal('Resource ID');
14 | });
15 | it('should not find ID if ID is not unique', function () {
16 | should.not.exist(getLocators({id: 'ID'}, `
17 |
18 |
19 | `).id);
20 | });
21 | it('should find accessibility id', function () {
22 | getLocators({'content-desc': 'Content Desc'})['accessibility id'].should.equal('Content Desc');
23 | getLocators({'name': 'Name'})['accessibility id'].should.equal('Name');
24 | getLocators({'name': 'Name'}, `
25 |
26 | `)['accessibility id'].should.equal('Name');
27 | getLocators({'content-desc': 'Content Desc', 'name': 'Name'})['accessibility id'].should.equal('Content Desc');
28 | });
29 | it('should not find accessibility ID if accessibility ID is not unique', function () {
30 | should.not.exist(getLocators({'content-desc': 'CD'}, `
31 |
32 |
33 | `).id);
34 | });
35 | });
36 | describe('.isUnique', function () {
37 | it('should return false if two nodes have the same attribute value', function () {
38 | isUnique('id', 'ID', `
39 |
40 |
41 | `).should.be.false;
42 | });
43 | it('should return false if two nodes have the same attribute value', function () {
44 | isUnique('id', 'ID', `
45 |
46 | `).should.be.true;
47 | isUnique('id', 'ID', `
48 |
49 |
50 | `).should.be.true;
51 | });
52 | it('should return true if no sourceXML was provided', function () {
53 | isUnique('hello', 'world').should.be.true;
54 | });
55 | });
56 | });
--------------------------------------------------------------------------------
/app/renderer/lib/client-frameworks/ruby.js:
--------------------------------------------------------------------------------
1 | import Framework from './framework';
2 |
3 | class RubyFramework extends Framework {
4 |
5 | get language () {
6 | return "ruby";
7 | }
8 |
9 | wrapWithBoilerplate (code) {
10 | let capStr = Object.keys(this.caps).map((k) => {
11 | return `caps[${JSON.stringify(k)}] = ${JSON.stringify(this.caps[k])}`;
12 | }).join("\n");
13 | return `# This sample code uses the Appium ruby client
14 | # gem install appium_lib
15 | # Then you can paste this into a file and simply run with Ruby
16 |
17 | require 'rubygems'
18 | require 'appium_lib'
19 |
20 | caps = {}
21 | ${capStr}
22 | opts = {
23 | sauce_username: nil,
24 | server_url: "${this.serverUrl}"
25 | }
26 | driver = Appium::Driver.new({caps: caps, appium_lib: opts}).start_driver
27 |
28 | ${code}
29 | driver.quit`;
30 | }
31 |
32 | codeFor_findAndAssign (strategy, locator, localVar, isArray) {
33 | let suffixMap = {
34 | xpath: ":xpath",
35 | 'accessibility id': ':accessibility_id',
36 | 'id': ':id',
37 | 'name': ':name',
38 | 'class name': ':class_name',
39 | '-android uiautomator': ':uiautomation',
40 | '-ios predicate string': ':predicate',
41 | '-ios class chain': ':class_chain',
42 | };
43 | if (!suffixMap[strategy]) {
44 | throw new Error(`Strategy ${strategy} can't be code-gened`);
45 | }
46 | if (isArray) {
47 | return `${localVar} = driver.find_element(${suffixMap[strategy]}, ${JSON.stringify(locator)})`;
48 | } else {
49 | return `${localVar} = driver.find_elements(${suffixMap[strategy]}, ${JSON.stringify(locator)})`;
50 | }
51 | }
52 |
53 | codeFor_click (varName, varIndex) {
54 | return `${this.getVarName(varName, varIndex)}.click`;
55 | }
56 |
57 | codeFor_clear (varName, varIndex) {
58 | return `${this.getVarName(varName, varIndex)}.clear`;
59 | }
60 |
61 | codeFor_sendKeys (varName, varIndex, text) {
62 | return `${this.getVarName(varName, varIndex)}.send_keys ${JSON.stringify(text)}`;
63 | }
64 |
65 | codeFor_back () {
66 | return `driver.back`;
67 | }
68 |
69 | codeFor_tap (varNameIgnore, varIndexIgnore, x, y) {
70 | return `TouchAction
71 | .new
72 | .tap(x: ${x}, y: ${y})
73 | .perform
74 | `;
75 | }
76 |
77 | codeFor_swipe (varNameIgnore, varIndexIgnore, x1, y1, x2, y2) {
78 | return `TouchAction
79 | .new
80 | .press({x: ${x1}, y: ${y1}})
81 | .moveTo({x: ${x2}, y: ${y2}})
82 | .release
83 | .perform
84 | `;
85 | }
86 | }
87 |
88 | RubyFramework.readableName = "Ruby";
89 |
90 | export default RubyFramework;
91 |
--------------------------------------------------------------------------------
/app/renderer/lib/client-frameworks/js-wd.js:
--------------------------------------------------------------------------------
1 | import Framework from './framework';
2 |
3 | class JsWdFramework extends Framework {
4 |
5 | get language () {
6 | return "js";
7 | }
8 |
9 | wrapWithBoilerplate (code) {
10 | let host = JSON.stringify(this.host);
11 | let caps = JSON.stringify(this.caps);
12 | return `// Requires the admc/wd client library
13 | // (npm install wd)
14 | // Then paste this into a .js file and run with Node 7.6+
15 |
16 | const wd = require('wd');
17 | const driver = wd.promiseChainRemote("${this.serverUrl}");
18 | const caps = ${caps};
19 |
20 | async function main () {
21 | await driver.init(caps);
22 | ${this.indent(code, 2)}
23 | await driver.quit();
24 | }
25 |
26 | main().catch(console.log);
27 | `;
28 | }
29 |
30 | codeFor_findAndAssign (strategy, locator, localVar, isArray) {
31 | let suffixMap = {
32 | xpath: "XPath",
33 | 'accessibility id': 'AccessibilityId',
34 | 'id': 'Id',
35 | 'name': 'Name',
36 | 'class name': 'ClassName',
37 | '-android uiautomator': 'AndroidUIAutomator',
38 | '-ios predicate string': 'IosUIAutomation',
39 | '-ios class chain': 'IosClassChain',
40 | };
41 | if (!suffixMap[strategy]) {
42 | throw new Error(`Strategy ${strategy} can't be code-gened`);
43 | }
44 | if (isArray) {
45 | return `let ${localVar} = await driver.elementsBy${suffixMap[strategy]}(${JSON.stringify(locator)});`;
46 | } else {
47 | return `let ${localVar} = await driver.elementBy${suffixMap[strategy]}(${JSON.stringify(locator)});`;
48 | }
49 | }
50 |
51 | codeFor_click (varName, varIndex) {
52 | return `await ${this.getVarName(varName, varIndex)}.click();`;
53 | }
54 |
55 | codeFor_clear (varName, varIndex) {
56 | return `await ${this.getVarName(varName, varIndex)}.clear();`;
57 | }
58 |
59 | codeFor_sendKeys (varName, varIndex, text) {
60 | return `await ${this.getVarName(varName, varIndex)}.sendKeys(${JSON.stringify(text)});`;
61 | }
62 |
63 | codeFor_back () {
64 | return `await driver.back();`;
65 | }
66 |
67 | codeFor_tap (varNameIgnore, varIndexIgnore, x, y) {
68 | return `await (new wd.TouchAction(driver))
69 | .tap({x: ${x}, y: ${y}})
70 | .perform()
71 | `;
72 | }
73 |
74 | codeFor_swipe (varNameIgnore, varIndexIgnore, x1, y1, x2, y2) {
75 | return `await (new wd.TouchAction(driver))
76 | .press({x: ${x1}, y: ${y1}})
77 | .moveTo({x: ${x2}, y: ${y2}})
78 | .release()
79 | .perform()
80 | `;
81 | }
82 |
83 | }
84 |
85 | JsWdFramework.readableName = "JS - WD (Promise)";
86 |
87 | export default JsWdFramework;
88 |
--------------------------------------------------------------------------------
/test/e2e/pages/inspector-page-object.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash';
2 | import { retryInterval } from 'asyncbox';
3 | import BasePage from './base-page-object';
4 |
5 | export default class InspectorPage extends BasePage {
6 |
7 | static selectors = {
8 | customServerHost: '#customServerHost',
9 | customServerPort: '#customServerPort',
10 | addDesiredCapabilityButton: '#btnAddDesiredCapability',
11 | formSubmitButton: '#btnStartSession',
12 | inspectorToolbar: 'div[class*=Inspector__inspector-toolbar]',
13 | selectedElementBody: '#selectedElementContainer .ant-card-body',
14 | tapSelectedElementButton: '#selectedElementContainer #btnTapElement',
15 | sourceTreeNode: '#sourceContainer .ant-tree-node-content-wrapper',
16 | recordedActionsPane: 'div[class*=Inspector__recorded-actions]',
17 | startRecordingButton: '#btnStartRecording',
18 | pauseRecordingButton: '#btnPause',
19 | reloadButton: '#btnReload',
20 | screenshotLoadingIndicator: '#screenshotContainer .ant-spin-dot',
21 | };
22 |
23 | constructor (client) {
24 | super(client);
25 | Object.assign(this, InspectorPage.selectors);
26 | }
27 |
28 | desiredCapabilityNameInput (rowIndex) {
29 | return `#desiredCapabilityName_${rowIndex}`;
30 | }
31 |
32 | desiredCapabilityValueInput (rowIndex) {
33 | return `#desiredCapabilityValue_${rowIndex}`;
34 | }
35 |
36 | async setCustomServerHost (host) {
37 | await this.client.setValue(this.customServerHost, host);
38 | }
39 |
40 | async setCustomServerPort (host) {
41 | await this.client.setValue(this.customServerPort, host);
42 | }
43 |
44 | async addDCaps (dcaps) {
45 | const dcapsPairs = _.toPairs(dcaps);
46 | for (let i = 0; i < dcapsPairs.length; i++) {
47 | const [name, value] = dcapsPairs[i];
48 | await this.client.setValue(this.desiredCapabilityNameInput(i), name);
49 | await this.client.setValue(this.desiredCapabilityValueInput(i), value);
50 | await this.client.click(this.addDesiredCapabilityButton);
51 | }
52 | }
53 |
54 | async startSession () {
55 | await this.client.click(this.formSubmitButton);
56 | }
57 |
58 | async closeNotification () {
59 | try {
60 | await retryInterval(5, 500, () => {
61 | this.client.click('.ant-notification-notice-close');
62 | });
63 | } catch (ign) { }
64 | }
65 |
66 | async startRecording () {
67 | await this.client.click(this.startRecordingButton);
68 | }
69 |
70 | async pauseRecording () {
71 | await this.client.click(this.pauseRecordingButton);
72 | }
73 |
74 | async reload () {
75 | await this.client.click(this.reloadButton);
76 | }
77 |
78 | }
--------------------------------------------------------------------------------
/app/renderer/components/ServerMonitor/ServerMonitor.css:
--------------------------------------------------------------------------------
1 | .container {
2 | width: 100%;
3 | height: 100%;
4 | background-color: #222;
5 | display: flex;
6 | flex-flow: column;
7 | align-items: center;
8 | justify-content: center;
9 | }
10 |
11 | .bar {
12 | width: 100%;
13 | height: 50px;
14 | padding: 8px;
15 | display: flex;
16 | justify-content: space-between;
17 | border-bottom: 1px solid #009a00;
18 | }
19 |
20 | .bar-running {
21 | }
22 |
23 | .bar-stopped {
24 | border-bottom-color: #a0a0a0;
25 | }
26 |
27 | .bar-stopping {
28 | border-bottom-color: orange;
29 | }
30 |
31 | .bar img {
32 | height: 30px;
33 | position: relative;
34 | top: 2px;
35 | }
36 |
37 | .status {
38 | color: #eee;
39 | font-size: 1.2em;
40 | position: relative;
41 | top: 4px;
42 | }
43 |
44 | .stopped {
45 | color: #a0a0a0;
46 | }
47 |
48 | .running {
49 | color: #009a00;
50 | }
51 |
52 | .stopping {
53 | color: orange;
54 | }
55 |
56 | .status span {
57 | margin-right: 10px;
58 | }
59 |
60 |
61 | .serverButton {
62 | color: #a0a0a0 !important;
63 | background-image: none !important;
64 | background-color: #252525 !important;
65 | border-color: #a0a0a0 !important;
66 | }
67 |
68 | .term {
69 | white-space: pre-wrap;
70 | width: 100%;
71 | flex: 1;
72 | color: #ccc;
73 | padding: 8px;
74 | user-select: text;
75 | -webkit-user-select: text;
76 | overflow: scroll;
77 | font-family: "Inconsolata", "Lucida console", Monaco, monospace;
78 | }
79 |
80 | .term::-webkit-scrollbar {
81 | width: 14px;
82 | background: transparent;
83 | }
84 |
85 | .term::-webkit-scrollbar-corner {
86 | background: transparent;
87 | }
88 |
89 | .term-stopped {
90 | color: #999;
91 | }
92 |
93 | .term div, .term span {
94 | user-select: text;
95 | -webkit-user-select: text;
96 | }
97 |
98 | .term span.icon {
99 | margin-right: 8px;
100 | font-size: 0.9em;
101 | display: inline-block;
102 | width: 10px;
103 | }
104 |
105 | .term :global(span.anticon-info-circle:before) {
106 | color: #5252bf;
107 | }
108 |
109 | .term :global(span.anticon-exclamation-circle:before) {
110 | color: yellow;
111 | }
112 |
113 | .term :global(span.anticon-close-circle:before) {
114 | color: red;
115 | }
116 |
117 | .term :global(span.anticon-message:before) {
118 | color: #999;
119 | }
120 |
121 | .term .last {
122 | border-bottom: 1px dashed #a0a0a0;
123 | margin-top: 10px;
124 | }
125 |
126 | .button-container button {
127 | padding: 8px;
128 | margin-right: 8px;
129 | height: auto;
130 | }
131 |
132 | .term .timestamp {
133 | color: #666;
134 | margin-right: 0.6em;
135 | }
136 |
--------------------------------------------------------------------------------
/app/renderer/components/Inspector/SourceScrollButtons.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { Icon, Button } from 'antd';
4 | import InspectorStyles from './Inspector.css';
5 | import { debounce, isEqual, clone } from 'lodash';
6 |
7 | export default class SourceScrollButtons extends Component {
8 |
9 | constructor () {
10 | super();
11 | this.checkShowScrollButtons = this.checkShowScrollButtons.bind(this);
12 | this.debouncedCheckShowScrollButtons = debounce(this.checkShowScrollButtons, 300);
13 | this.state = {};
14 | }
15 |
16 | componentDidMount () {
17 | this.checkShowScrollButtons();
18 | this.expandedPaths = clone(this.props.expandedPaths);
19 | window.addEventListener('resize', this.debouncedCheckShowScrollButtons);
20 | }
21 |
22 | componentWillUnmount () {
23 | window.removeEventListener('resize', this.debouncedCheckShowScrollButtons);
24 | }
25 |
26 | componentDidUpdate () {
27 | // If the expandedPaths changed, check if we need to show the scroll buttons
28 | if (!isEqual(this.props.expandedPaths, this.expandedPaths)) {
29 | this.debouncedCheckShowScrollButtons();
30 | this.expandedPaths = clone(this.props.expandedPaths);
31 | }
32 | }
33 |
34 | scroll (amount) {
35 | const cardBody = this.props.container.querySelector('.ant-card-body');
36 | const maxScroll = cardBody.scrollWidth - cardBody.clientWidth;
37 | const scrollLeft = cardBody.scrollLeft + amount;
38 |
39 | // Change scroll left, make it no lower than 0 and no higher than maxScroll
40 | if (scrollLeft > maxScroll) {
41 | cardBody.scrollLeft = maxScroll;
42 | } else if (scrollLeft < 0) {
43 | cardBody.scrollLeft = 0;
44 | } else {
45 | cardBody.scrollLeft = scrollLeft;
46 | }
47 |
48 | this.checkShowScrollButtons();
49 | }
50 |
51 | // Show the scroll buttons if the provided container is scrollable
52 | checkShowScrollButtons () {
53 | const cardBody = this.props.container.querySelector('.ant-card-body');
54 | const maxScroll = cardBody.scrollWidth - cardBody.clientWidth;
55 | this.setState({
56 | disableScrollLeft: cardBody.scrollLeft > 0,
57 | disableScrollRight: cardBody.scrollLeft < maxScroll,
58 | show: cardBody.scrollWidth > cardBody.clientWidth
59 | });
60 | }
61 |
62 | render () {
63 | return this.state.show ?
64 |
65 |
66 |
: null;
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/app/renderer/lib/client-frameworks/python.js:
--------------------------------------------------------------------------------
1 | import Framework from './framework';
2 |
3 | class PythonFramework extends Framework {
4 |
5 | get language () {
6 | return "python";
7 | }
8 |
9 | getPythonVal (jsonVal) {
10 | if (typeof jsonVal === 'boolean') {
11 | return jsonVal ? "True" : "False";
12 | }
13 | return JSON.stringify(jsonVal);
14 | }
15 |
16 | wrapWithBoilerplate (code) {
17 | let capStr = Object.keys(this.caps).map((k) => {
18 | return `caps[${JSON.stringify(k)}] = ${this.getPythonVal(this.caps[k])}`;
19 | }).join("\n");
20 | return `# This sample code uses the Appium python client
21 | # pip install Appium-Python-Client
22 | # Then you can paste this into a file and simply run with Python
23 |
24 | from appium import webdriver
25 |
26 | caps = {}
27 | ${capStr}
28 |
29 | driver = webdriver.Remote("${this.serverUrl}", caps)
30 |
31 | ${code}
32 | driver.quit()`;
33 | }
34 |
35 | codeFor_findAndAssign (strategy, locator, localVar, isArray) {
36 | let suffixMap = {
37 | xpath: "xpath",
38 | 'accessibility id': 'accessibility_id',
39 | 'id': 'id',
40 | 'name': 'name', // TODO: How does Python use name selector
41 | 'class name': 'class_name',
42 | '-android uiautomator': 'AndroidUIAutomator',
43 | '-ios predicate string': 'ios_predicate',
44 | '-ios class chain': 'ios_uiautomation', // TODO: Could not find iOS UIAutomation
45 | };
46 | if (!suffixMap[strategy]) {
47 | throw new Error(`Strategy ${strategy} can't be code-gened`);
48 | }
49 | if (isArray) {
50 | return `${localVar} = driver.find_elements_by_${suffixMap[strategy]}(${JSON.stringify(locator)})`;
51 | } else {
52 | return `${localVar} = driver.find_element_by_${suffixMap[strategy]}(${JSON.stringify(locator)})`;
53 | }
54 | }
55 |
56 | codeFor_click (varName, varIndex) {
57 | return `${this.getVarName(varName, varIndex)}.click()`;
58 | }
59 |
60 | codeFor_clear (varName, varIndex) {
61 | return `${this.getVarName(varName, varIndex)}.clear()`;
62 | }
63 |
64 | codeFor_sendKeys (varName, varIndex, text) {
65 | return `${this.getVarName(varName, varIndex)}.send_keys(${JSON.stringify(text)})`;
66 | }
67 |
68 | codeFor_back () {
69 | return `driver.back()`;
70 | }
71 |
72 | codeFor_tap (varNameIgnore, varIndexIgnore, x, y) {
73 | return `TouchAction(driver).tap(x=${x}, y=${y}).perform()`;
74 | }
75 |
76 | codeFor_swipe (varNameIgnore, varIndexIgnore, x1, y1, x2, y2) {
77 | return `TouchAction(driver) \
78 | .press(x=${x1}, y=${y1}) \
79 | .move_to(x=${x2}, y=${y2}) \
80 | .release() \
81 | .perform()
82 | `;
83 | }
84 | }
85 |
86 | PythonFramework.readableName = "Python";
87 |
88 | export default PythonFramework;
89 |
--------------------------------------------------------------------------------
/app/renderer/components/Inspector/Source.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Tree } from 'antd';
3 | import LocatorTestModal from './LocatorTestModal';
4 | import InspectorStyles from './Inspector.css';
5 |
6 | const {TreeNode} = Tree;
7 | const IMPORTANT_ATTRS = [
8 | 'name',
9 | 'content-desc',
10 | 'resource-id',
11 | 'AXDescription',
12 | 'AXIdentifier',
13 | ];
14 |
15 | /**
16 | * Shows the 'source' of the app as a Tree
17 | */
18 | export default class Source extends Component {
19 |
20 | getFormattedTag (el) {
21 | const {tagName, attributes} = el;
22 | let attrs = [];
23 | for (let attr of IMPORTANT_ATTRS) {
24 | if (attributes[attr]) {
25 | attrs.push(
26 | {attr}="{attributes[attr]}"
31 | );
32 | }
33 | }
34 | return
35 | <{tagName}{attrs}>
36 | ;
37 | }i
38 |
39 | /**
40 | * Binds to antd Tree onSelect. If an item is being unselected, path is undefined
41 | * otherwise 'path' refers to the element's path.
42 | */
43 | handleSelectElement (path) {
44 | const {selectElement, unselectElement} = this.props;
45 |
46 | if (!path) {
47 | unselectElement();
48 | } else {
49 | selectElement(path);
50 | }
51 | }
52 |
53 | render () {
54 | const {source, sourceError, setExpandedPaths, expandedPaths, selectedElement = {}, showLocatorTestModal} = this.props;
55 | const {path} = selectedElement;
56 |
57 | // Recursives through the source and renders a TreeNode for an element
58 | let recursive = (elemObj) => {
59 | if (!elemObj) return null;
60 | if (elemObj.children.length === 0) return null;
61 |
62 | return elemObj.children.map((el) => {
63 | return
64 | {recursive(el)}
65 | ;
66 | });
67 | };
68 |
69 | return
70 | {source &&
71 | this.handleSelectElement(selectedPaths[0])}
75 | selectedKeys={[path]}>
76 | {recursive(source)}
77 |
78 | }
79 | {!source && !sourceError &&
80 | Gathering initial app source...
81 | }
82 | {
83 | sourceError && `Could not obtain source: ${JSON.stringify(sourceError)}`
84 | }
85 |
86 |
;
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/app/renderer/components/Inspector/LocatedElements.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { Input, Row, Col, Button } from 'antd';
4 | import InspectorStyles from './Inspector.css';
5 |
6 | export default class LocatedElements extends Component {
7 |
8 | onSubmit () {
9 | const {locatedElements, locatorTestStrategy, locatorTestValue, searchForElement, clearSearchResults, hideLocatorTestModal} = this.props;
10 | if (locatedElements) {
11 | hideLocatorTestModal();
12 | clearSearchResults();
13 | } else {
14 | searchForElement(locatorTestStrategy, locatorTestValue);
15 | }
16 | }
17 |
18 | onCancel () {
19 | const {hideLocatorTestModal, clearSearchResults} = this.props;
20 | hideLocatorTestModal();
21 | clearSearchResults();
22 | }
23 |
24 | render () {
25 | const {locatedElements, applyClientMethod, setLocatorTestElement, locatorTestElement, clearSearchResults} = this.props;
26 |
27 | return
28 |
29 | e.preventDefault() || clearSearchResults()}><< Back
30 |
31 | Elements ({locatedElements.length}):
32 |
33 |
42 | {locatedElements.length > 0 &&
43 |
44 |
48 |
49 |
50 |
54 |
55 |
56 | this.setState({...this.state, sendKeys: e.target.value})}/>
57 |
60 |
61 |
}
62 |
63 |
;
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/app/main/main.development.js:
--------------------------------------------------------------------------------
1 | import { app, BrowserWindow, Menu } from 'electron';
2 | import { initializeIpc } from './appium';
3 | import { setSavedEnv } from './helpers';
4 | import menuTemplates from './menus';
5 | import path from 'path';
6 | import shellEnv from 'shell-env';
7 | import fixPath from 'fix-path';
8 |
9 | let menu;
10 | let template;
11 | let mainWindow = null;
12 | const isDev = process.env.NODE_ENV === 'development';
13 | // __dirname is this dir in dev, and the project root (where main.js is built) in prod
14 | const indexPath = path.resolve(__dirname, isDev ? '..' : 'app');
15 |
16 |
17 | if (isDev) {
18 | require('electron-debug')(); // eslint-disable-line global-require
19 | }
20 |
21 | if (!isDev) {
22 | // if we're running from the app package, we won't have access to env vars
23 | // normally loaded in a shell, so work around with the shell-env module
24 | const decoratedEnv = shellEnv.sync();
25 | process.env = {...process.env, ...decoratedEnv};
26 |
27 | // and we need to do the same thing with PATH
28 | fixPath();
29 | }
30 | setSavedEnv();
31 |
32 | app.on('window-all-closed', () => {
33 | app.quit();
34 | });
35 |
36 |
37 | const installExtensions = async () => {
38 | if (isDev) {
39 | const installer = require('electron-devtools-installer'); // eslint-disable-line global-require
40 | const extensions = [
41 | 'REACT_DEVELOPER_TOOLS',
42 | 'REDUX_DEVTOOLS'
43 | ];
44 | const forceDownload = !!process.env.UPGRADE_EXTENSIONS;
45 | for (const name of extensions) {
46 | try {
47 | await installer.default(installer[name], forceDownload);
48 | } catch (e) {} // eslint-disable-line
49 | }
50 | }
51 | };
52 |
53 | app.on('ready', async () => {
54 | await installExtensions();
55 |
56 | mainWindow = new BrowserWindow({
57 | show: false,
58 | width: isDev ? 1200 : 650,
59 | height: 600,
60 | minWidth: 650,
61 | minHeight: 600,
62 | });
63 |
64 | mainWindow.loadURL(`file://${indexPath}/renderer/index.html`);
65 |
66 | mainWindow.webContents.on('did-finish-load', () => {
67 | mainWindow.show();
68 | mainWindow.focus();
69 | });
70 |
71 | mainWindow.on('closed', () => {
72 | mainWindow = null;
73 | });
74 |
75 | if (isDev) {
76 | mainWindow.openDevTools();
77 | }
78 |
79 | mainWindow.webContents.on('context-menu', (e, props) => {
80 | const {x, y} = props;
81 |
82 | Menu.buildFromTemplate([{
83 | label: 'Inspect element',
84 | click () {
85 | mainWindow.inspectElement(x, y);
86 | }
87 | }]).popup(mainWindow);
88 | });
89 |
90 | if (process.platform === 'darwin') {
91 | template = await menuTemplates.mac(mainWindow);
92 | menu = Menu.buildFromTemplate(template);
93 | Menu.setApplicationMenu(menu);
94 | } else {
95 | template = await menuTemplates.other(mainWindow);
96 | menu = Menu.buildFromTemplate(template);
97 | mainWindow.setMenu(menu);
98 | }
99 |
100 | initializeIpc(mainWindow);
101 | });
102 |
103 |
--------------------------------------------------------------------------------
/app/renderer/components/Session/Session.css:
--------------------------------------------------------------------------------
1 | .active-session {
2 | background-color: #662d91;
3 | color: white;
4 | }
5 |
6 | .session-container {
7 | width: 100%;
8 | height: 100%;
9 | padding: 1em;
10 | background: linear-gradient(180deg, #f9f9f9, #f2f2f2, 5%, #f2f2f2, 95%, #d9d9d9);
11 | display: flex;
12 | flex-flow: column;
13 | }
14 |
15 | .session-container {
16 | width: 100%;
17 | padding: 1em;
18 | }
19 |
20 | .sessionFooter {
21 | text-align: right;
22 | height: 30px;
23 | }
24 |
25 | .tabText img {
26 | height: 20px;
27 | position: relative;
28 | top: 4px;
29 | filter: grayscale(100%);
30 | }
31 |
32 | :global(.ant-tabs-tab-active) .tabText img {
33 | filter: none;
34 | }
35 |
36 | .serverTabs {
37 | min-height: 160px;
38 | }
39 |
40 | .serverTabs :global(.ant-tabs-bar) {
41 | -webkit-app-region: drag;
42 | padding-top: 2em;
43 | margin-top: -1em;
44 | }
45 |
46 | .serverTabs :global(.ant-tabs-content) {
47 | height: 100px;
48 | }
49 |
50 | .scrollingTabCont {
51 | border-bottom: 1px solid #d9d9d9;
52 | margin-bottom: 15px;
53 | flex-grow: 1;
54 | display: flex;
55 | flex-direction: column;
56 | }
57 |
58 | .scrollingTabCont :global(.ant-tabs-bar) {
59 | margin-bottom: 0px;
60 | }
61 |
62 | .scrollingTab {
63 | overflow-y: scroll;
64 | flex-grow: 1;
65 | padding-top: 16px;
66 | }
67 |
68 | .scrollingTab::-webkit-scrollbar {
69 | width: 0px;
70 | }
71 |
72 | .formatted-caps {
73 | width: 100%;
74 | margin-bottom: 15px;
75 | min-height: 250px;
76 | }
77 |
78 | .formatted-caps pre {
79 | white-space: pre-wrap;
80 | word-wrap: break-word;
81 | }
82 |
83 | .start-session-button {
84 | margin-left: 1em;
85 | }
86 |
87 | .filepath-button {
88 | cursor: pointer;
89 | }
90 |
91 | .capsFormCol {
92 | min-width: 476px;
93 | }
94 |
95 | .capsFormRow {
96 | flex-wrap: nowrap;
97 | }
98 |
99 | .capsFormattedCol {
100 | flex-grow: 1;
101 | }
102 |
103 | .capsFormLastRow {
104 | width: 100%;
105 | text-align: right;
106 | }
107 |
108 | .capsValueControl {
109 | width: 142px;
110 | }
111 |
112 | .fileControlWrapper :global(.ant-input-group),
113 | .capsValueControl textarea {
114 | max-width: 142px;
115 | }
116 |
117 | .desired-capabilities-form-container {
118 | margin-bottom: 1em;
119 | }
120 |
121 | .desired-capabilities-form-container .add-desired-capability-button {
122 | display: none;
123 | }
124 |
125 | .desired-capabilities-form-container:last-child .add-desired-capability-button {
126 | display: initial;
127 | }
128 |
129 | .localDesc {
130 | font-style: italic;
131 | color: #666;
132 | }
133 |
134 | .selected {
135 | background-color: #d6e8ff;
136 | }
137 |
138 | .customServerInputLeft {
139 | width: 98% !important;
140 | }
141 |
142 | .capsEditorControls {
143 | position: absolute;
144 | right: 0;
145 | margin: 4px 24px;
146 | }
147 |
148 | .capsEditorButton {
149 | margin: 0 4px 0 0;
150 | }
151 |
152 | .capsEditor {
153 | width: 100%;
154 | height: 100%;
155 | border-color: lightgray;
156 | }
157 |
--------------------------------------------------------------------------------
/app/renderer/lib/client-frameworks/js-wdio.js:
--------------------------------------------------------------------------------
1 | import Framework from './framework';
2 |
3 | class JsWdIoFramework extends Framework {
4 |
5 | get language () {
6 | return "js";
7 | }
8 |
9 | chainifyCode (code) {
10 | return code
11 | .replace(/let .+ = /g, '')
12 | .replace(/(\n|^)(driver|el.+)\./g, '\n.')
13 | .replace(/;\n/g, '\n');
14 | }
15 |
16 | wrapWithBoilerplate (code) {
17 | let host = JSON.stringify(this.host);
18 | let caps = JSON.stringify(this.caps);
19 | let proto = JSON.stringify(this.scheme);
20 | let path = JSON.stringify(this.path);
21 | return `// Requires the webdriverio client library
22 | // (npm install webdriverio)
23 | // Then paste this into a .js file and run with Node:
24 | // node .js
25 |
26 | const wdio = require('webdriverio');
27 | const caps = ${caps};
28 | const driver = wdio.remote({
29 | protocol: ${proto},
30 | host: ${host},
31 | port: ${this.port},
32 | path: ${path},
33 | desiredCapabilities: caps
34 | });
35 |
36 | driver.init()
37 | ${this.indent(this.chainifyCode(code), 2)}
38 | .end();
39 | `;
40 | }
41 |
42 | codeFor_findAndAssign (strategy, locator, localVar, isArray) {
43 | // wdio has its own way of indicating the strategy in the locator string
44 | switch (strategy) {
45 | case "xpath": break; // xpath does not need to be updated
46 | case "accessibility id": locator = `~${locator}`; break;
47 | case "id": locator = `${locator}`; break;
48 | case "name": locator = `name=${locator}`; break;
49 | case "class name": locator = `${locator}`; break;
50 | case "-android uiautomator": locator = `android=${locator}`; break;
51 | case "-ios predicate string": locator = `ios=${locator}`; break;
52 | case "-ios class chain": locator = `ios=${locator}`; break; // TODO: Handle IOS class chain properly. Not all libs support it. Or take it out
53 | default: throw new Error(`Can't handle strategy ${strategy}`);
54 | }
55 | if (isArray) {
56 | return `let ${localVar} = driver.elements(${JSON.stringify(locator)});`;
57 | } else {
58 | return `let ${localVar} = driver.element(${JSON.stringify(locator)});`;
59 | }
60 | }
61 |
62 | codeFor_click (varName, varIndex) {
63 | return `${this.getVarName(varName, varIndex)}.click();`;
64 | }
65 |
66 | codeFor_clear (varName, varIndex) {
67 | return `${this.getVarName(varName, varIndex)}.clearElement();`;
68 | }
69 |
70 | codeFor_sendKeys (varName, varIndex, text) {
71 | return `${this.getVarName(varName, varIndex)}.setValue(${JSON.stringify(text)});`;
72 | }
73 |
74 | codeFor_back () {
75 | return `driver.back();`;
76 | }
77 |
78 | codeFor_tap (varNameIgnore, varIndexIgnore, x, y) {
79 | return `driver.touchAction({actions: 'tap', x: ${x}, y: ${y}})`;
80 | }
81 |
82 | codeFor_swipe (varNameIgnore, varIndexIgnore, x1, y1, x2, y2) {
83 | return `driver.touchAction([
84 | {action: 'press', x: ${x1}, y: ${y1}},
85 | {action: 'moveTo', x: ${x2}, y: ${y2}},
86 | 'release'
87 | ]);`;
88 | }
89 | }
90 |
91 | JsWdIoFramework.readableName = "JS - Webdriver.io";
92 |
93 | export default JsWdIoFramework;
94 |
--------------------------------------------------------------------------------
/app/renderer/actions/ServerMonitor.js:
--------------------------------------------------------------------------------
1 | import { ipcRenderer, shell } from 'electron';
2 | import { push } from 'react-router-redux';
3 | import { fs } from 'appium-support';
4 |
5 | export const SERVER_STOP_REQ = 'SERVER_STOP_REQ';
6 | export const SERVER_STOP_OK = 'SERVER_STOP_OK';
7 | export const SERVER_STOP_FAIL = 'SERVER_STOP_FAIL';
8 | export const LOGS_RECEIVED = 'LOGS_RECEIVED';
9 | export const LOGS_CLEARED = 'LOGS_CLEARED';
10 | export const MONITOR_CLOSED = 'MONITOR_CLOSED';
11 | export const START_SESSION_REQUEST = 'START_SESSION';
12 | export const SET_SERVER_ARGS = 'SET_SERVER_ARGS';
13 |
14 | export function stopServerReq () {
15 | return {type: SERVER_STOP_REQ};
16 | }
17 |
18 | export function stopServerOK () {
19 | return {type: SERVER_STOP_OK};
20 | }
21 |
22 | export function stopServerFailed (reason) {
23 | return {type: SERVER_STOP_FAIL, reason};
24 | }
25 |
26 | export function startSessionRequest (sessionUUID) {
27 | return {type: START_SESSION_REQUEST, sessionUUID};
28 | }
29 |
30 | export function serverLogsReceived (logs) {
31 | return {type: LOGS_RECEIVED, logs};
32 | }
33 |
34 | export function setServerArgs (args) {
35 | return {type: SET_SERVER_ARGS, args};
36 | }
37 |
38 | export function monitorClosed () {
39 | return {type: MONITOR_CLOSED};
40 | }
41 |
42 | function stopListening () {
43 | ipcRenderer.removeAllListeners('appium-log-line');
44 | ipcRenderer.removeAllListeners('appium-stop-error');
45 | }
46 |
47 | export function stopServer () {
48 | return (dispatch) => {
49 | dispatch(stopServerReq());
50 |
51 | ipcRenderer.once('appium-stop-error', (event, message) => {
52 | alert(`Stop server failed: ${message}`);
53 | dispatch(stopServerFailed(message));
54 | });
55 |
56 | stopListening();
57 |
58 | ipcRenderer.once('appium-stop-ok', () => {
59 | dispatch(serverLogsReceived([{
60 | level: 'info',
61 | msg: "Appium server stopped successfully"
62 | }]));
63 | setTimeout(() => {
64 | dispatch(stopServerOK());
65 | }, 0);
66 | });
67 |
68 | ipcRenderer.send('stop-server');
69 | };
70 | }
71 |
72 | export function closeMonitor () {
73 | return (dispatch) => {
74 | dispatch(monitorClosed());
75 | dispatch(push("/"));
76 | };
77 | }
78 |
79 | export function clearLogs () {
80 | return (dispatch, getState) => {
81 | const logfilePath = getState().startServer.logfilePath;
82 | if (logfilePath) {
83 | ipcRenderer.send('appium-clear-logfile', {logfilePath});
84 | }
85 | dispatch({type: LOGS_CLEARED});
86 | };
87 | }
88 |
89 | export function startSession () {
90 | return (dispatch) => {
91 | dispatch({type: START_SESSION_REQUEST});
92 | ipcRenderer.send('create-new-session-window');
93 | };
94 | }
95 |
96 | export function getRawLogs () {
97 | return (dispatch, getState) => {
98 | const logfilePath = getState().startServer.logfilePath;
99 | if (logfilePath) {
100 | shell.openExternal(`file://${logfilePath}`);
101 | } else {
102 | alert('An error has occurred: Logs not available');
103 | }
104 | };
105 | }
--------------------------------------------------------------------------------
/app/renderer/components/Session/SavedSessions.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import moment from 'moment';
3 | import { Button, Row, Col, Table } from 'antd';
4 | import FormattedCaps from './FormattedCaps';
5 | import SessionCSS from './Session.css';
6 |
7 |
8 | export default class SavedSessions extends Component {
9 |
10 |
11 | constructor (props) {
12 | super(props);
13 | this.onRowClick = this.onRowClick.bind(this);
14 | this.getRowClassName = this.getRowClassName.bind(this);
15 | }
16 |
17 | onRowClick (record) {
18 | const {setCaps} = this.props;
19 | let session = this.sessionFromUUID(record.key);
20 | setCaps(session.caps, session.uuid);
21 | }
22 |
23 | getRowClassName (record) {
24 | const {capsUUID} = this.props;
25 | return capsUUID === record.key ? SessionCSS.selected: '';
26 | }
27 |
28 | handleDelete (uuid) {
29 | return () => {
30 | if (window.confirm('Are you sure?')) {
31 | this.props.deleteSavedSession(uuid);
32 | }
33 | };
34 | }
35 |
36 | sessionFromUUID (uuid) {
37 | const {savedSessions} = this.props;
38 | for (let session of savedSessions) {
39 | if (session.uuid === uuid) {
40 | return session;
41 | }
42 | }
43 | throw new Error(`Couldn't find session with uuid ${uuid}`);
44 | }
45 |
46 | render () {
47 | const {savedSessions, setCaps, capsUUID, switchTabs} = this.props;
48 |
49 | const columns = [{
50 | title: 'Capability Set',
51 | dataIndex: 'name',
52 | key: 'name'
53 | }, {
54 | title: 'Created',
55 | dataIndex: 'date',
56 | key: 'date'
57 | }, {
58 | title: 'Actions',
59 | key: 'action',
60 | render: (text, record) => {
61 | let session = this.sessionFromUUID(record.key);
62 | return (
63 |
64 |
70 | );
71 | }
72 | }];
73 |
74 | let dataSource = [];
75 | if (savedSessions) {
76 | dataSource = savedSessions.map((session) => {
77 | return {
78 | key: session.uuid,
79 | name: (session.name || '(Unnamed)'),
80 | date: moment(session.date).format('YYYY-MM-DD')
81 | };
82 | });
83 | }
84 |
85 |
86 | return (
87 |
88 |
95 |
96 |
97 |
100 |
101 |
);
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/app/renderer/lib/client-frameworks/robot.js:
--------------------------------------------------------------------------------
1 | import Framework from './framework';
2 |
3 | class RobotFramework extends Framework {
4 |
5 | get language () {
6 | //TODO: Make https://highlightjs.org/ use robot syntax
7 | return "python";
8 | }
9 |
10 | get getCapsVariables() {
11 | return Object.keys(this.caps).map((k) => {
12 | return `\$\{${k}\} ${this.getPythonVal(this.caps[k])}`;
13 | }).join("\n");
14 | }
15 |
16 | getPythonVal (jsonVal) {
17 | if (typeof jsonVal === 'boolean') {
18 | return jsonVal ? "True" : "False";
19 | }
20 | return jsonVal;
21 | }
22 |
23 | wrapWithBoilerplate (code) {
24 | return `# This sample code uses the Appium robot client
25 | # pip install robotframework-appiumlibrary
26 | # Then you can paste this into a file and simply run with robot
27 | #
28 | # more keywords on: http://serhatbolsu.github.io/robotframework-appiumlibrary/AppiumLibrary.html
29 |
30 | *** Settings ***
31 | Library AppiumLibrary
32 |
33 | *** Variables ***
34 | \$\{REMOTE_URL\} ${this.serverUrl}
35 | ${this.getCapsVariables}
36 |
37 | *** Test Cases ***
38 | Test case name
39 | ${this.indent(this.getApplicationInitialization(), 4)}
40 | ${this.indent(code, 4)}
41 |
42 | *** Test Teardown ***
43 | Quit Application
44 |
45 | *** Suite Teardown ***
46 | Close Application`;
47 | }
48 |
49 | codeFor_findAndAssign (strategy, locator, localVar, isArray) {
50 | let suffixMap = {
51 | xpath: "xpath",
52 | 'accessibility id': 'accessibility_id',
53 | 'id': 'id',
54 | 'name': 'name', // TODO: How does Python use name selector
55 | 'class name': 'class_name',
56 | '-android uiautomator': 'AndroidUIAutomator',
57 | '-ios predicate string': 'ios_predicate',
58 | '-ios class chain': 'ios_uiautomation', // TODO: Could not find iOS UIAutomation
59 | };
60 | if (!suffixMap[strategy]) {
61 | throw new Error(`Strategy ${strategy} can't be code-gened`);
62 | }
63 | //TODO: in the robot case, we need the ID on the codeFor_ for execution
64 | this.lastID = `${strategy}=${locator}`;
65 | return `# ${this.lastID}`;
66 | }
67 |
68 | getApplicationInitialization() {
69 | let varString = Object.keys(this.caps).map((k) => {
70 | return `${k}=\$\{${k}\}`;
71 | }).join(" ");
72 | return `Open Application \$\{REMOTE_URL\} ${varString}`;
73 | }
74 |
75 | codeFor_click (varName, varIndex) {
76 | return `Click Element ${this.lastID}`;
77 | }
78 |
79 | codeFor_clear (varName, varIndex) {
80 | return `Clear Text ${this.lastID}`;
81 | }
82 |
83 | codeFor_sendKeys (varName, varIndex, text) {
84 | return `Input Text ${this.lastID} ${text}`;
85 | }
86 |
87 | codeFor_back () {
88 | return `Go Back`;
89 | }
90 |
91 | codeFor_tap (varNameIgnore, varIndexIgnore, x, y) {
92 | return `Tap ${this.lastID} ${x} ${y}`;
93 | }
94 |
95 | codeFor_swipe (varNameIgnore, varIndexIgnore, x1, y1, x2, y2) {
96 | return `Swipe ${x1} ${y1} ${x2} ${y2}`;
97 | }
98 | }
99 |
100 | RobotFramework.readableName = "Robot Framework";
101 |
102 | export default RobotFramework;
103 |
--------------------------------------------------------------------------------
/test/e2e/main-e2e.test.js:
--------------------------------------------------------------------------------
1 | import { Application } from 'spectron';
2 | import { fs } from 'appium-support';
3 | import os from 'os';
4 | import path from 'path';
5 | import chai from 'chai';
6 | import chaiAsPromised from 'chai-as-promised';
7 | import dirCompare from 'dir-compare';
8 | import MainPage from './pages/main-page-object';
9 |
10 | const platform = os.platform();
11 |
12 | chai.should();
13 | chai.use(chaiAsPromised);
14 |
15 | let appPath;
16 | if (platform === 'linux') {
17 | appPath = path.join(__dirname, '..', '..', 'release', 'linux-unpacked', 'appium-desktop');
18 | } else if (platform === 'darwin') {
19 | appPath = path.join(__dirname, '..', '..', 'release', 'mac', 'Appium.app', 'Contents', 'MacOS', 'Appium');
20 | } else if (platform === 'win32') {
21 | appPath = path.join(__dirname, '..', '..', 'release', 'win-ia32-unpacked', 'Appium.exe');
22 | }
23 |
24 | before(async function () {
25 | this.timeout(process.env.TRAVIS || process.env.APPVEYOR ? 10 * 60 * 1000 : 30 * 1000);
26 | this.app = new Application({
27 | path: appPath,
28 | });
29 | await this.app.start();
30 | });
31 |
32 | after(function () {
33 | if (this.app && this.app.isRunning()) {
34 | return this.app.stop();
35 | }
36 | });
37 |
38 | describe('application launch', function () {
39 | let initialWindowCount;
40 |
41 | let main;
42 | let client;
43 |
44 | before(async function () {
45 | client = this.app.client;
46 | main = new MainPage(client);
47 | initialWindowCount = await this.app.client.getWindowCount();
48 | });
49 |
50 | it('starts the server and opens a new session window', async function () {
51 | // Start the server
52 | await client.waitForExist(main.startServerButton);
53 | await main.startServer();
54 |
55 | // Wait for the server monitor container to be present
56 | await client.waitForExist(main.serverMonitorContainer);
57 | const source = await client.source();
58 | source.value.indexOf('Welcome to Appium').should.be.above(0);
59 |
60 | // Start a new session and confirm that it opens a new window
61 | await main.startNewSession();
62 | await client.pause(500);
63 | await client.getWindowCount().should.eventually.equal(initialWindowCount + 1);
64 | });
65 |
66 | it('check that WebDriverAgent folder is the same in /releases as it is in /node_modules (regression test for https://github.com/appium/appium-desktop/issues/417)', async function () {
67 | // NOTE: This isn't really an "e2e" test, but the test has to be written here because the /release
68 | // folder needs to be built in order to run the test
69 | if (platform !== 'darwin') {
70 | return this.skip();
71 | }
72 |
73 | const resourcesWDAPath = path.join(__dirname, '..', '..', 'release', 'mac', 'Appium.app', 'Contents', 'Resources',
74 | 'app', 'node_modules', 'appium', 'node_modules', 'appium-xcuitest-driver', 'WebDriverAgent');
75 |
76 | await fs.exists(path.join(resourcesWDAPath, 'PrivateHeaders')).should.eventually.be.true;
77 |
78 | const localWDAPath = path.join(__dirname, '..', '..', 'node_modules', 'appium', 'node_modules', 'appium-xcuitest-driver', 'WebDriverAgent');
79 | const res = await dirCompare.compare(resourcesWDAPath, localWDAPath);
80 | res.distinct.should.equal(0);
81 | });
82 | });
--------------------------------------------------------------------------------
/app/renderer/lib/client-frameworks/framework.js:
--------------------------------------------------------------------------------
1 | export default class Framework {
2 |
3 | constructor (host, port, path, https, caps) {
4 | this.host = host || "localhost";
5 | this.port = port || 4723;
6 | this.path = path || "/wd/hub";
7 | this.caps = caps || {};
8 | this.https = !!https;
9 | this.scheme = https ? 'https' : 'http';
10 | this.actions = [];
11 | this.localVarCount = 0;
12 | this.localVarCache = {};
13 | this.lastAssignedVar = null;
14 | }
15 |
16 | get serverUrl () {
17 | return `${this.scheme}://${this.host}:${this.port}${this.path}`;
18 | }
19 |
20 | get name () {
21 | throw new Error("Must implement name getter");
22 | }
23 |
24 | get language () {
25 | throw new Error("Must implement language getter");
26 | }
27 |
28 | addAction (action, params) {
29 | this.actions.push({action, params});
30 | }
31 |
32 | wrapWithBoilerplate () {
33 | throw new Error("Must implement wrapWithBoilerplate");
34 | }
35 |
36 | indent (str, spaces) {
37 | let lines = str.split("\n");
38 | let spaceStr = "";
39 | for (let i = 0; i < spaces; i++) {
40 | spaceStr += " ";
41 | }
42 | return lines
43 | .filter((l) => !!l.trim())
44 | .map((l) => `${spaceStr}${l}`)
45 | .join("\n");
46 | }
47 |
48 | getCodeString (includeBoilerplate = false) {
49 | let str = "";
50 | for (let {action, params} of this.actions) {
51 | let genCodeFn = `codeFor_${action}`;
52 | if (!this[genCodeFn]) {
53 | throw new Error(`Need to implement 'codeFor_${action}()'`);
54 | }
55 | let code = this[genCodeFn](...params);
56 | if (code) {
57 | str += `${code}\n`;
58 | }
59 | }
60 | if (includeBoilerplate) {
61 | return this.wrapWithBoilerplate(str);
62 | }
63 | return str;
64 | }
65 |
66 | getNewLocalVar () {
67 | this.localVarCount++;
68 | return `el${this.localVarCount}`;
69 | }
70 |
71 | getVarForFind (strategy, locator) {
72 | const key = `${strategy}-${locator}`;
73 | let wasNew = false;
74 | if (!this.localVarCache[key]) {
75 | this.localVarCache[key] = this.getNewLocalVar();
76 | wasNew = true;
77 | }
78 | this.lastAssignedVar = this.localVarCache[key];
79 | return [this.localVarCache[key], wasNew];
80 | }
81 |
82 | getVarName (varName, varIndex) {
83 | if (varIndex || varIndex === 0) {
84 | return `${varName}[${varIndex}]`;
85 | }
86 | return varName;
87 | }
88 |
89 | codeFor_findAndAssign () {
90 | throw new Error("Need to implement codeFor_findAndAssign");
91 | }
92 |
93 | codeFor_findElement (strategy, locator) {
94 | let [localVar, wasNew] = this.getVarForFind(strategy, locator);
95 | if (!wasNew) {
96 | // if we've already found this element, don't print out
97 | // finding it again
98 | return "";
99 | }
100 |
101 | return this.codeFor_findAndAssign(strategy, locator, localVar);
102 |
103 | }
104 |
105 | codeFor_tap () {
106 | throw new Error("Need to implement codeFor_tap");
107 | }
108 |
109 | codeFor_swipe () {
110 | throw new Error("Need to implement codeFor_tap");
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/app/renderer/components/Inspector/RecordedActions.js:
--------------------------------------------------------------------------------
1 | import { clipboard } from 'electron';
2 | import React, { Component } from 'react';
3 | import ReactDOM from 'react-dom';
4 | import { Card, Select, Tooltip, Button, Icon } from 'antd';
5 | import InspectorStyles from './Inspector.css';
6 | import frameworks from '../../lib/client-frameworks';
7 | import { highlight } from 'highlight.js';
8 |
9 | const Option = Select.Option;
10 | const ButtonGroup = Button.Group;
11 |
12 | export default class RecordedActions extends Component {
13 |
14 |
15 | code (raw = true) {
16 | let {showBoilerplate, sessionDetails, recordedActions, actionFramework
17 | } = this.props;
18 | let {host, port, path, https, desiredCapabilities} = sessionDetails;
19 |
20 | let framework = new frameworks[actionFramework](host, port, path,
21 | https, desiredCapabilities);
22 | framework.actions = recordedActions;
23 | let rawCode = framework.getCodeString(showBoilerplate);
24 | if (raw) {
25 | return rawCode;
26 | }
27 | return highlight(framework.language, rawCode, true).value;
28 | }
29 |
30 | actionBar () {
31 | let {showBoilerplate, recordedActions, setActionFramework,
32 | toggleShowBoilerplate, clearRecording, closeRecorder,
33 | actionFramework, isRecording
34 | } = this.props;
35 |
36 | let frameworkOpts = Object.keys(frameworks).map((f) => );
39 |
40 | let boilerplateType = showBoilerplate ? "primary" : "default";
41 |
42 | return
43 | {!!recordedActions.length &&
44 |
48 | }
49 | {(!!recordedActions.length || !isRecording) &&
50 |
51 | {!!recordedActions.length &&
52 |
53 |
56 |
57 | }
58 | {!!recordedActions.length &&
59 |
60 | clipboard.writeText(this.code())}
62 | />
63 |
64 | }
65 | {!!recordedActions.length &&
66 |
67 |
68 |
69 | }
70 | {!isRecording &&
71 |
72 |
73 |
74 | }
75 |
76 | }
77 |
;
78 | }
79 |
80 | render () {
81 | const {recordedActions} = this.props;
82 |
83 | const highlightedCode = this.code(false);
84 |
85 | return Recorder}
86 | className={InspectorStyles['recorded-actions']}
87 | extra={this.actionBar()}
88 | >
89 | {!recordedActions.length &&
90 |
91 | Perform some actions to see code show up here
92 |
93 | }
94 | {!!recordedActions.length &&
95 |
97 | }
98 | ;
99 | }
100 | }
101 |
102 |
--------------------------------------------------------------------------------
/docs/style.md:
--------------------------------------------------------------------------------
1 | # Style Guidelines
2 |
3 | (This is a work in progress)
4 |
5 | ## Writing Redux Reducers
6 |
7 | * Always define an `initialState` variable
8 |
9 | * Always extend the state and never overwrite it
10 |
11 | ```javascript
12 | // good
13 | export default function session (state = initialState, action) {
14 | switch (action.type) {
15 | case CASE_NAME:
16 | return {...state, item1: action.item1, item2: action.item2};
17 | default:
18 | return {...state};
19 | }
20 | }
21 |
22 | // bad
23 | export default function session (state = initialState, action) {
24 | switch (action.type) {
25 | case CASE_NAME:
26 | return {item1: action.item1, item2: action.item2}; // Do not overwrite the entire state
27 | default:
28 | return {...state};
29 | }
30 | }
31 | ```
32 |
33 | * The initial state should define every property up front (properties shouldn't be introduced to the state by a future action) and each property should always be scrictly typed
34 |
35 | ```javascript
36 | // good
37 | const initialState = {
38 | flag: false, // boolean
39 | str: '', // string
40 | arr: [], // array
41 | };
42 |
43 | export default function session (state = initialState, action) {
44 | switch (action.type) {
45 | case CASE_NAME_1:
46 | return {...state, flag: !!action.flag};
47 | case CASE_NAME_2:
48 | return {...state, str: action.str || ''};
49 | case CASE_NAME_3:
50 | return {...state, arr: Object.assign([], action.arr)};
51 | default:
52 | return {...state};
53 | }
54 | }
55 |
56 | // bad
57 | const initialState = {
58 | str: '', // string
59 | arr: [], // array
60 | };
61 |
62 | export default function session (state = v, action) {
63 | switch (action.type) {
64 | case CASE_NAME_1:
65 | return {...state, flag: !!action.flag}; // 'flag' should be defined in initial state
66 | case CASE_NAME_2:
67 | return {...state, str: action.str}; // What if action.str is not a string?
68 | case CASE_NAME_3:
69 | return {...state, arr: action.arr}; // What if action.arr is not an array?
70 | default:
71 | return {...state}
72 | }
73 | }
74 | ```
75 |
76 | * Objects should always be updated via the spread `...` notation (unless the case specifically requires the object to be overwritten). If an object is incorrectly overwritten, we risk having undefined references.
77 |
78 | ```javascript
79 |
80 | // good
81 | const initialState = {
82 | server: {
83 | local: {},
84 | remote: {},
85 | sauce: {},
86 | testobject: {},
87 | }
88 | };
89 |
90 | export default function session (state = INITIAL_STATE, action) {
91 | switch (action.type) {
92 | case CASE_NAME:
93 | return {
94 | ...state,
95 | server: {
96 | ...state.server,
97 | ...action.server,
98 | },
99 | };
100 | default:
101 | return {...state};
102 | }
103 | }
104 |
105 | // bad
106 | const initialState = {
107 | server: {
108 | local: {},
109 | remote: {},
110 | sauce: {},
111 | testobject: {},
112 | }
113 | };
114 |
115 | export default function session (state = INITIAL_STATE, action) {
116 | switch (action.type) {
117 | case CASE_NAME:
118 | return {
119 | ...state,
120 | server: {
121 | local: action.local,
122 | remote: action.remote,
123 | sauce: action.sauce,
124 | testobject: action.testobject,
125 | }, // What if a 5th property is added? It will be overwritten by this case.
126 | };
127 | default:
128 | return {...state};
129 | }
130 | }
131 | ```
132 |
133 |
134 |
135 |
136 |
137 |
--------------------------------------------------------------------------------
/test/e2e/inspector-e2e.test.js:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import chai from 'chai';
3 | import chaiAsPromised from 'chai-as-promised';
4 | import { startServer as startAppiumFakeDriverServer } from 'appium-fake-driver';
5 | import { retryInterval } from 'asyncbox';
6 | import InspectorPage from './pages/inspector-page-object';
7 |
8 | chai.should();
9 | chai.use(chaiAsPromised);
10 |
11 | const FAKE_DRIVER_PORT = 12121;
12 |
13 | const TEST_APP = path.resolve('node_modules', 'appium-fake-driver', 'test', 'fixtures', 'app.xml');
14 |
15 | const DEFAULT_CAPS = {
16 | platformName: 'Fake',
17 | deviceName: 'Fake',
18 | app: TEST_APP,
19 | };
20 |
21 | let client;
22 |
23 | describe('inspector window', function () {
24 |
25 | let inspector;
26 |
27 | before(async function () {
28 | // Start an Appium fake driver server
29 | startAppiumFakeDriverServer(FAKE_DRIVER_PORT, '127.0.0.1');
30 |
31 | // Navigate to session URL
32 | client = this.app.client;
33 | inspector = new InspectorPage(client);
34 | await inspector.open('session');
35 |
36 | // Set the desired capabilities
37 | await client.waitForExist(inspector.addDesiredCapabilityButton);
38 | await inspector.addDCaps(DEFAULT_CAPS);
39 |
40 | // Set the fake driver server and port
41 | await inspector.setCustomServerHost('127.0.0.1');
42 | await inspector.setCustomServerPort(FAKE_DRIVER_PORT);
43 |
44 | // Start the session
45 | await inspector.startSession();
46 | });
47 |
48 | after(async function () {
49 | await inspector.goHome();
50 | });
51 |
52 | beforeEach(async function () {
53 | await client.waitForExist(inspector.inspectorToolbar);
54 | });
55 |
56 | it('shows content in "Selected Element" pane when clicking on an item in the Source inspector', async function () {
57 | await client.getHTML(inspector.selectedElementBody).should.eventually.contain('Select an element');
58 | await client.waitForExist(inspector.sourceTreeNode);
59 | await client.click(inspector.sourceTreeNode);
60 | await client.waitForExist(inspector.tapSelectedElementButton);
61 | await client.getHTML(inspector.selectedElementBody).should.eventually.contain('btnTapElement');
62 | await client.click(inspector.tapSelectedElementButton);
63 | await inspector.closeNotification();
64 | });
65 |
66 | it('shows a loading indicator in screenshot after clicking "Refresh" and then indicator goes away when refresh is complete', async function () {
67 | await inspector.reload();
68 | const spinDots = await client.elements(inspector.screenshotLoadingIndicator);
69 | spinDots.value.length.should.equal(1);
70 | await retryInterval(15, 100, async function () {
71 | const spinDots = await client.elements(inspector.screenshotLoadingIndicator);
72 | spinDots.value.length.should.equal(0);
73 | });
74 | });
75 |
76 | it('shows a new pane when click "Start Recording" button and then the pane disappears when clicking "Pause"', async function () {
77 | // Check that there's no recorded actions pane
78 | let recordedPanes = await client.elements(inspector.recordedActionsPane);
79 | recordedPanes.value.length.should.equal(0);
80 |
81 | // Start a recording and check that there is a recorded actions pane
82 | await inspector.startRecording();
83 | await client.waitForExist(inspector.recordedActionsPane);
84 | recordedPanes = await client.elements(inspector.recordedActionsPane);
85 | recordedPanes.value.length.should.equal(1);
86 |
87 | // Pause the recording and check that the recorded actions pane is gone again
88 | await inspector.pauseRecording();
89 | recordedPanes = await client.elements(inspector.recordedActionsPane);
90 | recordedPanes.value.length.should.equal(0);
91 | });
92 | });
--------------------------------------------------------------------------------
/app/renderer/lib/client-frameworks/java.js:
--------------------------------------------------------------------------------
1 | import Framework from './framework';
2 |
3 | class JavaFramework extends Framework {
4 |
5 | get language () {
6 | return "java";
7 | }
8 |
9 | wrapWithBoilerplate (code) {
10 | let [pkg, cls] = (() => {
11 | if (this.caps.platformName) {
12 | switch (this.caps.platformName.toLowerCase()) {
13 | case "ios": return ["ios", "IOSDriver"];
14 | case "android": return ["android", "AndroidDriver"];
15 | default: return ["unknownPlatform", "UnknownDriver"];
16 | }
17 | } else {
18 | return ["unknownPlatform", "UnknownDriver"];
19 | }
20 | })();
21 | let capStr = this.indent(Object.keys(this.caps).map((k) => {
22 | return `desiredCapabilities.setCapability(${JSON.stringify(k)}, ${JSON.stringify(this.caps[k])});`;
23 | }).join("\n"), 4);
24 | return `import io.appium.java_client.MobileElement;
25 | import io.appium.java_client.${pkg}.${cls};
26 | import junit.framework.TestCase;
27 | import org.junit.After;
28 | import org.junit.Before;
29 | import org.junit.Test;
30 | import java.net.MalformedURLException;
31 | import java.net.URL;
32 | import org.openqa.selenium.remote.DesiredCapabilities;
33 |
34 | public class SampleTest {
35 |
36 | private ${cls} driver;
37 |
38 | @Before
39 | public void setUp() throws MalformedURLException {
40 | DesiredCapabilities desiredCapabilities = new DesiredCapabilities();
41 | ${capStr}
42 |
43 | URL remoteUrl = new URL("${this.serverUrl}");
44 |
45 | driver = new ${cls}(remoteUrl, desiredCapabilities);
46 | }
47 |
48 | @Test
49 | public void sampleTest() {
50 | ${this.indent(code, 4)}
51 | }
52 |
53 | @After
54 | public void tearDown() {
55 | driver.quit();
56 | }
57 | }
58 | `;
59 | }
60 |
61 | codeFor_findAndAssign (strategy, locator, localVar, isArray) {
62 | let suffixMap = {
63 | xpath: "XPath",
64 | 'accessibility id': 'AccessibilityId',
65 | 'id': 'Id',
66 | 'class name': 'ClassName',
67 | 'name': 'Name',
68 | '-android uiautomator': 'AndroidUIAutomator',
69 | '-ios predicate string': 'IosNsPredicate',
70 | '-ios class chain': 'IosClassChain',
71 | };
72 | if (!suffixMap[strategy]) {
73 | throw new Error(`Strategy ${strategy} can't be code-gened`);
74 | }
75 | if (isArray) {
76 | return `List ${localVar} = (MobileElement) driver.findElementsBy${suffixMap[strategy]}(${JSON.stringify(locator)});`;
77 | } else {
78 | return `MobileElement ${localVar} = (MobileElement) driver.findElementBy${suffixMap[strategy]}(${JSON.stringify(locator)});`;
79 | }
80 | }
81 |
82 | getVarName (varName, varIndex) {
83 | if (varIndex || varIndex === 0) {
84 | return `${varName}.get(${varIndex})`;
85 | }
86 | return varName;
87 | }
88 |
89 | codeFor_click (varName, varIndex) {
90 | return `${this.getVarName(varName, varIndex)}.click();`;
91 | }
92 |
93 | codeFor_clear (varName, varIndex) {
94 | return `${this.getVarName(varName, varIndex)}.clear();`;
95 | }
96 |
97 | codeFor_sendKeys (varName, varIndex, text) {
98 | return `${this.getVarName(varName, varIndex)}.sendKeys(${JSON.stringify(text)});`;
99 | }
100 |
101 | codeFor_back () {
102 | return `driver.navigate().back();`;
103 | }
104 |
105 | codeFor_tap (varNameIgnore, varIndexIgnore, x, y) {
106 | return `(new TouchAction(driver)).tap(${x}, ${y}).perform()`;
107 | }
108 |
109 | codeFor_swipe (varNameIgnore, varIndexIgnore, x1, y1, x2, y2) {
110 | return `(new TouchAction(driver))
111 | .press({x: ${x1}, y: ${y1}})
112 | .moveTo({x: ${x2}: y: ${y2}})
113 | .release()
114 | .perform()
115 | `;
116 | }
117 | }
118 |
119 | JavaFramework.readableName = "Java - JUnit";
120 |
121 | export default JavaFramework;
122 |
--------------------------------------------------------------------------------
/app/renderer/actions/StartServer.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | import { ipcRenderer } from 'electron';
3 | import { push } from 'react-router-redux';
4 | import { serverLogsReceived, clearLogs, setServerArgs } from './ServerMonitor';
5 | import settings from '../../settings';
6 |
7 | export const SERVER_START_REQ = 'SERVER_START_REQ';
8 | export const SERVER_START_OK = 'SERVER_START_OK';
9 | export const SERVER_START_ERR = 'SERVER_START_ERR';
10 | export const UPDATE_ARGS = 'UPDATE_ARGS';
11 | export const SWITCH_TAB = 'SWITCH_TAB';
12 | export const PRESET_SAVE_REQ = 'PRESET_SAVE_REQ';
13 | export const PRESET_SAVE_OK = 'PRESET_SAVE_OK';
14 | export const GET_PRESETS = 'GET_PRESETS';
15 | export const PRESET_DELETE_REQ = 'PRESET_DELETE_REQ';
16 | export const PRESET_DELETE_OK = 'PRESET_DELETE_OK';
17 | export const SET_LOGFILE_PATH = 'SET_LOGFILE_PATH';
18 |
19 | export const PRESETS = 'presets';
20 |
21 | export function startServer (evt) {
22 | evt.preventDefault();
23 | return (dispatch, getState) => {
24 | // signal to the UI that we are beginning our request
25 | dispatch({type: SERVER_START_REQ});
26 | const {serverArgs} = getState().startServer;
27 |
28 | // if we get an error from electron, fail with the message
29 | ipcRenderer.once('appium-start-error', (event, message) => {
30 | // don't listen for log lines any more if we failed to start, other-
31 | // wise we'll start to stack listeners for subsequent attempts
32 | ipcRenderer.removeAllListeners('appium-log-line');
33 | alert(`Error starting Appium server: ${message}`);
34 | dispatch({type: SERVER_START_ERR});
35 | });
36 |
37 | ipcRenderer.once('appium-start-ok', () => {
38 | // don't listen for subsequent server start failures later in the
39 | // lifetime of this app instance
40 | ipcRenderer.removeAllListeners('appium-start-error');
41 | dispatch({type: SERVER_START_OK});
42 | dispatch(setServerArgs(serverArgs));
43 | dispatch(push('/monitor'));
44 | });
45 |
46 | ipcRenderer.on('appium-log-line', (event, logs) => {
47 | dispatch(serverLogsReceived(logs));
48 | });
49 |
50 | dispatch(clearLogs());
51 | ipcRenderer.once('path-to-logs', (event, logfilePath) => dispatch({type: SET_LOGFILE_PATH, logfilePath}));
52 | ipcRenderer.send('start-server', serverArgs);
53 | };
54 | }
55 |
56 | export function updateArgs (args) {
57 | return (dispatch) => {
58 | dispatch({type: UPDATE_ARGS, args});
59 | };
60 | }
61 |
62 | export function switchTab (tabId) {
63 | return (dispatch) => {
64 | dispatch({type: SWITCH_TAB, tabId});
65 | };
66 | }
67 |
68 | export function savePreset (name, args) {
69 | return async (dispatch) => {
70 | dispatch({type: PRESET_SAVE_REQ});
71 | let presets = await settings.get(PRESETS);
72 | try {
73 | presets[name] = args;
74 | presets[name]._modified = Date.now();
75 | await settings.set(PRESETS, presets);
76 | } catch (e) {
77 | console.error(e);
78 | alert(`There was a problem saving preset: ${e.message}`);
79 | }
80 | dispatch({type: PRESET_SAVE_OK, presets});
81 | };
82 | }
83 |
84 | export function getPresets () {
85 | return async (dispatch) => {
86 | try {
87 | let presets = await settings.get(PRESETS);
88 | dispatch({type: GET_PRESETS, presets});
89 | } catch (e) {
90 | console.error(e);
91 | alert(`Error getting presets: ${e.message}`);
92 | }
93 | };
94 | }
95 |
96 | export function deletePreset (name) {
97 | return async (dispatch) => {
98 | dispatch({type: PRESET_DELETE_REQ});
99 | let presets = await settings.get(PRESETS);
100 | try {
101 | delete presets[name];
102 | await settings.set(PRESETS);
103 | } catch (e) {
104 | console.error(e);
105 | alert(`There was a problem deleting preset: ${e.message}`);
106 | }
107 | dispatch({type: PRESET_DELETE_OK, presets});
108 | };
109 | }
--------------------------------------------------------------------------------
/package.js:
--------------------------------------------------------------------------------
1 | /* eslint strict: 0, no-shadow: 0, no-unused-vars: 0, no-console: 0 */
2 | /* eslint-disable promise/prefer-await-to-callbacks, promise/no-native,
3 | promise/prefer-await-to-then */
4 | 'use strict';
5 |
6 | require('babel-polyfill');
7 | const os = require('os');
8 | const webpack = require('webpack');
9 | const electronCfg = require('./webpack.config.electron.js');
10 | const cfg = require('./webpack.config.production.js');
11 | const packager = require('electron-packager');
12 | const del = require('del');
13 | const exec = require('child_process').exec;
14 | const argv = require('minimist')(process.argv.slice(2));
15 | const pkg = require('./package.json');
16 | const deps = Object.keys(pkg.dependencies);
17 | const devDeps = Object.keys(pkg.devDependencies);
18 |
19 | const appName = argv.name || argv.n || pkg.productName;
20 | const shouldUseAsar = argv.asar || argv.a || false;
21 | const shouldBuildAll = argv.all || false;
22 |
23 |
24 | const DEFAULT_OPTS = {
25 | dir: './',
26 | name: 'Appium',
27 | asar: shouldUseAsar,
28 | ignore: [
29 | '^/test($|/)',
30 | '^/tools($|/)',
31 | '^/release($|/)',
32 | '^/main.development.js'
33 | ].concat(devDeps.map((name) => `/node_modules/${name}($|/)`))
34 | .concat(
35 | deps.filter((name) => !electronCfg.externals.includes(name))
36 | .map((name) => `/node_modules/${name}($|/)`)
37 | )
38 | };
39 |
40 | const icon = argv.icon || argv.i || 'app/app';
41 |
42 | if (icon) {
43 | DEFAULT_OPTS.icon = icon;
44 | }
45 |
46 | const version = argv.version || argv.v;
47 |
48 | if (version) {
49 | DEFAULT_OPTS.version = version;
50 | startPack();
51 | } else {
52 | // use the same version as the currently-installed electron-prebuilt
53 | exec('npm list electron-prebuilt --dev', (err, stdout) => {
54 | if (err) {
55 | DEFAULT_OPTS.version = '1.2.0';
56 | } else {
57 | DEFAULT_OPTS.version = stdout.split('electron-prebuilt@')[1].replace(/\s/g, '');
58 | }
59 |
60 | startPack();
61 | });
62 | }
63 |
64 |
65 | function build (cfg) {
66 | return new Promise((resolve, reject) => {
67 | webpack(cfg, (err, stats) => {
68 | if (err) return reject(err);
69 | resolve(stats);
70 | });
71 | });
72 | }
73 |
74 | function startPack () {
75 | console.log('start pack...');
76 | build(electronCfg)
77 | .then(() => build(cfg))
78 | .then(() => del('release'))
79 | .then((paths) => {
80 | if (shouldBuildAll) {
81 | // build for all platforms
82 | const archs = ['ia32', 'x64'];
83 | const platforms = ['linux', 'win32', 'darwin'];
84 |
85 | platforms.forEach((plat) => {
86 | archs.forEach((arch) => {
87 | pack(plat, arch, log(plat, arch));
88 | });
89 | });
90 | } else {
91 | // build for current platform only
92 | pack(os.platform(), os.arch(), log(os.platform(), os.arch()));
93 | }
94 | })
95 | .catch((err) => {
96 | console.error(err);
97 | });
98 | }
99 |
100 | function pack (plat, arch, cb) {
101 | // there is no darwin ia32 electron
102 | if (plat === 'darwin' && arch === 'ia32') return;
103 |
104 | const iconObj = {
105 | icon: DEFAULT_OPTS.icon + (() => {
106 | let extension = '.png';
107 | if (plat === 'darwin') {
108 | extension = '.icns';
109 | } else if (plat === 'win32') {
110 | extension = '.ico';
111 | }
112 | return extension;
113 | })()
114 | };
115 |
116 | const opts = Object.assign({}, DEFAULT_OPTS, iconObj, {
117 | platform: plat,
118 | arch,
119 | prune: true,
120 | 'app-version': pkg.version || DEFAULT_OPTS.version,
121 | out: `release/${plat}-${arch}`
122 | });
123 |
124 | packager(opts, cb);
125 | }
126 |
127 |
128 | function log (plat, arch) {
129 | return (err, filepath) => {
130 | if (err) return console.error(err);
131 | console.log(`${plat}-${arch} finished!`);
132 | };
133 | }
134 |
--------------------------------------------------------------------------------
/CONDUCT.md:
--------------------------------------------------------------------------------
1 | ## Code of Conduct
2 |
3 | ### What is this code of conduct for?
4 |
5 | Appium is a piece of technology, but **the core of the Appium community is the people in it**. As such, we are committed to providing a friendly, safe and welcoming environment for all, regardless of gender, gender identity and expression, sexual orientation, ability, physical appearance, body size, race, age, socioeconomic status, religion (or lack thereof), or other marginalized aspect of comunity members. We expect all members of the Appium community to abide by this Code of Conduct whenever interacting in Appium venues (pull requests, GitHub issues, 1-1 or group chat, meetups, conferences, etc...)
6 |
7 | ### Examples of inappropriate behavior
8 |
9 | Because we come from a variety of backgrounds, we don't want to assume that everyone has the same assumptions about what is and isn't appropriate. Here are some examples of inappropriate behavior that are incompatible with our community's ethos:
10 |
11 | * Spamming, trolling, intentionally disrupting conversations, or irrelevant solicitation or advertisement
12 | * Making demeaning or discriminatory comments
13 | * Making negative assumptions about someone's background, abilities, or intentions
14 | * Harassing or stalking individuals (online or in person)
15 | * Giving someone unwelcome sexual attention or making unwelcome physical contact (in the case of an IRL event)
16 | * Sharing sexual images or using sexually explicit language
17 |
18 | In general: treat others how you would like to be treated, were you in their place. Don't be a jerk. _Do_ ask questions. _Do_ keep conflicts productively focused on technical issues. _Do_ think before you speak; remember that what is perceived as a funny witticism in your group of friends might be hurtful or reinforce hurtful stereotypes in the context of our diverse online community. _Do_ remember that we are all people, not robots, and all equally deserving of sensitivity and respect. (If and when robots join our community, let's treat them with respect too!)
19 |
20 |
21 | ### What will organizers do about inappropriate behavior?
22 |
23 | If we notice you doing or saying something inappropriate, an organizer will explain why it's inappropriate and ask you to stop. We won't demonize or vilify you. But please do stop the inappropriate behavior so we can get back to writing and discussing code in a safe environment. If you have philosophical disagreements about what's actually inappropriate, please take them to a separate public or private conversation with an Appium maintainer so we don't turn pull requests into an ethics debate.
24 |
25 | If you keep doing unacceptable things, we'll likely ban you, report you to GitHub, or take other appropriate action.
26 |
27 | ### What if I see or am subject to what feels like inappropriate behavior?
28 |
29 | Let us know! Please notify a community organizer as soon as possible. Full contact information is listed in the [Contact Info](#contact-info) section of this document. All communications will be kept strictly confidential, unless otherwise required by law. No issue will be considered too inconsequential or unimportant for us to have a conversation about.
30 |
31 | ### Contact Info
32 |
33 | If you need to report an incident, please contact any of the following organizers directly:
34 |
35 | * Isaac Murchie [email](mailto:isaac@saucelabs.com) [twitter](https://twitter.com/imurchie)
36 | * Jonathan Lipps [email](mailto:jlipps@saucelabs.com) [twitter](https://twitter.com/jlipps)
37 |
38 | ### Credit, License, and Attribution
39 |
40 | This Code of Conduct is distributed under a [Creative Commons Attribution-ShareAlike license](http://creativecommons.org/licenses/by-sa/3.0/).
41 |
42 | It's inspired among other things by:
43 | * [Citizen Code of Conduct](http://citizencodeofconduct.org/)
44 | * [npmjs](https://www.npmjs.com/policies/conduct)
45 | * [Geek Feminism](http://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy)
46 | * [Ashe Dryden](http://www.ashedryden.com/blog/codes-of-conduct-101-faq)
47 | * [Model View Culture](https://modelviewculture.com/issues/events)
48 | * [Open Source & Feelings](http://osfeels.com/conduct).
--------------------------------------------------------------------------------
/app/main/auto-updater/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Auto Updater
3 | *
4 | * Similar to https://electronjs.org/docs/api/auto-updater#events
5 | * See https://electronjs.org/docs/tutorial/updates for documentation
6 | */
7 | import { app, autoUpdater, dialog } from 'electron';
8 | import moment from 'moment';
9 | import B from 'bluebird';
10 | import { checkUpdate } from './update-checker';
11 | import { getFeedUrl } from './config';
12 | import _ from 'lodash';
13 | import env from '../../env';
14 |
15 | const isDev = process.env.NODE_ENV === 'development';
16 |
17 | let checkNewUpdates = _.noop;
18 |
19 | if (!isDev) {
20 |
21 | autoUpdater.setFeedURL(getFeedUrl(app.getVersion()));
22 |
23 | /**
24 | * Check for new updates
25 | */
26 | checkNewUpdates = async function (fromMenu) {
27 | // autoupdate.checkForUpdates always downloads updates immediately
28 | // This method (getUpdate) let's us take a peek to see if there is an update
29 | // available before calling .checkForUpdates
30 | const update = await checkUpdate(app.getVersion());
31 | if (update) {
32 | let {name, notes, pub_date:pubDate} = update;
33 | pubDate = moment(pubDate).format('MMM Do YYYY, h:mma');
34 |
35 | let detail = `Release Date: ${pubDate}\n\nRelease Notes: ${notes.replace("*", "\n*")}`;
36 | if (env.NO_AUTO_UPDATE) {
37 | detail += `\n\nhttps://www.github.com/appium/appium-desktop/releases/latest`;
38 | }
39 |
40 |
41 | // Ask user if they wish to install now or later
42 | if (!process.env.RUNNING_IN_SPECTRON) {
43 | dialog.showMessageBox({
44 | type: 'info',
45 | buttons: env.NO_AUTO_UPDATE ? ['Ok'] : ['Install Now', 'Install Later'],
46 | message: `Appium Desktop ${name} is available`,
47 | detail,
48 | }, (response) => {
49 | if (response === 0) {
50 | // If they say yes, get the updates now
51 | if (!env.NO_AUTO_UPDATE) {
52 | autoUpdater.checkForUpdates();
53 | }
54 | }
55 | });
56 | }
57 | } else {
58 | if (fromMenu) {
59 | autoUpdater.emit('update-not-available');
60 | } else {
61 | // If no updates found check for updates every hour
62 | await B.delay(60 * 60 * 1000);
63 | checkNewUpdates();
64 | }
65 | }
66 | };
67 |
68 | // Inform user when the download is starting and that they'll be notified again when it is complete
69 | autoUpdater.on('update-available', () => {
70 | dialog.showMessageBox({
71 | type: 'info',
72 | buttons: ['Ok'],
73 | message: 'Update Download Started',
74 | detail: 'Update is being downloaded now. You will be notified again when it is complete',
75 | });
76 | });
77 |
78 | // Handle the unusual case where we checked the updates endpoint, found an update
79 | // but then after calling 'checkForUpdates', nothing was there
80 | autoUpdater.on('update-not-available', () => {
81 | dialog.showMessageBox({
82 | type: 'info',
83 | buttons: ['Ok'],
84 | message: 'No update available',
85 | detail: 'Appium Desktop is up-to-date',
86 | });
87 | });
88 |
89 | // When it's done, ask if user want to restart now or later
90 | autoUpdater.on('update-downloaded', (event, releaseNotes, releaseName) => {
91 | dialog.showMessageBox({
92 | type: 'info',
93 | buttons: ['Restart Now', 'Later'],
94 | message: 'Update Downloaded',
95 | detail: `Appium Desktop ${releaseName} has been downloaded. ` +
96 | `Must restart to apply the updates ` +
97 | `(note: it may take several minutes for Appium Desktop to install and restart)`,
98 | }, (response) => {
99 | // If they say yes, restart now
100 | if (response === 0) {
101 | autoUpdater.quitAndInstall();
102 | }
103 | });
104 | });
105 |
106 | // Handle error case
107 | autoUpdater.on('error', (message) => {
108 | dialog.showMessageBox({
109 | type: 'error',
110 | message: 'Could not download update',
111 | detail: `Failed to download update. Reason: ${message}`,
112 | });
113 | });
114 |
115 | }
116 |
117 | export { checkNewUpdates };
118 |
--------------------------------------------------------------------------------
/app/renderer/images/headspin_logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
65 |
--------------------------------------------------------------------------------
/app/renderer/components/StartServer/PresetsTab.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash';
2 | import moment from 'moment';
3 | import React, { Component } from 'react';
4 | import { notification, Table } from 'antd';
5 |
6 | import { propTypes } from './shared';
7 | import StartButton from './StartButton';
8 | import DeletePresetButton from './DeletePresetButton';
9 | import advancedStyles from './AdvancedTab.css';
10 | import styles from './PresetsTab.css';
11 |
12 | export default class PresetsTab extends Component {
13 | static propTypes = {...propTypes};
14 |
15 | constructor (props) {
16 | super(props);
17 | this.state = {selectedPreset: null};
18 | }
19 |
20 | componentWillMount () {
21 | this.props.getPresets();
22 | }
23 |
24 | hasPresets () {
25 | return !!_.size(this.props.presets);
26 | }
27 |
28 | presetIsSelected () {
29 | return this.hasPresets() && this.state.selectedPreset;
30 | }
31 |
32 | emptyPresetList () {
33 | return (
34 |
35 |
36 | You don't yet have any presets. Create some on the Advanced tab.
37 |
38 |
39 | );
40 | }
41 |
42 | selectedPresetData (presetName = null) {
43 | if (!presetName) presetName = this.state.selectedPreset;
44 | const {presets} = this.props;
45 | const preset = presets[presetName];
46 | if (preset._modified) {
47 | delete preset._modified;
48 | }
49 | return preset;
50 | }
51 |
52 | selectPreset (presetName) {
53 | const {serverArgs, updateArgs, serverVersion} = this.props;
54 |
55 | this.setState({selectedPreset: presetName});
56 |
57 | const preset = this.selectedPresetData(presetName);
58 |
59 | for (let [argName, newVal] of _.toPairs(preset)) {
60 | if (serverArgs[argName] !== newVal) {
61 | updateArgs({[argName]: newVal});
62 | }
63 | }
64 | }
65 |
66 | deletePreset (evt) {
67 | evt.preventDefault();
68 | if (window.confirm(`Are you sure you want to delete ${this.state.selectedPreset}?`)) {
69 | this.props.deletePreset(this.state.selectedPreset);
70 | this.setState({selectedPreset: null});
71 | notification.success({
72 | message: 'Deleted',
73 | description: 'Preset successfully trashed'
74 | });
75 | }
76 | }
77 |
78 | presetList () {
79 | const {presets} = this.props;
80 | return (
81 |
95 | );
96 | }
97 |
98 | presetDetail () {
99 | const preset = this.selectedPresetData();
100 | if (preset) {
101 | const columns = [{
102 | title: 'Server Argument',
103 | dataIndex: 'arg',
104 | width: 200
105 | }, {
106 | title: 'Value',
107 | dataIndex: 'val',
108 | }];
109 | let data = [];
110 | for (let [arg, val] of _.toPairs(preset)) {
111 | data.push({
112 | key: arg,
113 | arg,
114 | val: typeof val === 'string' ? val : JSON.stringify(val)
115 | });
116 | }
117 | return (
118 |
123 | );
124 | }
125 | return "";
126 | }
127 |
128 | render () {
129 | const {startServer, serverStarting, presetDeleting, serverVersion} = this.props;
130 |
131 | return (
132 |
146 | );
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/app/renderer/components/Inspector/SelectedElement.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import ReactDOM from 'react-dom';
3 | import _ from 'lodash';
4 | import { getLocators } from './shared';
5 | import styles from './Inspector.css';
6 | import { Button, Row, Col, Input, Modal, Table, Alert, Spin } from 'antd';
7 |
8 | const ButtonGroup = Button.Group;
9 |
10 | /**
11 | * Shows details of the currently selected element and shows methods that can
12 | * be called on the elements (tap, sendKeys)
13 | */
14 | export default class SelectedElement extends Component {
15 |
16 | constructor (props) {
17 | super(props);
18 | this.handleSendKeys = this.handleSendKeys.bind(this);
19 | }
20 |
21 | handleSendKeys () {
22 | const {sendKeys, applyClientMethod, hideSendKeysModal, selectedElementId:elementId} = this.props;
23 | applyClientMethod({methodName: 'sendKeys', elementId, args: [sendKeys]});
24 | hideSendKeysModal();
25 | }
26 |
27 | render () {
28 | const {applyClientMethod, setFieldValue, sendKeys, selectedElement, sendKeysModalVisible, showSendKeysModal,
29 | hideSendKeysModal, selectedElementId:elementId, sourceXML, elementInteractionsNotAvailable} = this.props;
30 | const {attributes, xpath} = selectedElement;
31 |
32 | // Get the columns for the attributes table
33 | let attributeColumns = [{
34 | title: 'Attribute',
35 | dataIndex: 'name',
36 | key: 'name',
37 | width: 100
38 | }, {
39 | title: 'Value',
40 | dataIndex: 'value',
41 | key: 'value'
42 | }];
43 |
44 | // Get the data for the attributes table
45 | let attrArray = _.toPairs(attributes).filter(([key]) => key !== 'path');
46 | let dataSource = attrArray.map(([key, value]) => ({
47 | key,
48 | value,
49 | name: key,
50 | }));
51 |
52 | // Get the columns for the strategies table
53 | let findColumns = [{
54 | title: 'Find By',
55 | dataIndex: 'find',
56 | key: 'find',
57 | width: 100
58 | }, {
59 | title: 'Selector',
60 | dataIndex: 'selector',
61 | key: 'selector'
62 | }];
63 |
64 | // Get the data for the strategies table
65 | let findDataSource = _.toPairs(getLocators(attributes, sourceXML)).map(([key, selector]) => ({
66 | key,
67 | selector,
68 | find: key,
69 | }));
70 |
71 | // If XPath is the only provided data source, warn the user about it's brittleness
72 | let showXpathWarning = false;
73 | if (findDataSource.length === 0) {
74 | showXpathWarning = true;
75 | }
76 |
77 | // Add XPath to the data source as well
78 | if (xpath) {
79 | findDataSource.push({
80 | key: 'xpath',
81 | find: 'xpath',
82 | selector: xpath,
83 | });
84 | }
85 |
86 | return
87 | {elementInteractionsNotAvailable &&
88 |
89 |
90 |
91 |
}
92 |
93 |
94 |
95 | applyClientMethod({methodName: 'click', elementId})}>Tap
96 | showSendKeysModal()}>Send Keys
97 | applyClientMethod({methodName: 'clear', elementId})}>Clear
98 |
99 |
100 |
101 | {findDataSource.length > 0 &&
}
102 |
103 | {showXpathWarning &&
104 |
112 | }
113 | {dataSource.length > 0 &&
114 |
115 |
116 |
117 | }
118 |
124 | setFieldValue('sendKeys', e.target.value)} />
125 |
126 |
;
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/app/renderer/components/Inspector/HighlighterRects.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { debounce } from 'lodash';
4 | import HighlighterRect from './HighlighterRect';
5 | import B from 'bluebird';
6 | import { parseCoordinates } from './shared';
7 |
8 | /**
9 | * Shows screenshot of running application and divs that highlight the elements' bounding boxes
10 | */
11 | export default class HighlighterRects extends Component {
12 |
13 | constructor (props) {
14 | super(props);
15 | this.state = {
16 | scaleRatio: 1,
17 | };
18 | this.updateScaleRatio = debounce(this.updateScaleRatio.bind(this), 1000);
19 | }
20 |
21 | /**
22 | * Calculates the ratio that the image is being scaled by
23 | */
24 | updateScaleRatio () {
25 | const screenshotEl = this.props.containerEl.querySelector('img');
26 |
27 | // now update scale ratio
28 | this.setState({
29 | scaleRatio: (this.props.windowSize.width / screenshotEl.offsetWidth)
30 | });
31 |
32 | }
33 |
34 | async handleScreenshotClick () {
35 | const {screenshotInteractionMode, applyClientMethod,
36 | swipeStart, swipeEnd, setSwipeStart, setSwipeEnd} = this.props;
37 | const {x, y} = this.state;
38 |
39 | if (screenshotInteractionMode === 'tap') {
40 | applyClientMethod({
41 | methodName: 'tap',
42 | args: [x, y],
43 | });
44 | } else if (screenshotInteractionMode === 'swipe') {
45 | if (!swipeStart) {
46 | setSwipeStart(x, y);
47 | } else if (!swipeEnd) {
48 | setSwipeEnd(x, y);
49 | await B.delay(500); // Wait a second to do the swipe so user can see the SVG line
50 | await this.handleDoSwipe();
51 | }
52 | }
53 | }
54 |
55 | handleMouseMove (e) {
56 | const {screenshotInteractionMode} = this.props;
57 | const {scaleRatio} = this.state;
58 |
59 | if (screenshotInteractionMode !== 'select') {
60 | const offsetX = e.nativeEvent.offsetX;
61 | const offsetY = e.nativeEvent.offsetY;
62 | const x = offsetX * scaleRatio;
63 | const y = offsetY * scaleRatio;
64 | this.setState({
65 | ...this.state,
66 | x: Math.round(x),
67 | y: Math.round(y),
68 | });
69 | }
70 | }
71 |
72 | handleMouseOut () {
73 | this.setState({
74 | ...this.state,
75 | x: null,
76 | y: null,
77 | });
78 | }
79 |
80 | async handleDoSwipe () {
81 | const {swipeStart, swipeEnd, clearSwipeAction, applyClientMethod} = this.props;
82 | await applyClientMethod({
83 | methodName: 'swipe',
84 | args: [swipeStart.x, swipeStart.y, swipeEnd.x, swipeEnd.y],
85 | });
86 | clearSwipeAction();
87 | }
88 |
89 | componentDidMount () {
90 | // When DOM is ready, calculate the image scale ratio and re-calculate it whenever the window is resized
91 | this.updateScaleRatio();
92 | window.addEventListener('resize', this.updateScaleRatio);
93 | }
94 |
95 | componentWillUnmount () {
96 | window.removeEventListener('resize', this.updateScaleRatio);
97 | }
98 |
99 | render () {
100 | const {source, screenshotInteractionMode, containerEl, searchedForElementBounds, isLocatorTestModalVisible} = this.props;
101 | const {scaleRatio} = this.state;
102 |
103 | // Recurse through the 'source' JSON and render a highlighter rect for each element
104 | const highlighterRects = [];
105 |
106 | let highlighterXOffset = 0;
107 | if (containerEl) {
108 | const screenshotEl = containerEl.querySelector('img');
109 | highlighterXOffset = screenshotEl.getBoundingClientRect().left -
110 | containerEl.getBoundingClientRect().left;
111 | }
112 |
113 | let recursive = (element, zIndex = 0) => {
114 | if (!element) {
115 | return;
116 | }
117 | highlighterRects.push();
124 |
125 | for (let childEl of element.children) {
126 | recursive(childEl, zIndex + 1);
127 | }
128 | };
129 |
130 | // If the use selected an element that they searched for, highlight that element
131 | if (searchedForElementBounds && isLocatorTestModalVisible) {
132 | const {location:elLocation, size} = searchedForElementBounds;
133 | highlighterRects.push();
134 | }
135 |
136 | // If we're tapping or swiping, show the 'crosshair' cursor style
137 | const screenshotStyle = {};
138 | if (screenshotInteractionMode === 'tap' || screenshotInteractionMode === 'swipe') {
139 | screenshotStyle.cursor = 'crosshair';
140 | }
141 |
142 | // Don't show highlighter rects when Search Elements modal is open
143 | if (!isLocatorTestModalVisible) {
144 | recursive(source);
145 | }
146 |
147 | return { highlighterRects }
;
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/test/integration/appium-method-handler.integration-test.js:
--------------------------------------------------------------------------------
1 | import chai from 'chai';
2 | import chaiAsPromised from 'chai-as-promised';
3 | import { startServer as startAppiumFakeDriverServer } from 'appium-fake-driver';
4 | import path from 'path';
5 | import wd from 'wd';
6 | import AppiumMethodHandler from '../../app/main/appium-method-handler';
7 |
8 | const should = chai.should();
9 | chai.use(chaiAsPromised);
10 |
11 | const FAKE_DRIVER_PORT = 12121;
12 |
13 | const TEST_APP = path.resolve('node_modules', 'appium-fake-driver', 'test', 'fixtures', 'app.xml');
14 |
15 | const DEFAULT_CAPS = {
16 | platformName: 'Fake',
17 | deviceName: 'Fake',
18 | app: TEST_APP,
19 | };
20 |
21 | describe('appiumDriverExtender', function () {
22 | let driver;
23 |
24 | before(async function () {
25 | await startAppiumFakeDriverServer(FAKE_DRIVER_PORT, '127.0.0.1');
26 | let p = await wd.promiseChainRemote({
27 | hostname: '127.0.0.1',
28 | port: FAKE_DRIVER_PORT,
29 | });
30 | await p.init(DEFAULT_CAPS);
31 | driver = new AppiumMethodHandler(p);
32 | });
33 |
34 | describe('.fetchElement, .fetchElements', function () {
35 | it('should return empty object if selector is null', async function () {
36 | const res = await driver.fetchElement('xpath', '//BadXPath');
37 | res.should.deep.equal({});
38 | });
39 | it('should fetchElement and cache it', async function () {
40 | const {id, variableName, variableType, strategy, selector} = await driver.fetchElement('xpath', '//MockListItem');
41 | id.should.exist;
42 | strategy.should.equal('xpath');
43 | selector.should.equal('//MockListItem');
44 | should.not.exist(variableName); // Shouldn't have a variable name until a method is performed on it
45 | variableType.should.equal('string');
46 | driver.elementCache[id].should.exist;
47 | should.not.exist(driver.elementCache[id].variableName);
48 | driver.elementCache[id].variableType.should.equal('string');
49 | });
50 | it('should fetchElements and cache all of them', async function () {
51 | const res = await driver.fetchElements('xpath', '//MockListItem');
52 | res.elements.length.should.be.above(0);
53 | res.variableName.should.equal('els1');
54 | res.variableType.should.equal('array');
55 | res.elements[0].variableName.should.equal('els1');
56 | res.elements[0].variableType.should.equal('string');
57 | res.elements[0].id.should.exist;
58 | res.elements[1].variableName.should.equal('els1');
59 | res.elements[1].variableType.should.equal('string');
60 | res.elements[1].id.should.exist;
61 | res.strategy.should.equal('xpath');
62 | res.selector.should.equal('//MockListItem');
63 | driver.elementCache[res.elements[0].id].variableName.should.equal('els1');
64 | driver.elementCache[res.elements[0].id].variableType.should.equal('string');
65 | driver.elementCache[res.elements[1].id].variableName.should.equal('els1');
66 | driver.elementCache[res.elements[1].id].variableType.should.equal('string');
67 | });
68 | });
69 | describe('.executeElementCommand', function () {
70 | it('should call the click method and have the variableName, variableType, etc... returned to it with source/screenshot', async function () {
71 | const {id, variableName, variableType} = await driver.fetchElement('xpath', '//MockListItem');
72 | should.not.exist(variableName); // Shouldn't have a cached variable name until a method is performed on it
73 | const {source, screenshot, variableName:repeatedVariableName,
74 | variableType:repeatedVariableType, id:repeatedId} = await driver.executeElementCommand(id, 'click');
75 | repeatedVariableName.should.exist;
76 | variableType.should.equal(repeatedVariableType);
77 | id.should.equal(repeatedId);
78 | source.should.exist;
79 | screenshot.should.exist;
80 | });
81 | it('should call the click method and have the variableName, variableType, etc... returned to it with source/screenshot', async function () {
82 | const {elements} = await driver.fetchElements('xpath', '//MockListItem');
83 | for (let element of elements) {
84 | const {id, variableName, variableType} = element;
85 | const {source, screenshot, variableName:repeatedVariableName,
86 | variableType:repeatedVariableType, id:repeatedId} = await driver.executeElementCommand(id, 'click');
87 | variableName.should.equal(repeatedVariableName);
88 | variableType.should.equal(repeatedVariableType);
89 | id.should.equal(repeatedId);
90 | source.should.exist;
91 | screenshot.should.exist;
92 | }
93 | });
94 | });
95 | describe('.executeMethod', function () {
96 | it('should call "setGeolocation" method and get result plus source and screenshot', async function () {
97 | const res = await driver.executeMethod('setGeoLocation', [100, 200]);
98 | res.screenshot.should.exist;
99 | res.source.should.exist;
100 | const getGeoLocationRes = await driver.executeMethod('getGeoLocation');
101 | getGeoLocationRes.res.latitude.should.equal(100);
102 | getGeoLocationRes.res.longitude.should.equal(200);
103 |
104 | });
105 | });
106 | });
--------------------------------------------------------------------------------
/app/renderer/components/Inspector/Screenshot.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { debounce } from 'lodash';
4 | import HighlighterRects from './HighlighterRects';
5 | import { Spin, Tooltip } from 'antd';
6 | import B from 'bluebird';
7 | import styles from './Inspector.css';
8 |
9 | /**
10 | * Shows screenshot of running application and divs that highlight the elements' bounding boxes
11 | */
12 | export default class Screenshot extends Component {
13 |
14 | constructor (props) {
15 | super(props);
16 | this.containerEl = null;
17 | this.state = {
18 | scaleRatio: 1,
19 | x: null,
20 | y: null,
21 | };
22 | this.updateScaleRatio = debounce(this.updateScaleRatio.bind(this), 1000);
23 | }
24 |
25 | /**
26 | * Calculates the ratio that the image is being scaled by
27 | */
28 | updateScaleRatio () {
29 | const screenshotEl = this.containerEl.querySelector('img');
30 |
31 | // now update scale ratio
32 | this.setState({
33 | scaleRatio: (this.props.windowSize.width / screenshotEl.offsetWidth)
34 | });
35 | }
36 |
37 | async handleScreenshotClick () {
38 | const {screenshotInteractionMode, applyClientMethod,
39 | swipeStart, swipeEnd, setSwipeStart, setSwipeEnd} = this.props;
40 | const {x, y} = this.state;
41 |
42 | if (screenshotInteractionMode === 'tap') {
43 | applyClientMethod({
44 | methodName: 'tap',
45 | args: [x, y],
46 | });
47 | } else if (screenshotInteractionMode === 'swipe') {
48 | if (!swipeStart) {
49 | setSwipeStart(x, y);
50 | } else if (!swipeEnd) {
51 | setSwipeEnd(x, y);
52 | await B.delay(500); // Wait a second to do the swipe so user can see the SVG line
53 | await this.handleDoSwipe();
54 | }
55 | }
56 | }
57 |
58 | handleMouseMove (e) {
59 | const {screenshotInteractionMode} = this.props;
60 | const {scaleRatio} = this.state;
61 |
62 | if (screenshotInteractionMode !== 'select') {
63 | const offsetX = e.nativeEvent.offsetX;
64 | const offsetY = e.nativeEvent.offsetY;
65 | const x = offsetX * scaleRatio;
66 | const y = offsetY * scaleRatio;
67 | this.setState({
68 | ...this.state,
69 | x: Math.round(x),
70 | y: Math.round(y),
71 | });
72 | }
73 | }
74 |
75 | handleMouseOut () {
76 | this.setState({
77 | ...this.state,
78 | x: null,
79 | y: null,
80 | });
81 | }
82 |
83 | async handleDoSwipe () {
84 | const {swipeStart, swipeEnd, clearSwipeAction, applyClientMethod} = this.props;
85 | await applyClientMethod({
86 | methodName: 'swipe',
87 | args: [swipeStart.x, swipeStart.y, swipeEnd.x, swipeEnd.y],
88 | });
89 | clearSwipeAction();
90 | }
91 |
92 | componentDidMount () {
93 | // When DOM is ready, calculate the image scale ratio and re-calculate it whenever the window is resized
94 | this.updateScaleRatio();
95 | window.addEventListener('resize', this.updateScaleRatio);
96 | }
97 |
98 | componentWillUnmount () {
99 | window.removeEventListener('resize', this.updateScaleRatio);
100 | }
101 |
102 | render () {
103 | const {screenshot, methodCallInProgress, screenshotInteractionMode,
104 | swipeStart, swipeEnd} = this.props;
105 | const {scaleRatio, x, y} = this.state;
106 |
107 | // If we're tapping or swiping, show the 'crosshair' cursor style
108 | const screenshotStyle = {};
109 | if (screenshotInteractionMode === 'tap' || screenshotInteractionMode === 'swipe') {
110 | screenshotStyle.cursor = 'crosshair';
111 | }
112 |
113 | let swipeInstructions = null;
114 | if (screenshotInteractionMode === 'swipe' && (!swipeStart || !swipeEnd)) {
115 | if (!swipeStart) {
116 | swipeInstructions = "Click swipe start point";
117 | } else if (!swipeEnd) {
118 | swipeInstructions = "Click swipe end point";
119 | }
120 | }
121 |
122 | const screenImg =
;
123 |
124 | // Show the screenshot and highlighter rects. Show loading indicator if a method call is in progress.
125 | return
126 |
127 |
{ this.containerEl = containerEl; }}
128 | style={screenshotStyle}
129 | onClick={this.handleScreenshotClick.bind(this)}
130 | onMouseMove={this.handleMouseMove.bind(this)}
131 | onMouseOut={this.handleMouseOut.bind(this)}
132 | className={styles.screenshotBox}>
133 | {x !== null &&
134 |
X: {x}
135 |
Y: {y}
136 |
}
137 | {swipeInstructions &&
{screenImg}}
138 | {!swipeInstructions && screenImg}
139 | {screenshotInteractionMode === 'select' && this.containerEl &&
}
140 | {screenshotInteractionMode === 'swipe' &&
141 |
153 | }
154 |
155 |
156 | ;
157 | }
158 | }
159 |
--------------------------------------------------------------------------------
/app/renderer/components/Session/Session.js:
--------------------------------------------------------------------------------
1 | import { shell } from 'electron';
2 | import React, { Component } from 'react';
3 | import _ from 'lodash';
4 | import NewSessionForm from './NewSessionForm';
5 | import SavedSessions from './SavedSessions';
6 | import AttachToSession from './AttachToSession';
7 | import ServerTabAutomatic from './ServerTabAutomatic';
8 | import ServerTabSauce from './ServerTabSauce';
9 | import ServerTabCustom from './ServerTabCustom';
10 | import ServerTabTestobject from './ServerTabTestobject';
11 | import ServerTabHeadspin from './ServerTabHeadspin';
12 | import ServerTabBrowserstack from './ServerTabBrowserstack';
13 | import ServerTabBitbar from './ServerTabBitbar';
14 | import { Tabs, Button, Spin, Icon } from 'antd';
15 | import { ServerTypes } from '../../actions/Session';
16 | import AdvancedServerParams from './AdvancedServerParams';
17 | import SessionStyles from './Session.css';
18 | import CloudProviders from '../../../shared/cloud-providers';
19 |
20 | const {TabPane} = Tabs;
21 |
22 | export default class Session extends Component {
23 |
24 | state = {visibleProviders: {}};
25 |
26 | componentWillMount () {
27 | const {setLocalServerParams, getSavedSessions, setSavedServerParams, getRunningSessions} = this.props;
28 | (async () => {
29 | this.checkProvidersVisibility();
30 | await getSavedSessions();
31 | await setSavedServerParams();
32 | await setLocalServerParams();
33 | getRunningSessions();
34 | })();
35 | }
36 |
37 | async checkProvidersVisibility () {
38 | const visibleProviders = {};
39 | for (let [providerName, provider] of _.toPairs(CloudProviders)) {
40 | if (await provider.isVisible()) {
41 | visibleProviders[providerName] = true;
42 | }
43 | }
44 | this.setState({
45 | ...this.state,
46 | visibleProviders
47 | });
48 | }
49 |
50 | render () {
51 | const {newSessionBegan, savedSessions, tabKey, switchTabs,
52 | changeServerType, serverType, server,
53 | requestSaveAsModal, newSession, caps, capsUUID, saveSession, isCapsDirty,
54 | sessionLoading, attachSessId} = this.props;
55 | const { visibleProviders } = this.state || {};
56 |
57 | const isAttaching = tabKey === 'attach';
58 |
59 | const sauceTabHead =
;
60 | const testObjectTabHead =
;
61 | const headspinTabHead =
;
62 | const browserstackTabHead =
;
63 | const bitbarTabHead =
;
64 |
65 | return
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 | { visibleProviders.saucelabs &&
76 |
77 | }
78 | { visibleProviders.testobject &&
79 |
80 | }
81 | { visibleProviders.headspin &&
82 |
83 | }
84 | { visibleProviders.browserstack &&
85 |
86 | }
87 | {visibleProviders.bitbar &&
88 |
89 | }
90 |
91 |
92 |
93 |
94 |
95 | {newSessionBegan &&
96 |
Session In Progress
97 |
}
98 |
99 | {!newSessionBegan &&
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 | }
110 |
111 |
117 | { (!isAttaching && capsUUID) &&
saveSession(caps, {uuid: capsUUID})} disabled={!isCapsDirty}>Save }
118 | {!isAttaching &&
Save As...}
119 | {!isAttaching &&
newSession(caps)} className={SessionStyles['start-session-button']}>Start Session
121 | }
122 | {isAttaching &&
123 |
newSession(null, attachSessId)}>
124 | Attach to Session
125 |
126 | }
127 |
128 |
129 | ;
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/app/renderer/components/Session/NewSessionForm.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Button, Switch, Input, Modal, Form, Icon, Row, Col, Select, notification } from 'antd';
3 | import { remote } from 'electron';
4 | import FormattedCaps from './FormattedCaps';
5 | import SessionStyles from './Session.css';
6 | const {dialog} = remote;
7 | const {Item:FormItem} = Form;
8 | const {Option} = Select;
9 |
10 | export default class NewSessionForm extends Component {
11 |
12 | getLocalFilePath (success) {
13 | dialog.showOpenDialog((filepath) => {
14 | if (filepath) {
15 | success(filepath);
16 | }
17 | });
18 | this.handleSetType = this.handleSetType.bind(this);
19 | }
20 |
21 | getCapsControl (cap, index) {
22 | const {setCapabilityParam, isEditingDesiredCaps} = this.props;
23 |
24 | const buttonAfter = this.getLocalFilePath((filepath) => setCapabilityParam(index, 'value', filepath[0]))} />;
27 |
28 | switch (cap.type) {
29 | case 'text': return setCapabilityParam(index, 'value', e.target.value)} size="large"/>;
30 | case 'boolean': return setCapabilityParam(index, 'value', value)} />;
32 | case 'number': return !isNaN(parseInt(e.target.value, 10)) ? setCapabilityParam(index, 'value', parseInt(e.target.value, 10)) : setCapabilityParam(index, 'value', undefined)} size="large"/>;
34 | case 'object':
35 | case 'json_object':
36 | return setCapabilityParam(index, 'value', e.target.value)} size="large"/>;
38 | case 'file': return
39 |
40 |
;
41 |
42 | default:
43 | throw `Invalid cap type: ${cap.type}`;
44 | }
45 | }
46 |
47 | /**
48 | * Callback when the type of a dcap is changed
49 | */
50 | handleSetType (index, type) {
51 | let {setCapabilityParam, caps} = this.props;
52 | setCapabilityParam(index, 'type', type);
53 |
54 | // Translate the current value to the new type
55 | let translatedValue = caps[index].value;
56 | switch (type) {
57 | case 'text':
58 | translatedValue = translatedValue + '';
59 | break;
60 | case 'boolean':
61 | if (translatedValue === 'true') {
62 | translatedValue = true;
63 | } else if (translatedValue === 'false') {
64 | translatedValue = false;
65 | } else {
66 | translatedValue = !!translatedValue;
67 | }
68 | break;
69 | case 'number':
70 | translatedValue = parseInt(translatedValue, 10) || 0;
71 | break;
72 | case 'json_object':
73 | case 'object':
74 | translatedValue = translatedValue + '';
75 | break;
76 | case 'file':
77 | translatedValue = '';
78 | break;
79 | default:
80 | break;
81 | }
82 | setCapabilityParam(index, 'value', translatedValue);
83 | }
84 |
85 | render () {
86 | const {setCapabilityParam, caps, addCapability, removeCapability, saveSession, hideSaveAsModal, saveAsText, showSaveAsModal, setSaveAsText, isEditingDesiredCaps} = this.props;
87 |
88 | return ;
135 | }
136 | }
137 |
--------------------------------------------------------------------------------