diff --git a/appium-tests/android/android.spec.js b/appium-tests/android/android.spec.js new file mode 100644 index 000000000..b75698708 --- /dev/null +++ b/appium-tests/android/android.spec.js @@ -0,0 +1,751 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * +*/ + +// these tests are meant to be executed by Cordova ParaMedic Appium runner +// you can find it here: https://github.com/apache/cordova-paramedic/ +// it is not necessary to do a full CI setup to run these tests +// Run: +// node cordova-paramedic/main.js --platform android --plugin cordova-plugin-camera --skipMainTests --target +// Please note only Android 5.1 and 4.4 are supported at this point. + +'use strict'; + +var wdHelper = global.WD_HELPER; +var screenshotHelper = global.SCREENSHOT_HELPER; +var wd = wdHelper.getWD(); +var cameraConstants = require('../../www/CameraConstants'); +var cameraHelper = require('../helpers/cameraHelper'); + +var MINUTE = 60 * 1000; +var BACK_BUTTON = 4; +var DEFAULT_SCREEN_WIDTH = 360; +var DEFAULT_SCREEN_HEIGHT = 567; +var DEFAULT_WEBVIEW_CONTEXT = 'WEBVIEW'; +var PROMISE_PREFIX = 'appium_camera_promise_'; +var CONTEXT_NATIVE_APP = 'NATIVE_APP'; + +describe('Camera tests Android.', function () { + var driver; + // the name of webview context, it will be changed to match needed context if there are named ones: + var webviewContext = DEFAULT_WEBVIEW_CONTEXT; + // this indicates that the device library has the test picture: + var isTestPictureSaved = false; + // we need to know the screen width and height to properly click on an image in the gallery: + var screenWidth = DEFAULT_SCREEN_WIDTH; + var screenHeight = DEFAULT_SCREEN_HEIGHT; + // promise count to use in promise ID + var promiseCount = 0; + // determine if Appium session is created successfully + var appiumSessionStarted = false; + // determine if camera is present on the device/emulator + var cameraAvailable = false; + // determine if emulator is within a range of acceptable resolutions able to run these tests + var isResolutionBad = true; + // a path to the image we add to the gallery before test run + var fillerImagePath; + var isAndroid7 = getIsAndroid7(); + + function getIsAndroid7() { + if (global.USE_SAUCE) { + return global.SAUCE_CAPS && (parseFloat(global.SAUCE_CAPS.platformVersion) >= 7); + } else { + // this is most likely null, meaning we cannot determine if it is Android 7 or not + // paramedic needs to be modified to receive and pass the platform version when testing locally + return global.PLATFORM_VERSION && (parseFloat(global.PLATFORM_VERSION) >= 7); + } + } + + function getNextPromiseId() { + promiseCount += 1; + return getCurrentPromiseId(); + } + + function getCurrentPromiseId() { + return PROMISE_PREFIX + promiseCount; + } + + function gracefullyFail(error) { + fail(error); + return driver + .quit() + .then(function () { + return getDriver(); + }); + } + + // combinines specified options in all possible variations + // you can add more options to test more scenarios + function generateOptions() { + var sourceTypes = [ + cameraConstants.PictureSourceType.CAMERA, + cameraConstants.PictureSourceType.PHOTOLIBRARY + ]; + var destinationTypes = cameraConstants.DestinationType; + var encodingTypes = cameraConstants.EncodingType; + var allowEditOptions = [ true, false ]; + var correctOrientationOptions = [ true, false ]; + + return cameraHelper.generateSpecs(sourceTypes, destinationTypes, encodingTypes, allowEditOptions, correctOrientationOptions); + } + + // invokes Camera.getPicture() with the specified options + // and goes through all UI interactions unless 'skipUiInteractions' is true + function getPicture(options, skipUiInteractions) { + var promiseId = getNextPromiseId(); + if (!options) { + options = {}; + } + // assign default values + if (!options.hasOwnProperty('allowEdit')) { + options.allowEdit = true; + } + if (!options.hasOwnProperty('destinationType')) { + options.destinationType = cameraConstants.DestinationType.FILE_URI; + } + if (!options.hasOwnProperty('sourceType')) { + options.destinationType = cameraConstants.PictureSourceType.CAMERA; + } + + return driver + .context(webviewContext) + .execute(cameraHelper.getPicture, [options, promiseId]) + .context(CONTEXT_NATIVE_APP) + .then(function () { + if (skipUiInteractions) { + return; + } + // selecting a picture from gallery + if (options.hasOwnProperty('sourceType') && + (options.sourceType === cameraConstants.PictureSourceType.PHOTOLIBRARY || + options.sourceType === cameraConstants.PictureSourceType.SAVEDPHOTOALBUM)) { + var tapTile = new wd.TouchAction(); + var swipeRight = new wd.TouchAction(); + tapTile + .tap({ + x: Math.round(screenWidth / 4), + y: Math.round(screenHeight / 4) + }); + swipeRight + .press({x: 10, y: Math.round(screenHeight / 4)}) + .wait(300) + .moveTo({x: Math.round(screenWidth - (screenWidth / 8)), y: 0}) + .wait(1500) + .release() + .wait(1000); + if (options.allowEdit) { + return driver + // always wait before performing touchAction + .sleep(7000) + .performTouchAction(tapTile); + } + return driver + .waitForElementByAndroidUIAutomator('new UiSelector().text("Gallery");', 20000) + .fail(function () { + // If the Gallery button is not present, swipe right to reveal the Gallery button! + return driver + .performTouchAction(swipeRight) + .waitForElementByAndroidUIAutomator('new UiSelector().text("Gallery");', 20000) + }) + .click() + // always wait before performing touchAction + .sleep(7000) + .performTouchAction(tapTile); + } + // taking a picture from camera + return driver + .waitForElementByAndroidUIAutomator('new UiSelector().resourceIdMatches(".*shutter.*")', MINUTE / 2) + .click() + .waitForElementByAndroidUIAutomator('new UiSelector().resourceIdMatches(".*done.*")', MINUTE / 2) + .click() + .then(function () { + if (isAndroid7 && options.allowEdit) { + return driver + .elementByAndroidUIAutomator('new UiSelector().text("Crop picture");', 20000) + .click() + .fail(function () { + // don't freak out just yet... + return driver; + }) + .elementByAndroidUIAutomator('new UiSelector().text("JUST ONCE");', 20000) + .click() + .fail(function () { + // maybe someone's hit that "ALWAYS" button? + return driver; + }); + } + return driver; + }); + }) + .then(function () { + if (skipUiInteractions) { + return; + } + if (options.allowEdit) { + var saveText = isAndroid7 ? 'SAVE' : 'Save'; + return driver + .waitForElementByAndroidUIAutomator('new UiSelector().text("' + saveText + '")', MINUTE) + .click(); + } + }) + .fail(function (failure) { + throw failure; + }); + } + + // checks if the picture was successfully taken + // if shouldLoad is falsy, ensures that the error callback was called + function checkPicture(shouldLoad, options) { + if (!options) { + options = {}; + } + return driver + .context(webviewContext) + .setAsyncScriptTimeout(MINUTE / 2) + .executeAsync(cameraHelper.checkPicture, [getCurrentPromiseId(), options, isAndroid7]) + .then(function (result) { + if (shouldLoad) { + if (result !== 'OK') { + fail(result); + } + } else if (result.indexOf('ERROR') === -1) { + throw 'Unexpected success callback with result: ' + result; + } + }); + } + + // deletes the latest image from the gallery + function deleteImage() { + var holdTile = new wd.TouchAction(); + holdTile + .press({x: Math.round(screenWidth / 4), y: Math.round(screenHeight / 5)}) + .wait(1000) + .release(); + return driver + // always wait before performing touchAction + .sleep(7000) + .performTouchAction(holdTile) + .elementByAndroidUIAutomator('new UiSelector().text("Delete")') + .then(function (element) { + return element + .click() + .elementByAndroidUIAutomator('new UiSelector().text("OK")') + .click(); + }, function () { + // couldn't find Delete menu item. Possibly there is no image. + return driver; + }); + } + + function getDriver() { + driver = wdHelper.getDriver('Android'); + return driver.getWebviewContext() + .then(function(context) { + webviewContext = context; + return driver.context(webviewContext); + }) + .waitForDeviceReady() + .injectLibraries() + .then(function () { + var options = { + quality: 50, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.SAVEDPHOTOALBUM, + saveToPhotoAlbum: false, + targetWidth: 210, + targetHeight: 210 + }; + return driver + .then(function () { return getPicture(options, true); }) + .context(CONTEXT_NATIVE_APP) + // case insensitive select, will be handy with Android 7 support + .elementByXPath('//android.widget.Button[translate(@text, "alow", "ALOW")="ALLOW"]') + .click() + .fail(function noAlert() { }) + .deviceKeyEvent(BACK_BUTTON) + .sleep(2000) + .elementById('action_bar_title') + .then(function () { + // success means we're still in native app + return driver + .deviceKeyEvent(BACK_BUTTON); + }, function () { + // error means we're already in webview + return driver; + }); + }) + .then(function () { + // doing it inside a function because otherwise + // it would not hook up to the webviewContext var change + // in the first methods of this chain + return driver.context(webviewContext); + }) + .deleteFillerImage(fillerImagePath) + .then(function () { + fillerImagePath = null; + }) + .addFillerImage() + .then(function (result) { + if (result && result.indexOf('ERROR:') === 0) { + throw new Error(result); + } else { + fillerImagePath = result; + } + }); + } + + function recreateSession() { + return driver + .quit() + .finally(function () { + return getDriver(); + }); + } + + function tryRunSpec(spec) { + return driver + .then(spec) + .fail(function () { + return recreateSession() + .then(spec) + .fail(function() { + return recreateSession() + .then(spec); + }); + }) + .fail(gracefullyFail); + } + + // produces a generic spec function which + // takes a picture with specified options + // and then verifies it + function generateSpec(options) { + return function () { + return driver + .then(function () { + return getPicture(options); + }) + .then(function () { + return checkPicture(true, options); + }); + }; + } + + function checkSession(done, skipResolutionCheck) { + if (!appiumSessionStarted) { + fail('Failed to start a session ' + (lastFailureReason ? lastFailureReason : '')); + done(); + } + if (!skipResolutionCheck && isResolutionBad) { + fail('The resolution of this target device is not within the appropriate range of width: blah-blah and height: bleh-bleh. The target\'s current resolution is: ' + isResolutionBad); + } + } + + function checkCamera(options, pending) { + if (!cameraAvailable) { + pending('Skipping because this test requires a functioning camera on the Android device/emulator, and this test suite\'s functional camera test failed on your target environment.'); + } else if (isAndroid7 && options.allowEdit) { + // TODO: Check if it is fixed some day + pending('Skipping because can\'t test with allowEdit=true on Android 7: getting unexpected "Camera cancelled" message.'); + } else if (isAndroid7 && (options.sourceType !== cameraConstants.PictureSourceType.CAMERA)) { + pending('Skipping because can\'t click on the gallery tile on Android 7.'); + } + } + + afterAll(function (done) { + checkSession(done); + driver + .quit() + .done(done); + }, MINUTE); + + it('camera.ui.util configuring driver and starting a session', function (done) { + // retry up to 3 times + getDriver() + .fail(function () { + return getDriver() + .fail(function () { + return getDriver() + .fail(fail); + }); + }) + .then(function () { + appiumSessionStarted = true; + }) + .done(done); + }, 30 * MINUTE); + + it('camera.ui.util determine screen dimensions', function (done) { + checkSession(done, /*skipResolutionCheck?*/ true); // skip the resolution check here since we are about to find out in this spec! + driver + .context(CONTEXT_NATIVE_APP) + .getWindowSize() + .then(function (size) { + screenWidth = Number(size.width); + screenHeight = Number(size.height); + isResolutionBad = false; + /* + TODO: what are acceptable resolution values? + need to check what the emulators used in CI return. + and also what local device definitions work and dont + */ + }) + .done(done); + }, MINUTE); + + it('camera.ui.util determine camera availability', function (done) { + checkSession(done); + var opts = { + sourceType: cameraConstants.PictureSourceType.CAMERA, + saveToPhotoAlbum: false + }; + + return driver + .then(function () { + return getPicture(opts); + }) + .then(function () { + cameraAvailable = true; + }, function () { + return recreateSession(); + }) + .done(done); + }, 5 * MINUTE); + + describe('Specs.', function () { + // getPicture() with saveToPhotoLibrary = true + it('camera.ui.spec.1 Saving a picture to the photo library', function (done) { + var opts = { + quality: 50, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.CAMERA, + saveToPhotoAlbum: true + }; + checkSession(done); + checkCamera(opts, pending); + + var spec = generateSpec(opts); + tryRunSpec(spec) + .then(function () { + isTestPictureSaved = true; + }) + .done(done); + }, 10 * MINUTE); + + // getPicture() with mediaType: VIDEO, sourceType: PHOTOLIBRARY + it('camera.ui.spec.2 Selecting only videos', function (done) { + checkSession(done); + var spec = function () { + var options = { sourceType: cameraConstants.PictureSourceType.PHOTOLIBRARY, + mediaType: cameraConstants.MediaType.VIDEO }; + return driver + .then(function () { + return getPicture(options, true); + }) + .context(CONTEXT_NATIVE_APP) + .then(function () { + // try to find "Gallery" menu item + // if there's none, the gallery should be already opened + return driver + .waitForElementByAndroidUIAutomator('new UiSelector().text("Gallery")', 20000) + .then(function (element) { + return element.click(); + }, function () { + return driver; + }); + }) + .then(function () { + // if the gallery is opened on the videos page, + // there should be a "Choose video" or "Select video" caption + var videoSelector = isAndroid7 ? 'new UiSelector().text("Select video")' : 'new UiSelector().text("Choose video")'; + return driver + .elementByAndroidUIAutomator(videoSelector) + .fail(function () { + throw 'Couldn\'t find a "Choose/select video" element.'; + }); + }) + .deviceKeyEvent(BACK_BUTTON) + .elementByAndroidUIAutomator('new UiSelector().text("Gallery")') + .deviceKeyEvent(BACK_BUTTON) + .finally(function () { + return driver + .elementById('action_bar_title') + .then(function () { + // success means we're still in native app + return driver + .deviceKeyEvent(BACK_BUTTON) + // give native app some time to close + .sleep(2000) + // try again! because every ~30th build + // on Sauce Labs this backbutton doesn't work + .elementById('action_bar_title') + .then(function () { + // success means we're still in native app + return driver + .deviceKeyEvent(BACK_BUTTON); + }, function () { + // error means we're already in webview + return driver; + }); + }, function () { + // error means we're already in webview + return driver; + }); + }); + }; + tryRunSpec(spec).done(done); + }, 10 * MINUTE); + + // getPicture(), then dismiss + // wait for the error callback to be called + it('camera.ui.spec.3 Dismissing the camera', function (done) { + var options = { + quality: 50, + allowEdit: true, + sourceType: cameraConstants.PictureSourceType.CAMERA, + destinationType: cameraConstants.DestinationType.FILE_URI + }; + checkSession(done); + checkCamera(options, pending); + var spec = function () { + return driver + .then(function () { + return getPicture(options, true); + }) + .context(CONTEXT_NATIVE_APP) + .waitForElementByAndroidUIAutomator('new UiSelector().resourceIdMatches(".*cancel.*")', MINUTE / 2) + .click() + .then(function () { + return checkPicture(false); + }); + }; + + tryRunSpec(spec).done(done); + }, 10 * MINUTE); + + // getPicture(), then take picture but dismiss the edit + // wait for the error callback to be called + it('camera.ui.spec.4 Dismissing the edit', function (done) { + var options = { + quality: 50, + allowEdit: true, + sourceType: cameraConstants.PictureSourceType.CAMERA, + destinationType: cameraConstants.DestinationType.FILE_URI + }; + checkSession(done); + checkCamera(options, pending); + var spec = function () { + return driver + .then(function () { + return getPicture(options, true); + }) + .waitForElementByAndroidUIAutomator('new UiSelector().resourceIdMatches(".*shutter.*")', MINUTE / 2) + .click() + .waitForElementByAndroidUIAutomator('new UiSelector().resourceIdMatches(".*done.*")', MINUTE / 2) + .click() + .then(function () { + if (isAndroid7 && options.allowEdit) { + return driver + .waitForElementByAndroidUIAutomator('new UiSelector().text("Crop picture");', 20000) + .click() + .waitForElementByAndroidUIAutomator('new UiSelector().text("JUST ONCE");', 20000) + .click() + .deviceKeyEvent(BACK_BUTTON); + } + return driver + .waitForElementByAndroidUIAutomator('new UiSelector().resourceIdMatches(".*discard.*")', MINUTE / 2) + .click(); + }) + .then(function () { + return checkPicture(false); + }); + }; + + tryRunSpec(spec).done(done); + }, 10 * MINUTE); + + it('camera.ui.spec.5 Verifying target image size, sourceType=CAMERA', function (done) { + var opts = { + quality: 50, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.CAMERA, + saveToPhotoAlbum: false, + targetWidth: 210, + targetHeight: 210 + }; + checkSession(done); + checkCamera(opts, pending); + var spec = generateSpec(opts); + + tryRunSpec(spec).done(done); + }, 10 * MINUTE); + + it('camera.ui.spec.6 Verifying target image size, sourceType=PHOTOLIBRARY', function (done) { + var opts = { + quality: 50, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.PHOTOLIBRARY, + saveToPhotoAlbum: false, + targetWidth: 210, + targetHeight: 210 + }; + checkSession(done); + checkCamera(opts, pending); + var spec = generateSpec(opts); + + tryRunSpec(spec).done(done); + }, 10 * MINUTE); + + it('camera.ui.spec.7 Verifying target image size, sourceType=CAMERA, DestinationType=NATIVE_URI', function (done) { + var opts = { + quality: 50, + allowEdit: true, + sourceType: cameraConstants.PictureSourceType.CAMERA, + destinationType: cameraConstants.DestinationType.NATIVE_URI, + saveToPhotoAlbum: false, + targetWidth: 210, + targetHeight: 210 + }; + checkSession(done); + checkCamera(opts, pending); + var spec = generateSpec(opts); + + tryRunSpec(spec).done(done); + }, 10 * MINUTE); + + it('camera.ui.spec.8 Verifying target image size, sourceType=PHOTOLIBRARY, DestinationType=NATIVE_URI', function (done) { + var opts = { + quality: 50, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.PHOTOLIBRARY, + destinationType: cameraConstants.DestinationType.NATIVE_URI, + saveToPhotoAlbum: false, + targetWidth: 210, + targetHeight: 210 + }; + checkSession(done); + checkCamera(opts, pending); + + var spec = generateSpec(opts); + tryRunSpec(spec).done(done); + }, 10 * MINUTE); + + it('camera.ui.spec.9 Verifying target image size, sourceType=CAMERA, DestinationType=NATIVE_URI, quality=100', function (done) { + var opts = { + quality: 50, + allowEdit: true, + sourceType: cameraConstants.PictureSourceType.CAMERA, + destinationType: cameraConstants.DestinationType.NATIVE_URI, + saveToPhotoAlbum: false, + targetWidth: 305, + targetHeight: 305 + }; + checkSession(done); + checkCamera(opts, pending); + var spec = generateSpec(opts); + + tryRunSpec(spec).done(done); + }, 10 * MINUTE); + + it('camera.ui.spec.10 Verifying target image size, sourceType=PHOTOLIBRARY, DestinationType=NATIVE_URI, quality=100', function (done) { + var opts = { + quality: 100, + allowEdit: true, + sourceType: cameraConstants.PictureSourceType.PHOTOLIBRARY, + destinationType: cameraConstants.DestinationType.NATIVE_URI, + saveToPhotoAlbum: false, + targetWidth: 305, + targetHeight: 305 + }; + checkSession(done); + checkCamera(opts, pending); + var spec = generateSpec(opts); + + tryRunSpec(spec).done(done); + }, 10 * MINUTE); + + // combine various options for getPicture() + generateOptions().forEach(function (spec) { + it('camera.ui.spec.11.' + spec.id + ' Combining options. ' + spec.description, function (done) { + checkSession(done); + checkCamera(spec.options, pending); + + var s = generateSpec(spec.options); + tryRunSpec(s).done(done); + }, 10 * MINUTE); + }); + + it('camera.ui.util Delete filler picture from device library', function (done) { + if (isAndroid7 || global.USE_SAUCE) { + pending(); + } + driver + .context(webviewContext) + .deleteFillerImage(fillerImagePath) + .done(done); + }, MINUTE); + + it('camera.ui.util Delete taken picture from device library', function (done) { + if (isAndroid7 || global.USE_SAUCE) { + pending(); + } + checkSession(done); + if (!isTestPictureSaved) { + // couldn't save test picture earlier, so nothing to delete here + done(); + return; + } + // delete exactly one latest picture + // this should be the picture we've taken in the first spec + driver + .context(CONTEXT_NATIVE_APP) + .deviceKeyEvent(BACK_BUTTON) + .sleep(1000) + .deviceKeyEvent(BACK_BUTTON) + .sleep(1000) + .deviceKeyEvent(BACK_BUTTON) + .elementById('Apps') + .click() + .then(function () { + return driver + .elementByXPath('//android.widget.Button[@text="OK"]') + .click() + .fail(function () { + // no cling is all right + // it is not a brand new emulator, then + }); + }) + .elementByAndroidUIAutomator('new UiSelector().text("Gallery")') + .click() + .elementByAndroidUIAutomator('new UiSelector().textContains("Pictures")') + .click() + .then(deleteImage) + .deviceKeyEvent(BACK_BUTTON) + .sleep(1000) + .deviceKeyEvent(BACK_BUTTON) + .sleep(1000) + .deviceKeyEvent(BACK_BUTTON) + .fail(fail) + .finally(done); + }, 3 * MINUTE); + }); + +}); + diff --git a/appium-tests/helpers/cameraHelper.js b/appium-tests/helpers/cameraHelper.js new file mode 100644 index 000000000..72f7a2700 --- /dev/null +++ b/appium-tests/helpers/cameraHelper.js @@ -0,0 +1,311 @@ +/* global Q, resolveLocalFileSystemURL, Camera, cordova */ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * +*/ + +'use strict'; + +var cameraConstants = require('../../www/CameraConstants'); + +function findKeyByValue(set, value) { + for (var k in set) { + if (set.hasOwnProperty(k)) { + if (set[k] == value) { + return k; + } + } + } + return undefined; +} + +function getDescription(spec) { + var desc = ''; + + desc += 'sourceType: ' + findKeyByValue(cameraConstants.PictureSourceType, spec.options.sourceType); + desc += ', destinationType: ' + findKeyByValue(cameraConstants.DestinationType, spec.options.destinationType); + desc += ', encodingType: ' + findKeyByValue(cameraConstants.EncodingType, spec.options.encodingType); + desc += ', allowEdit: ' + spec.options.allowEdit.toString(); + desc += ', correctOrientation: ' + spec.options.correctOrientation.toString(); + + return desc; +} + +module.exports.generateSpecs = function (sourceTypes, destinationTypes, encodingTypes, allowEditOptions, correctOrientationOptions) { + var destinationType, + sourceType, + encodingType, + allowEdit, + correctOrientation, + specs = [], + id = 1; + for (destinationType in destinationTypes) { + if (destinationTypes.hasOwnProperty(destinationType)) { + for (sourceType in sourceTypes) { + if (sourceTypes.hasOwnProperty(sourceType)) { + for (encodingType in encodingTypes) { + if (encodingTypes.hasOwnProperty(encodingType)) { + for (allowEdit in allowEditOptions) { + if (allowEditOptions.hasOwnProperty(allowEdit)) { + for (correctOrientation in correctOrientationOptions) { + // if taking picture from photolibrary, don't vary 'correctOrientation' option + if ((sourceTypes[sourceType] === cameraConstants.PictureSourceType.PHOTOLIBRARY || + sourceTypes[sourceType] === cameraConstants.PictureSourceType.SAVEDPHOTOALBUM) && + correctOrientation === true) { continue; } + var spec = { + 'id': id++, + 'options': { + 'destinationType': destinationTypes[destinationType], + 'sourceType': sourceTypes[sourceType], + 'encodingType': encodingTypes[encodingType], + 'allowEdit': allowEditOptions[allowEdit], + 'saveToPhotoAlbum': false, + 'correctOrientation': correctOrientationOptions[correctOrientation] + } + }; + spec.description = getDescription(spec); + specs.push(spec); + } + } + } + } + } + } + } + } + } + return specs; +}; + +// calls getPicture() and saves the result in promise +// note that this function is executed in the context of tested app +// and not in the context of tests +module.exports.getPicture = function (opts, pid) { + if (navigator._appiumPromises[pid - 1]) { + navigator._appiumPromises[pid - 1] = null; + } + navigator._appiumPromises[pid] = Q.defer(); + navigator.camera.getPicture(function (result) { + navigator._appiumPromises[pid].resolve(result); + }, function (err) { + navigator._appiumPromises[pid].reject(err); + }, opts); +}; + +// verifies taken picture when the promise is resolved, +// calls a callback with 'OK' if everything is good, +// calls a callback with 'ERROR: ' if something is wrong +// note that this function is executed in the context of tested app +// and not in the context of tests +module.exports.checkPicture = function (pid, options, skipContentCheck, cb) { + var isIos = cordova.platformId === "ios"; + var isAndroid = cordova.platformId === "android"; + // skip image type check if it's unmodified on Android: + // https://github.com/apache/cordova-plugin-camera/#android-quirks-1 + var skipFileTypeCheckAndroid = isAndroid && options.quality === 100 && + !options.targetWidth && !options.targetHeight && + !options.correctOrientation; + + // Skip image type check if destination is NATIVE_URI and source - device's photoalbum + // https://github.com/apache/cordova-plugin-camera/#ios-quirks-1 + var skipFileTypeCheckiOS = isIos && options.destinationType === Camera.DestinationType.NATIVE_URI && + (options.sourceType === Camera.PictureSourceType.PHOTOLIBRARY || + options.sourceType === Camera.PictureSourceType.SAVEDPHOTOALBUM); + + var skipFileTypeCheck = skipFileTypeCheckAndroid || skipFileTypeCheckiOS; + + var desiredType = 'JPEG'; + var mimeType = 'image/jpeg'; + if (options.encodingType === Camera.EncodingType.PNG) { + desiredType = 'PNG'; + mimeType = 'image/png'; + } + + function errorCallback(msg) { + if (msg.hasOwnProperty('message')) { + msg = msg.message; + } + cb('ERROR: ' + msg); + } + + // verifies the image we get from plugin + function verifyResult(result) { + if (result.length === 0) { + errorCallback('The result is empty.'); + return; + } else if (isIos && options.destinationType === Camera.DestinationType.NATIVE_URI && result.indexOf('assets-library:') !== 0) { + errorCallback('Expected "' + result.substring(0, 150) + '"to start with "assets-library:"'); + return; + } else if (isIos && options.destinationType === Camera.DestinationType.FILE_URI && result.indexOf('file:') !== 0) { + errorCallback('Expected "' + result.substring(0, 150) + '"to start with "file:"'); + return; + } + + try { + window.atob(result); + // if we got here it is a base64 string (DATA_URL) + result = "data:" + mimeType + ";base64," + result; + } catch (e) { + // not DATA_URL + if (options.destinationType === Camera.DestinationType.DATA_URL) { + errorCallback('Expected ' + result.substring(0, 150) + 'not to be DATA_URL'); + return; + } + } + + try { + if (result.indexOf('file:') === 0 || + result.indexOf('content:') === 0 || + result.indexOf('assets-library:') === 0) { + + if (!window.resolveLocalFileSystemURL) { + errorCallback('Cannot read file. Please install cordova-plugin-file to fix this.'); + return; + } + if (skipContentCheck) { + cb('OK'); + return; + } + resolveLocalFileSystemURL(result, function (entry) { + if (skipFileTypeCheck) { + displayFile(entry); + } else { + verifyFile(entry); + } + }, function (err) { + errorCallback(err); + }); + } else { + displayImage(result); + } + } catch (e) { + errorCallback(e); + } + } + + // verifies that the file type matches the requested type + function verifyFile(entry) { + try { + var reader = new FileReader(); + reader.onloadend = function(e) { + var arr = (new Uint8Array(e.target.result)).subarray(0, 4); + var header = ''; + for(var i = 0; i < arr.length; i++) { + header += arr[i].toString(16); + } + var actualType = 'unknown'; + + switch (header) { + case "89504e47": + actualType = 'PNG'; + break; + case 'ffd8ffe0': + case 'ffd8ffe1': + case 'ffd8ffe2': + actualType = 'JPEG'; + break; + } + + if (actualType === desiredType) { + displayFile(entry); + } else { + errorCallback('File type mismatch. Expected ' + desiredType + ', got ' + actualType); + } + }; + reader.onerror = function (e) { + errorCallback(e); + }; + entry.file(function (file) { + reader.readAsArrayBuffer(file); + }, function (e) { + errorCallback(e); + }); + } catch (e) { + errorCallback(e); + } + } + + // reads the file, then displays the image + function displayFile(entry) { + function onFileReceived(file) { + var reader = new FileReader(); + reader.onerror = function (e) { + errorCallback(e); + }; + reader.onloadend = function (evt) { + displayImage(evt.target.result); + }; + reader.readAsDataURL(file); + } + + entry.file(onFileReceived, function (e) { + errorCallback(e); + }); + } + + function displayImage(image) { + try { + var imgEl = document.getElementById('camera_test_image'); + if (!imgEl) { + imgEl = document.createElement('img'); + imgEl.id = 'camera_test_image'; + document.body.appendChild(imgEl); + } + var timedOut = false; + var loadTimeout = setTimeout(function () { + timedOut = true; + imgEl.src = ''; + errorCallback('The image did not load: ' + image.substring(0, 150)); + }, 10000); + var done = function (status) { + if (!timedOut) { + clearTimeout(loadTimeout); + imgEl.src = ''; + cb(status); + } + }; + imgEl.onload = function () { + try { + // aspect ratio is preserved so only one dimension should match + if ((typeof options.targetWidth === 'number' && imgEl.naturalWidth !== options.targetWidth) && + (typeof options.targetHeight === 'number' && imgEl.naturalHeight !== options.targetHeight)) + { + done('ERROR: Wrong image size: ' + imgEl.naturalWidth + 'x' + imgEl.naturalHeight + + '. Requested size: ' + options.targetWidth + 'x' + options.targetHeight); + } else { + done('OK'); + } + } catch (e) { + errorCallback(e); + } + }; + imgEl.src = image; + } catch (e) { + errorCallback(e); + } + } + + navigator._appiumPromises[pid].promise + .then(function (result) { + verifyResult(result); + }) + .fail(function (e) { + errorCallback(e); + }); +}; diff --git a/appium-tests/ios/ios.spec.js b/appium-tests/ios/ios.spec.js new file mode 100644 index 000000000..d4eebdec7 --- /dev/null +++ b/appium-tests/ios/ios.spec.js @@ -0,0 +1,512 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * +*/ + +// these tests are meant to be executed by Cordova Paramedic test runner +// you can find it here: https://github.com/apache/cordova-paramedic/ +// it is not necessary to do a full CI setup to run these tests +// just run "node cordova-paramedic/main.js --platform ios --plugin cordova-plugin-camera" + +'use strict'; + +var wdHelper = global.WD_HELPER; +var screenshotHelper = global.SCREENSHOT_HELPER; +var isDevice = global.DEVICE; +var cameraConstants = require('../../www/CameraConstants'); +var cameraHelper = require('../helpers/cameraHelper'); + +var MINUTE = 60 * 1000; +var DEFAULT_WEBVIEW_CONTEXT = 'WEBVIEW_1'; +var PROMISE_PREFIX = 'appium_camera_promise_'; +var CONTEXT_NATIVE_APP = 'NATIVE_APP'; + +describe('Camera tests iOS.', function () { + var driver; + var webviewContext = DEFAULT_WEBVIEW_CONTEXT; + // promise count to use in promise ID + var promiseCount = 0; + // going to set this to false if session is created successfully + var failedToStart = true; + // points out which UI automation to use + var isXCUI = false; + // spec counter to restart the session + var specsRun = 0; + + function getNextPromiseId() { + promiseCount += 1; + return getCurrentPromiseId(); + } + + function getCurrentPromiseId() { + return PROMISE_PREFIX + promiseCount; + } + + function gracefullyFail(error) { + fail(error); + return driver + .quit() + .then(function () { + return getDriver(); + }); + } + + // generates test specs by combining all the specified options + // you can add more options to test more scenarios + function generateOptions() { + var sourceTypes = cameraConstants.PictureSourceType; + var destinationTypes = cameraConstants.DestinationType; + var encodingTypes = cameraConstants.EncodingType; + var allowEditOptions = [ true, false ]; + var correctOrientationOptions = [ true, false ]; + + return cameraHelper.generateSpecs(sourceTypes, destinationTypes, encodingTypes, allowEditOptions, correctOrientationOptions); + } + + function usePicture(allowEdit) { + return driver + .sleep(10) + .then(function () { + if (isXCUI) { + return driver.waitForElementByAccessibilityId('Choose', MINUTE / 3).click(); + } else { + if (allowEdit) { + return wdHelper.tapElementByXPath('//UIAButton[@label="Choose"]', driver); + } + return driver.elementByXPath('//*[@label="Use"]').click(); + } + }); + } + + function clickPhoto() { + if (isXCUI) { + // iOS >=10 + return driver + .context(CONTEXT_NATIVE_APP) + .elementsByXPath('//XCUIElementTypeCell') + .then(function(photos) { + if (photos.length == 0) { + return driver + .sleep(0) // driver.source is not a function o.O + .source() + .then(function (src) { + console.log(src); + gracefullyFail('Couldn\'t find an image to click'); + }); + } + // intentionally clicking the second photo here + // the first one is not clickable for some reason + return photos[1].click(); + }); + } + // iOS <10 + return driver + .elementByXPath('//UIACollectionCell') + .click(); + } + + function getPicture(options, cancelCamera, skipUiInteractions) { + var promiseId = getNextPromiseId(); + if (!options) { + options = {}; + } + // assign defaults + if (!options.hasOwnProperty('allowEdit')) { + options.allowEdit = true; + } + if (!options.hasOwnProperty('destinationType')) { + options.destinationType = cameraConstants.DestinationType.FILE_URI; + } + if (!options.hasOwnProperty('sourceType')) { + options.destinationType = cameraConstants.PictureSourceType.CAMERA; + } + + return driver + .context(webviewContext) + .execute(cameraHelper.getPicture, [options, promiseId]) + .context(CONTEXT_NATIVE_APP) + .then(function () { + if (skipUiInteractions) { + return; + } + if (options.hasOwnProperty('sourceType') && options.sourceType === cameraConstants.PictureSourceType.PHOTOLIBRARY) { + return driver + .waitForElementByAccessibilityId('Camera Roll', MINUTE / 2) + .click() + .then(function () { + return clickPhoto(); + }) + .then(function () { + if (!options.allowEdit) { + return driver; + } + return usePicture(options.allowEdit); + }); + } + if (options.hasOwnProperty('sourceType') && options.sourceType === cameraConstants.PictureSourceType.SAVEDPHOTOALBUM) { + return clickPhoto() + .then(function () { + if (!options.allowEdit) { + return driver; + } + return usePicture(options.allowEdit); + }); + } + if (cancelCamera) { + return driver + .waitForElementByAccessibilityId('Cancel', MINUTE / 2) + .click(); + } + return driver + .waitForElementByAccessibilityId('Take Picture', MINUTE / 2) + .click() + .waitForElementByAccessibilityId('Use Photo', MINUTE / 2) + .click(); + }) + .fail(fail); + } + + // checks if the picture was successfully taken + // if shouldLoad is falsy, ensures that the error callback was called + function checkPicture(shouldLoad, options) { + if (!options) { + options = {}; + } + return driver + .context(webviewContext) + .setAsyncScriptTimeout(MINUTE / 2) + .executeAsync(cameraHelper.checkPicture, [getCurrentPromiseId(), options, false]) + .then(function (result) { + if (shouldLoad) { + if (result !== 'OK') { + fail(result); + } + } else if (result.indexOf('ERROR') === -1) { + throw 'Unexpected success callback with result: ' + result; + } + }); + } + + // takes a picture with the specified options + // and then verifies it + function runSpec(options, done, pending) { + if (options.sourceType === cameraConstants.PictureSourceType.CAMERA && !isDevice) { + pending('Camera is not available on iOS simulator'); + } + checkSession(done); + specsRun += 1; + return driver + .then(function () { + return getPicture(options); + }) + .then(function () { + return checkPicture(true, options); + }) + .fail(gracefullyFail); + } + + function getDriver() { + failedToStart = true; + driver = wdHelper.getDriver('iOS'); + return wdHelper.getWebviewContext(driver) + .then(function(context) { + webviewContext = context; + return driver.context(webviewContext); + }) + .then(function () { + return wdHelper.waitForDeviceReady(driver); + }) + .then(function () { + return wdHelper.injectLibraries(driver); + }) + .sessionCapabilities() + .then(function (caps) { + var platformVersion = parseFloat(caps.platformVersion); + isXCUI = platformVersion >= 10.0; + }) + .then(function () { + var options = { + quality: 50, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.SAVEDPHOTOALBUM, + saveToPhotoAlbum: false, + targetWidth: 210, + targetHeight: 210 + }; + return driver + .then(function () { return getPicture(options, false, true); }) + .context(CONTEXT_NATIVE_APP) + .acceptAlert() + .then(function alertDismissed() { + // TODO: once we move to only XCUITest-based (which is force on you in either iOS 10+ or Xcode 8+) + // UI tests, we will have to: + // a) remove use of autoAcceptAlerts appium capability since it no longer functions in XCUITest + // b) can remove this entire then() clause, as we do not need to explicitly handle the acceptAlert + // failure callback, since we will be guaranteed to hit the permission dialog on startup. + }, function noAlert() { + // in case the contacts permission alert never showed up: no problem, don't freak out. + // This can happen if: + // a) The application-under-test already had photos permissions granted to it + // b) Appium's autoAcceptAlerts capability is provided (and functioning) + }) + .elementByAccessibilityId('Cancel', 10000) + .click(); + }) + .then(function () { + failedToStart = false; + }); + } + + function checkSession(done) { + if (failedToStart) { + fail('Failed to start a session'); + done(); + } + } + + it('camera.ui.util configure driver and start a session', function (done) { + // retry up to 3 times + getDriver() + .fail(function () { + return getDriver() + .fail(function () { + return getDriver() + .fail(fail); + }); + }) + .fail(fail) + .done(done); + }, 30 * MINUTE); + + describe('Specs.', function () { + afterEach(function (done) { + if (specsRun >= 19) { + specsRun = 0; + // we need to restart the session regularly because for some reason + // when running against iOS 10 simulator on SauceLabs, + // Appium cannot handle more than ~20 specs at one session + // the error would be as follows: + // "Could not proxy command to remote server. Original error: Error: connect ECONNREFUSED 127.0.0.1:8100" + checkSession(done); + return driver + .quit() + .then(function () { + return getDriver() + .fail(function () { + return getDriver() + .fail(function () { + return getDriver() + .fail(fail); + }); + }); + }) + .done(done); + } else { + done(); + } + }, 30 * MINUTE); + + // getPicture() with mediaType: VIDEO, sourceType: PHOTOLIBRARY + it('camera.ui.spec.1 Selecting only videos', function (done) { + checkSession(done); + specsRun += 1; + var options = { sourceType: cameraConstants.PictureSourceType.PHOTOLIBRARY, + mediaType: cameraConstants.MediaType.VIDEO }; + driver + // skip ui unteractions + .then(function () { return getPicture(options, false, true); }) + .waitForElementByXPath('//*[contains(@label,"Videos")]', MINUTE / 2) + .elementByAccessibilityId('Cancel') + .click() + .fail(gracefullyFail) + .done(done); + }, 7 * MINUTE); + + // getPicture(), then dismiss + // wait for the error callback to be called + it('camera.ui.spec.2 Dismissing the camera', function (done) { + checkSession(done); + if (!isDevice) { + pending('Camera is not available on iOS simulator'); + } + specsRun += 1; + var options = { sourceType: cameraConstants.PictureSourceType.CAMERA, + saveToPhotoAlbum: false }; + driver + .then(function () { + return getPicture(options, true); + }) + .then(function () { + return checkPicture(false); + }) + .fail(gracefullyFail) + .done(done); + }, 7 * MINUTE); + + it('camera.ui.spec.3 Verifying target image size, sourceType=CAMERA', function (done) { + var options = { + quality: 50, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.CAMERA, + saveToPhotoAlbum: false, + targetWidth: 210, + targetHeight: 210 + }; + + runSpec(options, done, pending).done(done); + }, 7 * MINUTE); + + it('camera.ui.spec.4 Verifying target image size, sourceType=SAVEDPHOTOALBUM', function (done) { + var options = { + quality: 50, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.SAVEDPHOTOALBUM, + saveToPhotoAlbum: false, + targetWidth: 210, + targetHeight: 210 + }; + + runSpec(options, done, pending).done(done); + }, 7 * MINUTE); + + it('camera.ui.spec.5 Verifying target image size, sourceType=PHOTOLIBRARY', function (done) { + var options = { + quality: 50, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.PHOTOLIBRARY, + saveToPhotoAlbum: false, + targetWidth: 210, + targetHeight: 210 + }; + + runSpec(options, done, pending).done(done); + }, 7 * MINUTE); + + it('camera.ui.spec.6 Verifying target image size, sourceType=CAMERA, destinationType=FILE_URL', function (done) { + // remove this line if you don't mind the tests leaving a photo saved on device + pending('Cannot prevent iOS from saving the picture to photo library'); + + var options = { + quality: 50, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.CAMERA, + destinationType: cameraConstants.DestinationType.FILE_URL, + saveToPhotoAlbum: false, + targetWidth: 210, + targetHeight: 210 + }; + + runSpec(options, done, pending).done(done); + }, 7 * MINUTE); + + it('camera.ui.spec.7 Verifying target image size, sourceType=SAVEDPHOTOALBUM, destinationType=FILE_URL', function (done) { + var options = { + quality: 50, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.SAVEDPHOTOALBUM, + destinationType: cameraConstants.DestinationType.FILE_URL, + saveToPhotoAlbum: false, + targetWidth: 210, + targetHeight: 210 + }; + + runSpec(options, done, pending).done(done); + }, 7 * MINUTE); + + it('camera.ui.spec.8 Verifying target image size, sourceType=PHOTOLIBRARY, destinationType=FILE_URL', function (done) { + var options = { + quality: 50, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.PHOTOLIBRARY, + destinationType: cameraConstants.DestinationType.FILE_URL, + saveToPhotoAlbum: false, + targetWidth: 210, + targetHeight: 210 + }; + + runSpec(options, done, pending).done(done); + }, 7 * MINUTE); + + it('camera.ui.spec.9 Verifying target image size, sourceType=CAMERA, destinationType=FILE_URL, quality=100', function (done) { + // remove this line if you don't mind the tests leaving a photo saved on device + pending('Cannot prevent iOS from saving the picture to photo library'); + + var options = { + quality: 100, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.CAMERA, + destinationType: cameraConstants.DestinationType.FILE_URL, + saveToPhotoAlbum: false, + targetWidth: 305, + targetHeight: 305 + }; + runSpec(options, done, pending).done(done); + }, 7 * MINUTE); + + it('camera.ui.spec.10 Verifying target image size, sourceType=SAVEDPHOTOALBUM, destinationType=FILE_URL, quality=100', function (done) { + var options = { + quality: 100, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.SAVEDPHOTOALBUM, + destinationType: cameraConstants.DestinationType.FILE_URL, + saveToPhotoAlbum: false, + targetWidth: 305, + targetHeight: 305 + }; + + runSpec(options, done, pending).done(done); + }, 7 * MINUTE); + + it('camera.ui.spec.11 Verifying target image size, sourceType=PHOTOLIBRARY, destinationType=FILE_URL, quality=100', function (done) { + var options = { + quality: 100, + allowEdit: false, + sourceType: cameraConstants.PictureSourceType.PHOTOLIBRARY, + destinationType: cameraConstants.DestinationType.FILE_URL, + saveToPhotoAlbum: false, + targetWidth: 305, + targetHeight: 305 + }; + + runSpec(options, done, pending).done(done); + }, 7 * MINUTE); + + // combine various options for getPicture() + generateOptions().forEach(function (spec) { + it('camera.ui.spec.12.' + spec.id + ' Combining options. ' + spec.description, function (done) { + // remove this check if you don't mind the tests leaving a photo saved on device + if (spec.options.sourceType === cameraConstants.PictureSourceType.CAMERA && + spec.options.destinationType === cameraConstants.DestinationType.NATIVE_URI) { + pending('Skipping: cannot prevent iOS from saving the picture to photo library and cannot delete it. ' + + 'For more info, see iOS quirks here: https://github.com/apache/cordova-plugin-camera#ios-quirks-1'); + } + + runSpec(spec.options, done, pending).done(done); + }, 7 * MINUTE); + }); + + }); + + it('camera.ui.util Destroy the session', function (done) { + checkSession(done); + driver + .quit() + .done(done); + }, 5 * MINUTE); +});