bring back @jonahss's settings api changes

This commit is contained in:
Jonathan Lipps
2014-09-08 16:10:56 -07:00
parent 3395f2b47e
commit 5daea720ba
20 changed files with 339 additions and 10 deletions
+42
View File
@@ -0,0 +1,42 @@
## Settings
Settings are a new concept introduced by appium. They are currently not a part of the Mobile JSON Wire Protocol, or the Webdriver spec.
Settings are a way to specify the behavior of the appium server.
Settings are:
- Mutable, they can be changed during a session
- Only relevant during the session they are applied. They are reset for each new session.
- Control the way the appium server behaves during test automation. They do not apply to controlling the app or device under test.
An example of a setting would be `ignoreUnimportantViews` for Android. Android can be set to ignore elements in the View Hierarchy which it deems irrelevant. Setting this can cause tests to run faster. A user who *wants* to access the ignored elements however, would want to disable `ignoreUnimportantViews`, and reenable it afterwards.
Another example of a use-case for settings would be telling appium to ignore elements which are not visible.
Settings are implemented via the following API endpoints:
**POST** /session/:sessionId/appium/settings
>Expects a JSON hash of settings, where keys correspond to setting names, and values to the value of the setting.
```
{
settings: {
ignoreUnimportantViews : true
}
}
```
**GET** /session/:sessionId/appium/settings
>Returns a JSON hash of all the currently specified settings.
```
{
settings: {
ignoreUnimportantViews : true
}
}
```
### Supported Settings
**"ignoreUnimportantViews"** - Boolean which sets whether Android devices should use `setCompressedLayoutHeirarchy()` which ignores all views which are marked IMPORTANT_FOR_ACCESSIBILITY_NO or IMPORTANT_FOR_ACCESSIBILITY_AUTO (and have been deemed not important by the system), in an attempt to make things less confusing or faster.
+1
View File
@@ -53,6 +53,7 @@
|`unicodeKeyboard`| Enable Unicode input, default `false`| `true` or `false`|
|`resetKeyboard`| Reset keyboard to its original state, after running Unicode tests with `unicodeKeyboard` capability. Ignored if used alone. Default `false`| `true` or `false`|
|`noSign`| Skip checking and signing of app with debug keys, will work only with UiAutomator and not with selendroid, default `false`| `true` or `false`|
|`ignoreUnimportantViews`| Calls the `setCompressedLayoutHierarchy()` uiautomator function. This capability can speed up test execution, since Accessibility commands will run faster ignoring some elements. The ignored elements will not be findable, which is why this capability has also been implemented as a toggle-able *setting* as well as a capability. Defaults to `false` | `true` or `false`
### iOS Only
+33
View File
@@ -71,6 +71,15 @@ Android.prototype.init = function () {
, ['POST', new RegExp('^/wd/hub/session/[^/]+/appium')]
, ['GET', new RegExp('^/wd/hub/session/[^/]+/appium')]
];
// listen for changes to ignoreUnimportantViews
this.settings.on("update", function (update) {
if (update.key === "ignoreUnimportantViews") {
this.setCompressedLayoutHierarchy(update.value, update.callback);
} else {
update.callback();
}
}.bind(this));
};
Android.prototype._deviceConfigure = Device.prototype.configure;
@@ -113,6 +122,7 @@ Android.prototype.start = function (cb, onDie) {
this.wakeUp.bind(this),
this.unlockScreen.bind(this),
this.getDataDir.bind(this),
this.setupCompressedLayoutHierarchy.bind(this),
this.startApp.bind(this),
this.initAutoWebview.bind(this)
], function (err) {
@@ -159,6 +169,7 @@ Android.prototype.restartUiautomator = function (cb) {
async.series([
this.forwardPort.bind(this)
, this.uiautomator.start.bind(this.uiautomator)
, this.setupCompressedLayoutHierarchy.bind(this)
], cb);
};
@@ -502,6 +513,26 @@ Android.prototype.getDataDir = function (cb) {
}.bind(this));
};
// Set CompressedLayoutHierarchy on the device based on current settings object
Android.prototype.setupCompressedLayoutHierarchy = function (cb) {
// setup using cap
if (_.has(this.args, 'ignoreUnimportantViews')) {
// set the setting directly on the internal _settings object, this way we don't trigger an update event
this.settings._settings.ignoreUnimportantViews = this.args.ignoreUnimportantViews;
}
if (_.isUndefined(this.getSetting("ignoreUnimportantViews"))) {
return cb();
}
this.setCompressedLayoutHierarchy(this.getSetting("ignoreUnimportantViews"), cb);
};
// Set CompressedLayoutHierarchy on the device
Android.prototype.setCompressedLayoutHierarchy = function (compress, cb) {
this.proxy(["compressedLayoutHierarchy", {compressLayout: compress}], cb);
};
Android.prototype.waitForActivityToStop = function (cb) {
this.adb.waitForNotActivity(this.args.appWaitPackage, this.args.appWaitActivity, cb);
};
@@ -510,6 +541,8 @@ Android.prototype.waitForActivityToStop = function (cb) {
Android.prototype.resetTimeout = deviceCommon.resetTimeout;
Android.prototype.waitForCondition = deviceCommon.waitForCondition;
Android.prototype.implicitWaitForCondition = deviceCommon.implicitWaitForCondition;
Android.prototype.getSettings = deviceCommon.getSettings;
Android.prototype.updateSettings = deviceCommon.updateSettings;
_.extend(Android.prototype, androidController);
_.extend(Android.prototype, androidContextController);
@@ -46,6 +46,7 @@ class AndroidCommandExecutor {
map.put("performMultiPointerGesture", new MultiPointerGesture());
map.put("openNotification", new OpenNotification());
map.put("source", new Source());
map.put("compressedLayoutHierarchy", new CompressedLayoutHierarchy());
}
/**
@@ -0,0 +1,32 @@
package io.appium.android.bootstrap.handler;
import io.appium.android.bootstrap.AndroidCommand;
import io.appium.android.bootstrap.AndroidCommandResult;
import io.appium.android.bootstrap.CommandHandler;
import io.appium.android.bootstrap.utils.NotImportantViews;
import org.json.JSONException;
import java.util.Hashtable;
/**
* Calls the uiautomator setCompressedLayoutHierarchy() function. If set to true, ignores some views during all Accessibility operations.
*/
public class CompressedLayoutHierarchy extends CommandHandler {
@Override
public AndroidCommandResult execute(AndroidCommand command) throws JSONException {
boolean compressLayout;
try {
final Hashtable<String, Object> params = command.params();
compressLayout = (Boolean) params.get("compressLayout");
NotImportantViews.discard(compressLayout);
} catch (ClassCastException e) {
return getErrorResult("must supply a 'compressLayout' boolean parameter");
} catch (Exception e) {
return getErrorResult("error setting compressLayoutHierarchy " + e.getMessage());
}
return getSuccessResult(compressLayout);
}
}
@@ -8,14 +8,10 @@ public abstract class NotImportantViews {
// setCompressedLayoutHeirarchy doesn't exist on API <= 17
// http://developer.android.com/reference/android/accessibilityservice/AccessibilityServiceInfo.html#FLAG_INCLUDE_NOT_IMPORTANT_VIEWS
private static boolean canDiscard = API_18;
private static boolean lastDiscard = false;
public static void discard(boolean discard) {
if (canDiscard) {
if (discard != lastDiscard) {
UiDevice.getInstance().setCompressedLayoutHeirarchy(discard);
lastDiscard = discard;
}
UiDevice.getInstance().setCompressedLayoutHeirarchy(discard);
}
}
}
@@ -75,9 +75,6 @@ public abstract class XMLHierarchy {
dumpFile.delete();
//compression off by default TODO add this as a config option
NotImportantViews.discard(false);
try {
// dumpWindowHierarchy often has a NullPointerException
UiDevice.getInstance().dumpWindowHierarchy(dumpFileName);
+3
View File
@@ -74,6 +74,9 @@ Selendroid.prototype.init = function () {
this.curContext = this.defaultContext();
};
Selendroid.prototype.getSettings = deviceCommon.getSettings;
Selendroid.prototype.updateSettings = deviceCommon.updateSettings;
_.extend(Selendroid.prototype, androidCommon);
Selendroid.prototype._deviceConfigure = Device.prototype.configure;
Selendroid.prototype._setAndroidArgs = androidCommon.setAndroidArgs;
+29
View File
@@ -347,3 +347,32 @@ exports.jwpResponse = function (err, val, cb) {
}
return exports.jwpSuccess(val, cb);
};
// methods for appium session Settings
// These should really be on a Session object instead of the Device, but Sessions don't exist yet
exports.getSettings = function (cb) {
if (!this.settings) {
return cb(new UnknownError('No settings object for session'));
}
return cb(null, {
status: status.codes.Success.code
, value: this.settings._settings
});
};
// settings passed in update matching keys in existing settings object, create if not present.
exports.updateSettings = function (newSettings, cb) {
if (!this.settings) {
return cb(new UnknownError('No settings object for session'));
}
this.settings.update(newSettings, function (err) {
if (err) {
return cb(new UnknownError('err updating setting: ' + err));
}
return cb(null, {
status: status.codes.Success.code
, value: null
});
});
};
+64
View File
@@ -0,0 +1,64 @@
"use strict";
var EventEmitter = require('events').EventEmitter
, CamelBackPromise = require('camel-back-promise') // The straw that breaks the camels back. Instantiate by passing in a deferred promise and a number n. After the CamelBackPromise is called n times, the deferred promise is resolved.
, _ = require('underscore')
, Q = require('q')
;
// EventEmitter which emits an 'update' event every time a setting is changed. One call to "updateSettings" could trigger multiple update events.
// The value of the event is a tuple: {key, value, oldValue, callback}.
// Any listeners *MUST* call the function stored in 'callback' to tell the appium server that it it can continue to take commands. This is to prevent race conditions between enabling a setting and calling the next command.
// `callback` is a node-style callback, so passing an argument will cause the updateSettings command to throw an error. Note that the setting still gets changed, even if the subscriber doesn't affect the change. It's up to the client to resolve this case.
var DeviceSettings = function () {
this.init();
};
_.extend(DeviceSettings.prototype, EventEmitter.prototype);
DeviceSettings.prototype.init = function () {
this._settings = {};
// this is where default settings can be declared
this._settings.ignoreUnimportantViews = false;
};
DeviceSettings.prototype.update = function (newSettings, cb) {
// if this code looks familiar, it's because it's modified from the underscore.js implementation of `extend`
if (!_.isObject(newSettings)) {
cb();
}
var numListeners = this.listeners("update").length;
var prop;
var pendingUpdates = [];
for (prop in newSettings) {
if (hasOwnProperty.call(newSettings, prop)) {
var deferred = Q.defer();
pendingUpdates.push(deferred.promise);
var updatePayload = {
key: prop,
value: newSettings[prop],
oldValue: this._settings[prop],
callback: new CamelBackPromise(deferred, numListeners)
};
this._settings[prop] = newSettings[prop];
this.emit("update", updatePayload);
}
}
Q.all(pendingUpdates).then(
function () {
return cb();
},
function (err) {
return cb(err);
}
);
};
module.exports = DeviceSettings;
+7
View File
@@ -9,6 +9,7 @@ var fs = require('fs')
, unzipApp = helpers.unzipApp
, downloadFile = helpers.downloadFile
, capConversion = require('../server/capabilities.js').capabilityConversions
, DeviceSettings = require('./device-settings.js')
, url = require('url');
var Device = function () {
@@ -20,6 +21,7 @@ Device.prototype.init = function () {
this.tempFiles = [];
this.args = {};
this.capabilities = {};
this.settings = new DeviceSettings();
};
Device.prototype.configure = function (args, caps) {
@@ -170,4 +172,9 @@ Device.prototype.downloadAndUnzipApp = function (appUrl, cb) {
}.bind(this));
};
// get a specific setting
Device.prototype.getSetting = function (str) {
return this.settings._settings[str];
};
module.exports = Device;
+2
View File
@@ -163,6 +163,8 @@ Firefox.prototype.receive = function (data) {
};
Firefox.prototype.proxy = deviceCommon.proxy;
Firefox.prototype.getSettings = deviceCommon.getSettings;
Firefox.prototype.updateSettings = deviceCommon.updateSettings;
Firefox.prototype.push = function (elem) {
this.queue.push(elem);
+2
View File
@@ -1346,6 +1346,8 @@ IOS.prototype.implicitWaitForCondition = deviceCommon.implicitWaitForCondition;
IOS.prototype.proxy = deviceCommon.proxy;
IOS.prototype.proxyWithMinTime = deviceCommon.proxyWithMinTime;
IOS.prototype.respond = deviceCommon.respond;
IOS.prototype.getSettings = deviceCommon.getSettings;
IOS.prototype.updateSettings = deviceCommon.updateSettings;
IOS.prototype.initQueue = function () {
+1
View File
@@ -66,6 +66,7 @@ var androidCaps = [
, 'unicodeKeyboard'
, 'resetKeyboard'
, 'noSign'
, 'ignoreUnimportantViews'
];
var iosCaps = [
+11
View File
@@ -1169,3 +1169,14 @@ exports.setNetworkConnection = function (req, res) {
var type = req.body.type || req.body.parameters.type;
req.device.setNetworkConnection(type, getResponseHandler(req, res));
};
exports.getSettings = function (req, res) {
req.device.getSettings(getResponseHandler(req, res));
};
exports.updateSettings = function (req, res) {
var settings = req.body.settings || req.body.parameters.settings;
if (checkMissingParams(req, res, {settings: settings})) {
req.device.updateSettings(settings, getResponseHandler(req, res));
}
};
+3
View File
@@ -133,6 +133,9 @@ module.exports = function (appium) {
rest.post('/wd/hub/session/:sessionId?/appium/element/:elementId?/value', controller.setValueImmediate);
rest.post('/wd/hub/session/:sessionId?/appium/element/:elementId?/replace_value', controller.replaceValue);
rest.post('/wd/hub/session/:sessionId?/appium/settings', controller.updateSettings);
rest.get('/wd/hub/session/:sessionId?/appium/settings', controller.getSettings);
// keep this at the very end!
rest.all('/*', controller.unknownCommand);
};
+1
View File
@@ -53,6 +53,7 @@
"bplist-parser": "~0.0.5",
"bufferpack": "0.0.6",
"bytes": "~1.0.0",
"camel-back-promise": "^1.0.0",
"colors": "~0.6.2",
"date-utils": "~1.2.15",
"difflib": "~0.2.4",
@@ -3,7 +3,8 @@
var setup = require("../../../common/setup-base")
, desired = require("../desired")
, atv = 'android.widget.TextView'
, alv = 'android.widget.ListView';
, alv = 'android.widget.ListView'
;
describe("apidemo - find - by xpath", function () {
@@ -15,7 +16,7 @@ describe("apidemo - find - by xpath", function () {
var t = atv;
before(function (done) {
driver.sleep(1000).nodeify(done);
driver.sleep(2000).nodeify(done);
});
it('should find element by type', function (done) {
@@ -69,4 +70,27 @@ describe("apidemo - find - by xpath", function () {
})
.nodeify(done);
});
it('should find less elements with compression turned on', function (done) {
var getElementsWithoutCompression = function () {
return driver.updateSettings({"ignoreUnimportantViews": false}).elementsByXPath("//*");
};
var getElementsWithCompression = function () {
return driver.updateSettings({"ignoreUnimportantViews": true }).elementsByXPath("//*");
};
var elementsWithoutCompression, elementsWithCompression;
getElementsWithoutCompression()
.then(function (els) {
elementsWithoutCompression = els;
return getElementsWithCompression();
})
.then(function (els) {
elementsWithCompression = els;
})
.then(function () {
return elementsWithoutCompression.length.should.be.greaterThan(elementsWithCompression.length);
})
.nodeify(done);
});
});
@@ -33,4 +33,27 @@ describe("apidemos - source", function () {
.elementByAccessibilityId('Animation')
.nodeify(done);
});
it('should get less source when compression is enabled', function (done) {
var getSourceWithoutCompression = function () {
return driver.updateSettings({"ignoreUnimportantViews": false}).source();
};
var getSourceWithCompression = function () {
return driver.updateSettings({"ignoreUnimportantViews": true }).source();
};
var sourceWithoutCompression, sourceWithCompression;
getSourceWithoutCompression()
.then(function (els) {
sourceWithoutCompression = els;
return getSourceWithCompression();
})
.then(function (els) {
sourceWithCompression = els;
})
.then(function () {
return sourceWithoutCompression.length.should.be.greaterThan(sourceWithCompression.length);
})
.nodeify(done);
});
});
+57
View File
@@ -0,0 +1,57 @@
"use strict";
var setup = require("./setup-base")
, env = require('../../helpers/env')
, getAppPath = require('../../helpers/app').getAppPath;
var app;
if (env.IOS) {
app = 'testapp';
} else if (env.ANDROID || env.SELENDROID) {
app = 'ApiDemos';
}
var desired = {
app: getAppPath(app)
};
if (env.SELENDROID) {
desired.automationName = 'selendroid';
}
describe('settings', function () {
var driver;
setup(this, desired, {'no-reset': true}).then(function (d) { driver = d; });
it('should return a settings object even if none specified', function (done) {
driver
.settings().should.eventually.exist
.nodeify(done);
});
it('should be able to store a setting', function (done) {
driver
.updateSettings({'settlers of': 'Catan'})
.settings().should.eventually.have.property('settlers of')
.nodeify(done);
});
it('should overwrite new settings', function (done) {
driver
.updateSettings({'settlers of': 'Catan'})
.updateSettings({'settlers of': 'Europa'})
.settings().then(function (settings) {
return settings['settlers of'].should.equal('Europa');
})
.nodeify(done);
});
it('should leave non-specified settings unchanged', function (done) {
driver
.updateSettings({'thing one': 1})
.updateSettings({'thing two': 2})
.settings().should.eventually.have.property('thing one')
.nodeify(done);
});
});