From 4c0681c92598f59edd0a548cb0daca8ff0b67694 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Sat, 10 Jun 2023 16:19:29 +0200 Subject: [PATCH 01/47] First commit --- .gitattributes | 17 +- .github/workflows/ci.yml | 32 +- LICENSE => LICENSE.md | 0 README.md | 2 +- composer.json | 6 +- docker-compose.yml | 22 + phpunit.xml.dist | 18 +- resources/get-syn.php | 38 + resources/syn.js | 2952 +++++++++++++++++ src/WebdriverClassicDriver.php | 1328 +++++++- .../Custom/ChromeDesiredCapabilitiesTest.php | 71 + .../Custom/FirefoxDesiredCapabilitiesTest.php | 71 + tests/Custom/TimeoutTest.php | 59 + tests/Custom/WebDriverTest.php | 32 + tests/Custom/WindowNameTest.php | 33 + tests/WebdriverClassicConfig.php | 37 +- tests/bootstrap.php | 18 + 17 files changed, 4705 insertions(+), 31 deletions(-) rename LICENSE => LICENSE.md (100%) create mode 100644 docker-compose.yml create mode 100644 resources/get-syn.php create mode 100644 resources/syn.js create mode 100644 tests/Custom/ChromeDesiredCapabilitiesTest.php create mode 100644 tests/Custom/FirefoxDesiredCapabilitiesTest.php create mode 100644 tests/Custom/TimeoutTest.php create mode 100644 tests/Custom/WebDriverTest.php create mode 100644 tests/Custom/WindowNameTest.php create mode 100644 tests/bootstrap.php diff --git a/.gitattributes b/.gitattributes index e02c837..60c56bf 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,7 +1,10 @@ -.editorconfig export-ignore -.gitattributes export-ignore -.github/ export-ignore -.gitignore export-ignore -phpstan*.neon export-ignore -phpunit.xml.dist export-ignore -tests/ export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.github export-ignore +/.gitignore export-ignore +/phpstan*.neon export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore +/README.md export-ignore +/resources/get-syn.php export-ignore +/docker-compose.yaml export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a56c990..be0978f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,39 +37,61 @@ jobs: tests: name: Tests runs-on: ubuntu-latest + # We cannot trigger events in old chrome, so we allow the run to fail + continue-on-error: ${{ matrix.browser == 'chrome' && matrix.selenium == '2.53.1' }} strategy: matrix: php: [ '7.4', '8.0', '8.1', '8.2' ] + browser: [ 'firefox', 'chrome' ] + selenium: [ '2.53.1', '3', '4' ] fail-fast: false steps: - name: Checkout uses: actions/checkout@v3 + - name: Start Selenium + run: | + mkdir -p ./logs + SELENIUM_IMAGE=selenium/standalone-${{ matrix.browser }}:${{ matrix.selenium }} docker compose up --no-color &> ./logs/selenium.log & + - name: Setup PHP uses: shivammathur/setup-php@v2 with: coverage: "xdebug" php-version: "${{ matrix.php }}" + tools: composer ini-file: development + ini-values: date.timezone=Europe/Berlin, error_reporting=-1, display_errors=On + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Install dependencies run: | composer update --no-interaction --prefer-dist --ansi --no-progress + - name: Wait for selenium to start + run: | + curl --head -X GET --silent --show-error --retry 60 --retry-connrefused --retry-delay 1 http://172.18.0.2:4444 + - name: Run tests + env: + WEB_FIXTURES_BROWSER: ${{ matrix.browser }} + DRIVER_URL: http://172.18.0.2:4444/wd/hub + WEB_FIXTURES_HOST: http://host.docker.internal:8002 + DRIVER_MACHINE_BASE_PATH: /fixtures/ run: | - vendor/bin/phpunit -v --coverage-clover=coverage.xml + vendor/bin/phpunit -v --coverage-clover=coverage.xml --colors=always --testdox - name: Upload coverage uses: codecov/codecov-action@v3 + if: ${{ !env.ACT }} with: files: coverage.xml - name: Archive logs artifacts - if: ${{ failure() }} uses: actions/upload-artifact@v3 + if: always() with: - name: logs_php-${{ matrix.php }} - path: | - logs + name: logs_php-${{ matrix.php }}_selenium-${{ matrix.selenium }}_${{ matrix.browser }} + path: logs diff --git a/LICENSE b/LICENSE.md similarity index 100% rename from LICENSE rename to LICENSE.md diff --git a/README.md b/README.md index 7f9ec89..20a9b84 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Latest Unstable Version](https://poser.pugx.org/mink/webdriver-classic-driver/v/unstable)](https://packagist.org/packages/mink/webdriver-classic-driver) [![Total Downloads](https://poser.pugx.org/mink/webdriver-classic-driver/downloads)](https://packagist.org/packages/mink/webdriver-classic-driver) [![CI](https://github.com/minkphp/webdriver-classic-driver/actions/workflows/ci.yml/badge.svg)](https://github.com/minkphp/webdriver-classic-driver/actions/workflows/ci.yml) -[![License](https://poser.pugx.org/mink/webdriver-classic-driver/license)](https://packagist.org/packages/mink/webdriver-classic-driver) +[![License](https://poser.pugx.org/mink/webdriver-classic-driver/license)](https://github.com/minkphp/webdriver-classic-driver/blob/main/LICENSE.md) [![codecov](https://codecov.io/gh/minkphp/webdriver-classic-driver/branch/main/graph/badge.svg?token=11hgqXqod9)](https://codecov.io/gh/minkphp/webdriver-classic-driver) ## Installation diff --git a/composer.json b/composer.json index a959b97..9615e6d 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,9 @@ ], "require": { "php": ">=7.4", - "behat/mink": "^1.9@dev" + "ext-json": "*", + "behat/mink": "^1.9@dev", + "php-webdriver/webdriver": "^1.14" }, "require-dev": { "mink/driver-testsuite": "dev-master", @@ -30,7 +32,7 @@ }, "autoload-dev": { "psr-4": { - "Mink\\WebdriverClassDriver\\Tests\\": "tests" + "Mink\\WebdriverClassDriver\\Tests\\": "tests/" } }, "extra": { diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..809f14a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +version: "3" + +services: + selenium: + image: ${SELENIUM_IMAGE:-selenium/standalone-chrome:4} + hostname: selenium + shm_size: 4g + environment: + VNC_NO_PASSWORD: 1 + SCREEN_WIDTH: 1024 + SCREEN_HEIGHT: 768 + volumes: + - /dev/shm:/dev/shm + - ./vendor/mink/driver-testsuite/web-fixtures:/fixtures + ports: + - "4444:4444" + # VNC Web Viewer port (new images) + - "7900:7900" + # VNC Server port (old "-debug" images) + - "5900:5900" + extra_hosts: + - host.docker.internal:host-gateway diff --git a/phpunit.xml.dist b/phpunit.xml.dist index eea62a1..83494c6 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,8 +1,9 @@ + xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" + bootstrap="tests/bootstrap.php" + beStrictAboutOutputDuringTests="true" + colors="true"> ./src @@ -18,15 +19,18 @@ - + + - + + - - + + + diff --git a/resources/get-syn.php b/resources/get-syn.php new file mode 100644 index 0000000..1ad1a99 --- /dev/null +++ b/resources/get-syn.php @@ -0,0 +1,38 @@ + -1, + opera: !!window.opera, + webkit: navigator.userAgent.indexOf('AppleWebKit/') > -1, + safari: navigator.userAgent.indexOf('AppleWebKit/') > -1 && navigator.userAgent.indexOf('Chrome/') === -1, + gecko: navigator.userAgent.indexOf('Gecko') > -1, + mobilesafari: !!navigator.userAgent.match(/Apple.*Mobile.*Safari/), + rhino: navigator.userAgent.match(/Rhino/) && true, + chrome: !!window.chrome && !!window.chrome.webstore + }, createEventObject = function (type, options, element) { + var event = element.ownerDocument.createEventObject(); + return extend(event, options); + }, data = {}, id = 1, expando = '_synthetic' + new Date().getTime(), bind, unbind, schedule, key = /keypress|keyup|keydown/, page = /load|unload|abort|error|select|change|submit|reset|focus|blur|resize|scroll/, activeElement, syn = function (type, element, options, callback) { + return new syn.init(type, element, options, callback); + }; + syn.config = opts; + syn.__tryFocus = function tryFocus(element) { + try { + element.focus(); + } catch (e) { + } + }; + bind = function (el, ev, f) { + return el.addEventListener ? el.addEventListener(ev, f, false) : el.attachEvent('on' + ev, f); + }; + unbind = function (el, ev, f) { + return el.addEventListener ? el.removeEventListener(ev, f, false) : el.detachEvent('on' + ev, f); + }; + schedule = syn.config.schedule || function (fn, ms) { + setTimeout(fn, ms); + }; + extend(syn, { + init: function (type, element, options, callback) { + var args = syn.args(options, element, callback), self = this; + this.queue = []; + this.element = args.element; + if (typeof this[type] === 'function') { + this[type](args.element, args.options, function (defaults, el) { + if (args.callback) { + args.callback.apply(self, arguments); + } + self.done.apply(self, arguments); + }); + } else { + this.result = syn.trigger(args.element, type, args.options); + if (args.callback) { + args.callback.call(this, args.element, this.result); + } + } + }, + jquery: function (el, fast) { + if (window.FuncUnit && window.FuncUnit.jQuery) { + return window.FuncUnit.jQuery; + } + if (el) { + return syn.helpers.getWindow(el).jQuery || window.jQuery; + } else { + return window.jQuery; + } + }, + args: function () { + var res = {}, i = 0; + for (; i < arguments.length; i++) { + if (typeof arguments[i] === 'function') { + res.callback = arguments[i]; + } else if (arguments[i] && arguments[i].jquery) { + res.element = arguments[i][0]; + } else if (arguments[i] && arguments[i].nodeName) { + res.element = arguments[i]; + } else if (res.options && typeof arguments[i] === 'string') { + res.element = document.getElementById(arguments[i]); + } else if (arguments[i]) { + res.options = arguments[i]; + } + } + return res; + }, + click: function (element, options, callback) { + syn('click!', element, options, callback); + }, + defaults: { + focus: function focus() { + if (!syn.support.focusChanges) { + var element = this, nodeName = element.nodeName.toLowerCase(); + syn.data(element, 'syntheticvalue', element.value); + if (nodeName === 'input' || nodeName === 'textarea') { + bind(element, 'blur', function blur() { + if (syn.data(element, 'syntheticvalue') !== element.value) { + syn.trigger(element, 'change', {}); + } + unbind(element, 'blur', blur); + }); + } + } + }, + submit: function () { + syn.onParents(this, function (el) { + if (el.nodeName.toLowerCase() === 'form') { + el.submit(); + return false; + } + }); + } + }, + changeOnBlur: function (element, prop, value) { + bind(element, 'blur', function onblur() { + if (value !== element[prop]) { + syn.trigger(element, 'change', {}); + } + unbind(element, 'blur', onblur); + }); + }, + closest: function (el, type) { + while (el && el.nodeName.toLowerCase() !== type.toLowerCase()) { + el = el.parentNode; + } + return el; + }, + data: function (el, key, value) { + var d; + if (!el[expando]) { + el[expando] = id++; + } + if (!data[el[expando]]) { + data[el[expando]] = {}; + } + d = data[el[expando]]; + if (value) { + data[el[expando]][key] = value; + } else { + return data[el[expando]][key]; + } + }, + onParents: function (el, func) { + var res; + while (el && res !== false) { + res = func(el); + el = el.parentNode; + } + return el; + }, + focusable: /^(a|area|frame|iframe|label|input|select|textarea|button|html|object)$/i, + isFocusable: function (elem) { + var attributeNode; + if (elem.getAttributeNode) { + attributeNode = elem.getAttributeNode('tabIndex'); + } + return this.focusable.test(elem.nodeName) || attributeNode && attributeNode.specified && syn.isVisible(elem); + }, + isVisible: function (elem) { + return elem.offsetWidth && elem.offsetHeight || elem.clientWidth && elem.clientHeight; + }, + tabIndex: function (elem) { + var attributeNode = elem.getAttributeNode('tabIndex'); + return attributeNode && attributeNode.specified && (parseInt(elem.getAttribute('tabIndex')) || 0); + }, + bind: bind, + unbind: unbind, + schedule: schedule, + browser: browser, + helpers: { + createEventObject: createEventObject, + createBasicStandardEvent: function (type, defaults, doc) { + var event; + try { + event = doc.createEvent('Events'); + } catch (e2) { + event = doc.createEvent('UIEvents'); + } finally { + event.initEvent(type, true, true); + extend(event, defaults); + } + return event; + }, + inArray: function (item, array) { + var i = 0; + for (; i < array.length; i++) { + if (array[i] === item) { + return i; + } + } + return -1; + }, + getWindow: function (element) { + if (element.ownerDocument) { + return element.ownerDocument.defaultView || element.ownerDocument.parentWindow; + } + }, + extend: extend, + scrollOffset: function (win, set) { + var doc = win.document.documentElement, body = win.document.body; + if (set) { + window.scrollTo(set.left, set.top); + } else { + return { + left: (doc && doc.scrollLeft || body && body.scrollLeft || 0) + (doc.clientLeft || 0), + top: (doc && doc.scrollTop || body && body.scrollTop || 0) + (doc.clientTop || 0) + }; + } + }, + scrollDimensions: function (win) { + var doc = win.document.documentElement, body = win.document.body, docWidth = doc.clientWidth, docHeight = doc.clientHeight, compat = win.document.compatMode === 'CSS1Compat'; + return { + height: compat && docHeight || body.clientHeight || docHeight, + width: compat && docWidth || body.clientWidth || docWidth + }; + }, + addOffset: function (options, el) { + var jq = syn.jquery(el), off; + if (typeof options === 'object' && options.clientX === undefined && options.clientY === undefined && options.pageX === undefined && options.pageY === undefined && jq) { + el = jq(el); + off = el.offset(); + options.pageX = off.left + el.width() / 2; + options.pageY = off.top + el.height() / 2; + } + } + }, + key: { + ctrlKey: null, + altKey: null, + shiftKey: null, + metaKey: null + }, + dispatch: function (event, element, type, autoPrevent) { + if (element.dispatchEvent && event) { + var preventDefault = event.preventDefault, prevents = autoPrevent ? -1 : 0; + if (autoPrevent) { + bind(element, type, function ontype(ev) { + ev.preventDefault(); + unbind(this, type, ontype); + }); + } + event.preventDefault = function () { + prevents++; + if (++prevents > 0) { + preventDefault.apply(this, []); + } + }; + element.dispatchEvent(event); + return prevents <= 0; + } else { + try { + window.event = event; + } catch (e) { + } + return element.sourceIndex <= 0 || element.fireEvent && element.fireEvent('on' + type, event); + } + }, + create: { + page: { + event: function (type, options, element) { + var doc = syn.helpers.getWindow(element).document || document, event; + if (doc.createEvent) { + event = doc.createEvent('Events'); + event.initEvent(type, true, true); + return event; + } else { + try { + event = createEventObject(type, options, element); + } catch (e) { + } + return event; + } + } + }, + focus: { + event: function (type, options, element) { + syn.onParents(element, function (el) { + if (syn.isFocusable(el)) { + if (el.nodeName.toLowerCase() !== 'html') { + syn.__tryFocus(el); + activeElement = el; + } else if (activeElement) { + var doc = syn.helpers.getWindow(element).document; + if (doc !== window.document) { + return false; + } else if (doc.activeElement) { + doc.activeElement.blur(); + activeElement = null; + } else { + activeElement.blur(); + activeElement = null; + } + } + return false; + } + }); + return true; + } + } + }, + support: { + clickChanges: false, + clickSubmits: false, + keypressSubmits: false, + mouseupSubmits: false, + radioClickChanges: false, + focusChanges: false, + linkHrefJS: false, + keyCharacters: false, + backspaceWorks: false, + mouseDownUpClicks: false, + tabKeyTabs: false, + keypressOnAnchorClicks: false, + optionClickBubbles: false, + pointerEvents: false, + touchEvents: false, + ready: 0 + }, + trigger: function (element, type, options) { + if (!options) { + options = {}; + } + var create = syn.create, setup = create[type] && create[type].setup, kind = key.test(type) ? 'key' : page.test(type) ? 'page' : 'mouse', createType = create[type] || {}, createKind = create[kind], event, ret, autoPrevent, dispatchEl = element; + if (syn.support.ready === 2 && setup) { + setup(type, options, element); + } + autoPrevent = options._autoPrevent; + delete options._autoPrevent; + if (createType.event) { + ret = createType.event(type, options, element); + } else { + options = createKind.options ? createKind.options(type, options, element) : options; + if (!syn.support.changeBubbles && /option/i.test(element.nodeName)) { + dispatchEl = element.parentNode; + } + event = createKind.event(type, options, dispatchEl); + ret = syn.dispatch(event, dispatchEl, type, autoPrevent); + } + if (ret && syn.support.ready === 2 && syn.defaults[type]) { + syn.defaults[type].call(element, options, autoPrevent); + } + return ret; + }, + eventSupported: function (eventName) { + var el = document.createElement('div'); + eventName = 'on' + eventName; + var isSupported = eventName in el; + if (!isSupported) { + el.setAttribute(eventName, 'return;'); + isSupported = typeof el[eventName] === 'function'; + } + el = null; + return isSupported; + } + }); + extend(syn.init.prototype, { + then: function (type, element, options, callback) { + if (syn.autoDelay) { + this.delay(); + } + var args = syn.args(options, element, callback), self = this; + this.queue.unshift(function (el, prevented) { + if (typeof this[type] === 'function') { + this.element = args.element || el; + this[type](this.element, args.options, function (defaults, el) { + if (args.callback) { + args.callback.apply(self, arguments); + } + self.done.apply(self, arguments); + }); + } else { + this.result = syn.trigger(args.element, type, args.options); + if (args.callback) { + args.callback.call(this, args.element, this.result); + } + return this; + } + }); + return this; + }, + delay: function (timeout, callback) { + if (typeof timeout === 'function') { + callback = timeout; + timeout = null; + } + timeout = timeout || 600; + var self = this; + this.queue.unshift(function () { + schedule(function () { + if (callback) { + callback.apply(self, []); + } + self.done.apply(self, arguments); + }, timeout); + }); + return this; + }, + done: function (defaults, el) { + if (el) { + this.element = el; + } + if (this.queue.length) { + this.queue.pop().call(this, this.element, defaults); + } + }, + '_click': function (element, options, callback, force) { + syn.helpers.addOffset(options, element); + if (syn.support.pointerEvents) { + syn.trigger(element, 'pointerdown', options); + } + if (syn.support.touchEvents) { + syn.trigger(element, 'touchstart', options); + } + syn.trigger(element, 'mousedown', options); + schedule(function () { + if (syn.support.pointerEvents) { + syn.trigger(element, 'pointerup', options); + } + if (syn.support.touchEvents) { + syn.trigger(element, 'touchend', options); + } + syn.trigger(element, 'mouseup', options); + if (!syn.support.mouseDownUpClicks || force) { + syn.trigger(element, 'click', options); + callback(true); + } else { + syn.create.click.setup('click', options, element); + syn.defaults.click.call(element); + schedule(function () { + callback(true); + }, 1); + } + }, 1); + }, + '_rightClick': function (element, options, callback) { + syn.helpers.addOffset(options, element); + var mouseopts = extend(extend({}, syn.mouse.browser.right.mouseup), options); + if (syn.support.pointerEvents) { + syn.trigger(element, 'pointerdown', mouseopts); + } + syn.trigger(element, 'mousedown', mouseopts); + schedule(function () { + if (syn.support.pointerEvents) { + syn.trigger(element, 'pointerup', mouseopts); + } + syn.trigger(element, 'mouseup', mouseopts); + if (syn.mouse.browser.right.contextmenu) { + syn.trigger(element, 'contextmenu', extend(extend({}, syn.mouse.browser.right.contextmenu), options)); + } + callback(true); + }, 1); + }, + '_dblclick': function (element, options, callback) { + syn.helpers.addOffset(options, element); + var self = this; + this._click(element, options, function () { + schedule(function () { + self._click(element, options, function () { + syn.trigger(element, 'dblclick', options); + callback(true); + }, true); + }, 2); + }); + } + }); + var actions = [ + 'click', + 'dblclick', + 'move', + 'drag', + 'key', + 'type', + 'rightClick' + ], makeAction = function (name) { + syn[name] = function (element, options, callback) { + return syn('_' + name, element, options, callback); + }; + syn.init.prototype[name] = function (element, options, callback) { + return this.then('_' + name, element, options, callback); + }; + }, i = 0; + for (; i < actions.length; i++) { + makeAction(actions[i]); + } + module.exports = syn; +}); +/*syn@0.14.1#keyboard-event-keys*/ +define('syn/keyboard-event-keys', [ + 'require', + 'exports', + 'module', + 'syn/synthetic' +], function (require, exports, module) { + var syn = require('syn/synthetic'); + syn.key.keyboardEventKeys = { + '\b': 'Backspace', + '\t': 'Tab', + '\r': 'Enter', + 'shift': 'Shift', + 'ctrl': 'Control', + 'alt': 'Alt', + 'meta': 'Meta', + 'pause-break': 'Pause', + 'caps': 'CapsLock', + 'escape': 'Escape', + 'num-lock': 'NumLock', + 'scroll-lock': 'ScrollLock', + 'print': 'Print', + 'page-up': 'PageUp', + 'page-down': 'PageDown', + 'end': 'End', + 'home': 'Home', + 'left': 'ArrowLeft', + 'up': 'ArrowUp', + 'right': 'ArrowRight', + 'down': 'ArrowDown', + 'insert': 'Insert', + 'delete': 'Delete', + 'f1': 'F1', + 'f2': 'F2', + 'f3': 'F3', + 'f4': 'F4', + 'f5': 'F5', + 'f6': 'F6', + 'f7': 'F7', + 'f8': 'F8', + 'f9': 'F9', + 'f10': 'F10', + 'f11': 'F11', + 'f12': 'F12' + }; +}); +/*syn@0.14.1#mouse*/ +define('syn/mouse', [ + 'require', + 'exports', + 'module', + 'syn/synthetic' +], function (require, exports, module) { + var syn = require('syn/synthetic'); + var h = syn.helpers, getWin = h.getWindow; + syn.mouse = {}; + h.extend(syn.defaults, { + mousedown: function (options) { + syn.trigger(this, 'focus', {}); + }, + click: function () { + var element = this, href, type, createChange, radioChanged, nodeName, scope; + try { + href = element.href; + type = element.type; + createChange = syn.data(element, 'createChange'); + radioChanged = syn.data(element, 'radioChanged'); + scope = getWin(element); + nodeName = element.nodeName.toLowerCase(); + } catch (e) { + return; + } + if (!syn.support.linkHrefJS && /^\s*javascript:/.test(href)) { + var code = href.replace(/^\s*javascript:/, ''); + if (code !== '//' && code.indexOf('void(0)') === -1) { + if (window.selenium) { + eval('with(selenium.browserbot.getCurrentWindow()){' + code + '}'); + } else { + eval('with(scope){' + code + '}'); + } + } + } + if (!syn.support.clickSubmits && ((nodeName === 'input' || nodeName === 'button') && type === 'submit')) { + var form = syn.closest(element, 'form'); + if (form) { + syn.trigger(form, 'submit', {}); + } + } + if (nodeName === 'a' && element.href && !/^\s*javascript:/.test(href)) { + scope.location.href = href; + } + if (nodeName === 'input' && type === 'checkbox') { + if (!syn.support.clickChanges) { + syn.trigger(element, 'change', {}); + } + } + if (nodeName === 'input' && type === 'radio') { + if (radioChanged && !syn.support.radioClickChanges) { + syn.trigger(element, 'change', {}); + } + } + if (nodeName === 'option' && createChange) { + syn.trigger(element.parentNode, 'change', {}); + syn.data(element, 'createChange', false); + } + } + }); + h.extend(syn.create, { + mouse: { + options: function (type, options, element) { + var doc = document.documentElement, body = document.body, center = [ + options.pageX || 0, + options.pageY || 0 + ], left = syn.mouse.browser && syn.mouse.browser.left[type], right = syn.mouse.browser && syn.mouse.browser.right[type]; + return h.extend({ + bubbles: true, + cancelable: true, + view: window, + detail: 1, + screenX: 1, + screenY: 1, + clientX: options.clientX || center[0] - (doc && doc.scrollLeft || body && body.scrollLeft || 0) - (doc.clientLeft || 0), + clientY: options.clientY || center[1] - (doc && doc.scrollTop || body && body.scrollTop || 0) - (doc.clientTop || 0), + ctrlKey: !!syn.key.ctrlKey, + altKey: !!syn.key.altKey, + shiftKey: !!syn.key.shiftKey, + metaKey: !!syn.key.metaKey, + button: left && left.button !== null ? left.button : right && right.button || (type === 'contextmenu' ? 2 : 0), + relatedTarget: document.documentElement + }, options); + }, + event: function (type, defaults, element) { + var doc = getWin(element).document || document, event; + if (doc.createEvent) { + try { + defaults.view = doc.defaultView; + event = doc.createEvent('MouseEvents'); + event.initMouseEvent(type, defaults.bubbles, defaults.cancelable, defaults.view, defaults.detail, defaults.screenX, defaults.screenY, defaults.clientX, defaults.clientY, defaults.ctrlKey, defaults.altKey, defaults.shiftKey, defaults.metaKey, defaults.button, defaults.relatedTarget); + } catch (e) { + event = h.createBasicStandardEvent(type, defaults, doc); + } + event.synthetic = true; + return event; + } else { + try { + event = h.createEventObject(type, defaults, element); + } catch (e) { + } + return event; + } + } + }, + click: { + setup: function (type, options, element) { + var nodeName = element.nodeName.toLowerCase(); + if (!syn.support.clickChecks && !syn.support.changeChecks && nodeName === 'input') { + type = element.type.toLowerCase(); + if (type === 'checkbox') { + element.checked = !element.checked; + } + if (type === 'radio') { + if (!element.checked) { + try { + syn.data(element, 'radioChanged', true); + } catch (e) { + } + element.checked = true; + } + } + } + if (nodeName === 'a' && element.href && !/^\s*javascript:/.test(element.href)) { + syn.data(element, 'href', element.href); + } + if (/option/i.test(element.nodeName)) { + var child = element.parentNode.firstChild, i = -1; + while (child) { + if (child.nodeType === 1) { + i++; + if (child === element) { + break; + } + } + child = child.nextSibling; + } + if (i !== element.parentNode.selectedIndex) { + element.parentNode.selectedIndex = i; + syn.data(element, 'createChange', true); + } + } + } + }, + mousedown: { + setup: function (type, options, element) { + var nn = element.nodeName.toLowerCase(); + if (syn.browser.safari && (nn === 'select' || nn === 'option')) { + options._autoPrevent = true; + } + } + } + }); +}); +/*syn@0.14.1#mouse.support*/ +define('syn/mouse.support', [ + 'require', + 'exports', + 'module', + 'syn/synthetic', + 'syn/mouse' +], function (require, exports, module) { + var syn = require('syn/synthetic'); + require('syn/mouse'); + (function checkSupport() { + if (!document.body) { + return syn.schedule(checkSupport, 1); + } + window.__synthTest = function () { + syn.support.linkHrefJS = true; + }; + var div = document.createElement('div'), checkbox, submit, form, select; + div.innerHTML = '
' + '' + '' + '' + '' + '' + '' + '' + '' + '
'; + document.documentElement.appendChild(div); + form = div.firstChild; + checkbox = form.childNodes[0]; + submit = form.childNodes[2]; + select = form.getElementsByTagName('select')[0]; + syn.trigger(form.childNodes[6], 'click', {}); + checkbox.checked = false; + checkbox.onchange = function () { + syn.support.clickChanges = true; + }; + syn.trigger(checkbox, 'click', {}); + syn.support.clickChecks = checkbox.checked; + checkbox.checked = false; + syn.trigger(checkbox, 'change', {}); + syn.support.changeChecks = checkbox.checked; + form.onsubmit = function (ev) { + if (ev.preventDefault) { + ev.preventDefault(); + } + syn.support.clickSubmits = true; + return false; + }; + syn.trigger(submit, 'click', {}); + form.childNodes[1].onchange = function () { + syn.support.radioClickChanges = true; + }; + syn.trigger(form.childNodes[1], 'click', {}); + syn.bind(div, 'click', function onclick() { + syn.support.optionClickBubbles = true; + syn.unbind(div, 'click', onclick); + }); + syn.trigger(select.firstChild, 'click', {}); + syn.support.changeBubbles = syn.eventSupported('change'); + div.onclick = function () { + syn.support.mouseDownUpClicks = true; + }; + syn.trigger(div, 'mousedown', {}); + syn.trigger(div, 'mouseup', {}); + document.documentElement.removeChild(div); + syn.support.pointerEvents = syn.eventSupported('pointerdown'); + syn.support.touchEvents = syn.eventSupported('touchstart'); + syn.support.ready++; + }()); +}); +/*syn@0.14.1#browsers*/ +define('syn/browsers', [ + 'require', + 'exports', + 'module', + 'syn/synthetic', + 'syn/mouse' +], function (require, exports, module) { + var syn = require('syn/synthetic'); + require('syn/mouse'); + syn.key.browsers = { + webkit: { + 'prevent': { + 'keyup': [], + 'keydown': [ + 'char', + 'keypress' + ], + 'keypress': ['char'] + }, + 'character': { + 'keydown': [ + 0, + 'key' + ], + 'keypress': [ + 'char', + 'char' + ], + 'keyup': [ + 0, + 'key' + ] + }, + 'specialChars': { + 'keydown': [ + 0, + 'char' + ], + 'keyup': [ + 0, + 'char' + ] + }, + 'navigation': { + 'keydown': [ + 0, + 'key' + ], + 'keyup': [ + 0, + 'key' + ] + }, + 'special': { + 'keydown': [ + 0, + 'key' + ], + 'keyup': [ + 0, + 'key' + ] + }, + 'tab': { + 'keydown': [ + 0, + 'char' + ], + 'keyup': [ + 0, + 'char' + ] + }, + 'pause-break': { + 'keydown': [ + 0, + 'key' + ], + 'keyup': [ + 0, + 'key' + ] + }, + 'caps': { + 'keydown': [ + 0, + 'key' + ], + 'keyup': [ + 0, + 'key' + ] + }, + 'escape': { + 'keydown': [ + 0, + 'key' + ], + 'keyup': [ + 0, + 'key' + ] + }, + 'num-lock': { + 'keydown': [ + 0, + 'key' + ], + 'keyup': [ + 0, + 'key' + ] + }, + 'scroll-lock': { + 'keydown': [ + 0, + 'key' + ], + 'keyup': [ + 0, + 'key' + ] + }, + 'print': { + 'keyup': [ + 0, + 'key' + ] + }, + 'function': { + 'keydown': [ + 0, + 'key' + ], + 'keyup': [ + 0, + 'key' + ] + }, + '\r': { + 'keydown': [ + 0, + 'key' + ], + 'keypress': [ + 'char', + 'key' + ], + 'keyup': [ + 0, + 'key' + ] + } + }, + gecko: { + 'prevent': { + 'keyup': [], + 'keydown': ['char'], + 'keypress': ['char'] + }, + 'character': { + 'keydown': [ + 0, + 'key' + ], + 'keypress': [ + 'char', + 0 + ], + 'keyup': [ + 0, + 'key' + ] + }, + 'specialChars': { + 'keydown': [ + 0, + 'key' + ], + 'keypress': [ + 0, + 'key' + ], + 'keyup': [ + 0, + 'key' + ] + }, + 'navigation': { + 'keydown': [ + 0, + 'key' + ], + 'keypress': [ + 0, + 'key' + ], + 'keyup': [ + 0, + 'key' + ] + }, + 'special': { + 'keydown': [ + 0, + 'key' + ], + 'keyup': [ + 0, + 'key' + ] + }, + '\t': { + 'keydown': [ + 0, + 'key' + ], + 'keypress': [ + 0, + 'key' + ], + 'keyup': [ + 0, + 'key' + ] + }, + 'pause-break': { + 'keydown': [ + 0, + 'key' + ], + 'keypress': [ + 0, + 'key' + ], + 'keyup': [ + 0, + 'key' + ] + }, + 'caps': { + 'keydown': [ + 0, + 'key' + ], + 'keyup': [ + 0, + 'key' + ] + }, + 'escape': { + 'keydown': [ + 0, + 'key' + ], + 'keypress': [ + 0, + 'key' + ], + 'keyup': [ + 0, + 'key' + ] + }, + 'num-lock': { + 'keydown': [ + 0, + 'key' + ], + 'keyup': [ + 0, + 'key' + ] + }, + 'scroll-lock': { + 'keydown': [ + 0, + 'key' + ], + 'keyup': [ + 0, + 'key' + ] + }, + 'print': { + 'keyup': [ + 0, + 'key' + ] + }, + 'function': { + 'keydown': [ + 0, + 'key' + ], + 'keyup': [ + 0, + 'key' + ] + }, + '\r': { + 'keydown': [ + 0, + 'key' + ], + 'keypress': [ + 0, + 'key' + ], + 'keyup': [ + 0, + 'key' + ] + } + }, + msie: { + 'prevent': { + 'keyup': [], + 'keydown': [ + 'char', + 'keypress' + ], + 'keypress': ['char'] + }, + 'character': { + 'keydown': [ + null, + 'key' + ], + 'keypress': [ + null, + 'char' + ], + 'keyup': [ + null, + 'key' + ] + }, + 'specialChars': { + 'keydown': [ + null, + 'char' + ], + 'keyup': [ + null, + 'char' + ] + }, + 'navigation': { + 'keydown': [ + null, + 'key' + ], + 'keyup': [ + null, + 'key' + ] + }, + 'special': { + 'keydown': [ + null, + 'key' + ], + 'keyup': [ + null, + 'key' + ] + }, + 'tab': { + 'keydown': [ + null, + 'char' + ], + 'keyup': [ + null, + 'char' + ] + }, + 'pause-break': { + 'keydown': [ + null, + 'key' + ], + 'keyup': [ + null, + 'key' + ] + }, + 'caps': { + 'keydown': [ + null, + 'key' + ], + 'keyup': [ + null, + 'key' + ] + }, + 'escape': { + 'keydown': [ + null, + 'key' + ], + 'keypress': [ + null, + 'key' + ], + 'keyup': [ + null, + 'key' + ] + }, + 'num-lock': { + 'keydown': [ + null, + 'key' + ], + 'keyup': [ + null, + 'key' + ] + }, + 'scroll-lock': { + 'keydown': [ + null, + 'key' + ], + 'keyup': [ + null, + 'key' + ] + }, + 'print': { + 'keyup': [ + null, + 'key' + ] + }, + 'function': { + 'keydown': [ + null, + 'key' + ], + 'keyup': [ + null, + 'key' + ] + }, + '\r': { + 'keydown': [ + null, + 'key' + ], + 'keypress': [ + null, + 'key' + ], + 'keyup': [ + null, + 'key' + ] + } + }, + opera: { + 'prevent': { + 'keyup': [], + 'keydown': [], + 'keypress': ['char'] + }, + 'character': { + 'keydown': [ + null, + 'key' + ], + 'keypress': [ + null, + 'char' + ], + 'keyup': [ + null, + 'key' + ] + }, + 'specialChars': { + 'keydown': [ + null, + 'char' + ], + 'keypress': [ + null, + 'char' + ], + 'keyup': [ + null, + 'char' + ] + }, + 'navigation': { + 'keydown': [ + null, + 'key' + ], + 'keypress': [ + null, + 'key' + ] + }, + 'special': { + 'keydown': [ + null, + 'key' + ], + 'keypress': [ + null, + 'key' + ], + 'keyup': [ + null, + 'key' + ] + }, + 'tab': { + 'keydown': [ + null, + 'char' + ], + 'keypress': [ + null, + 'char' + ], + 'keyup': [ + null, + 'char' + ] + }, + 'pause-break': { + 'keydown': [ + null, + 'key' + ], + 'keypress': [ + null, + 'key' + ], + 'keyup': [ + null, + 'key' + ] + }, + 'caps': { + 'keydown': [ + null, + 'key' + ], + 'keyup': [ + null, + 'key' + ] + }, + 'escape': { + 'keydown': [ + null, + 'key' + ], + 'keypress': [ + null, + 'key' + ] + }, + 'num-lock': { + 'keyup': [ + null, + 'key' + ], + 'keydown': [ + null, + 'key' + ], + 'keypress': [ + null, + 'key' + ] + }, + 'scroll-lock': { + 'keydown': [ + null, + 'key' + ], + 'keypress': [ + null, + 'key' + ], + 'keyup': [ + null, + 'key' + ] + }, + 'print': {}, + 'function': { + 'keydown': [ + null, + 'key' + ], + 'keypress': [ + null, + 'key' + ], + 'keyup': [ + null, + 'key' + ] + }, + '\r': { + 'keydown': [ + null, + 'key' + ], + 'keypress': [ + null, + 'key' + ], + 'keyup': [ + null, + 'key' + ] + } + } + }; + syn.mouse.browsers = { + webkit: { + 'right': { + 'mousedown': { + 'button': 2, + 'which': 3 + }, + 'mouseup': { + 'button': 2, + 'which': 3 + }, + 'contextmenu': { + 'button': 2, + 'which': 3 + } + }, + 'left': { + 'mousedown': { + 'button': 0, + 'which': 1 + }, + 'mouseup': { + 'button': 0, + 'which': 1 + }, + 'click': { + 'button': 0, + 'which': 1 + } + } + }, + opera: { + 'right': { + 'mousedown': { + 'button': 2, + 'which': 3 + }, + 'mouseup': { + 'button': 2, + 'which': 3 + } + }, + 'left': { + 'mousedown': { + 'button': 0, + 'which': 1 + }, + 'mouseup': { + 'button': 0, + 'which': 1 + }, + 'click': { + 'button': 0, + 'which': 1 + } + } + }, + msie: { + 'right': { + 'mousedown': { 'button': 2 }, + 'mouseup': { 'button': 2 }, + 'contextmenu': { 'button': 0 } + }, + 'left': { + 'mousedown': { 'button': 1 }, + 'mouseup': { 'button': 1 }, + 'click': { 'button': 0 } + } + }, + chrome: { + 'right': { + 'mousedown': { + 'button': 2, + 'which': 3 + }, + 'mouseup': { + 'button': 2, + 'which': 3 + }, + 'contextmenu': { + 'button': 2, + 'which': 3 + } + }, + 'left': { + 'mousedown': { + 'button': 0, + 'which': 1 + }, + 'mouseup': { + 'button': 0, + 'which': 1 + }, + 'click': { + 'button': 0, + 'which': 1 + } + } + }, + gecko: { + 'left': { + 'mousedown': { + 'button': 0, + 'which': 1 + }, + 'mouseup': { + 'button': 0, + 'which': 1 + }, + 'click': { + 'button': 0, + 'which': 1 + } + }, + 'right': { + 'mousedown': { + 'button': 2, + 'which': 3 + }, + 'mouseup': { + 'button': 2, + 'which': 3 + }, + 'contextmenu': { + 'button': 2, + 'which': 3 + } + } + } + }; + syn.key.browser = function () { + if (syn.key.browsers[window.navigator.userAgent]) { + return syn.key.browsers[window.navigator.userAgent]; + } + for (var browser in syn.browser) { + if (syn.browser[browser] && syn.key.browsers[browser]) { + return syn.key.browsers[browser]; + } + } + return syn.key.browsers.gecko; + }(); + syn.mouse.browser = function () { + if (syn.mouse.browsers[window.navigator.userAgent]) { + return syn.mouse.browsers[window.navigator.userAgent]; + } + for (var browser in syn.browser) { + if (syn.browser[browser] && syn.mouse.browsers[browser]) { + return syn.mouse.browsers[browser]; + } + } + return syn.mouse.browsers.gecko; + }(); +}); +/*syn@0.14.1#typeable*/ +define('syn/typeable', [ + 'require', + 'exports', + 'module', + 'syn/synthetic' +], function (require, exports, module) { + var syn = require('syn/synthetic'); + var typeables = []; + var __indexOf = [].indexOf || function (item) { + for (var i = 0, l = this.length; i < l; i++) { + if (i in this && this[i] === item) { + return i; + } + } + return -1; + }; + syn.typeable = function (fn) { + if (__indexOf.call(typeables, fn) === -1) { + typeables.push(fn); + } + }; + syn.typeable.test = function (el) { + for (var i = 0, len = typeables.length; i < len; i++) { + if (typeables[i](el)) { + return true; + } + } + return false; + }; + var type = syn.typeable; + var typeableExp = /input|textarea/i; + type(function (el) { + return typeableExp.test(el.nodeName); + }); + type(function (el) { + return __indexOf.call([ + '', + 'true' + ], el.getAttribute('contenteditable')) !== -1; + }); +}); +/*syn@0.14.1#key*/ +define('syn/key', [ + 'require', + 'exports', + 'module', + 'syn/synthetic', + 'syn/typeable', + 'syn/browsers' +], function (require, exports, module) { + var syn = require('syn/synthetic'); + require('syn/typeable'); + require('syn/browsers'); + var h = syn.helpers, formElExp = /input|textarea/i, supportsSelection = function (el) { + var result; + try { + result = el.selectionStart !== undefined && el.selectionStart !== null; + } catch (e) { + result = false; + } + return result; + }, getSelection = function (el) { + var real, r, start; + if (supportsSelection(el)) { + if (document.activeElement && document.activeElement !== el && el.selectionStart === el.selectionEnd && el.selectionStart === 0) { + return { + start: el.value.length, + end: el.value.length + }; + } + return { + start: el.selectionStart, + end: el.selectionEnd + }; + } else { + try { + if (el.nodeName.toLowerCase() === 'input') { + real = h.getWindow(el).document.selection.createRange(); + r = el.createTextRange(); + r.setEndPoint('EndToStart', real); + start = r.text.length; + return { + start: start, + end: start + real.text.length + }; + } else { + real = h.getWindow(el).document.selection.createRange(); + r = real.duplicate(); + var r2 = real.duplicate(), r3 = real.duplicate(); + r2.collapse(); + r3.collapse(false); + r2.moveStart('character', -1); + r3.moveStart('character', -1); + r.moveToElementText(el); + r.setEndPoint('EndToEnd', real); + start = r.text.length - real.text.length; + var end = r.text.length; + if (start !== 0 && r2.text === '') { + start += 2; + } + if (end !== 0 && r3.text === '') { + end += 2; + } + return { + start: start, + end: end + }; + } + } catch (e) { + var prop = formElExp.test(el.nodeName) ? 'value' : 'textContent'; + return { + start: el[prop].length, + end: el[prop].length + }; + } + } + }, getFocusable = function (el) { + var document = h.getWindow(el).document, res = []; + var els = document.getElementsByTagName('*'), len = els.length; + for (var i = 0; i < len; i++) { + if (syn.isFocusable(els[i]) && els[i] !== document.documentElement) { + res.push(els[i]); + } + } + return res; + }, textProperty = function () { + var el = document.createElement('span'); + return el.textContent != null ? 'textContent' : 'innerText'; + }(), getText = function (el) { + if (formElExp.test(el.nodeName)) { + return el.value; + } + return el[textProperty]; + }, setText = function (el, value) { + if (formElExp.test(el.nodeName)) { + el.value = value; + } else { + el[textProperty] = value; + } + }; + h.extend(syn, { + keycodes: { + '\b': 8, + '\t': 9, + '\r': 13, + 'shift': 16, + 'ctrl': 17, + 'alt': 18, + 'meta': 91, + 'pause-break': 19, + 'caps': 20, + 'escape': 27, + 'num-lock': 144, + 'scroll-lock': 145, + 'print': 44, + 'page-up': 33, + 'page-down': 34, + 'end': 35, + 'home': 36, + 'left': 37, + 'up': 38, + 'right': 39, + 'down': 40, + 'insert': 45, + 'delete': 46, + ' ': 32, + '0': 48, + '1': 49, + '2': 50, + '3': 51, + '4': 52, + '5': 53, + '6': 54, + '7': 55, + '8': 56, + '9': 57, + 'a': 65, + 'b': 66, + 'c': 67, + 'd': 68, + 'e': 69, + 'f': 70, + 'g': 71, + 'h': 72, + 'i': 73, + 'j': 74, + 'k': 75, + 'l': 76, + 'm': 77, + 'n': 78, + 'o': 79, + 'p': 80, + 'q': 81, + 'r': 82, + 's': 83, + 't': 84, + 'u': 85, + 'v': 86, + 'w': 87, + 'x': 88, + 'y': 89, + 'z': 90, + 'num0': 96, + 'num1': 97, + 'num2': 98, + 'num3': 99, + 'num4': 100, + 'num5': 101, + 'num6': 102, + 'num7': 103, + 'num8': 104, + 'num9': 105, + '*': 106, + '+': 107, + 'subtract': 109, + 'decimal': 110, + 'divide': 111, + ';': 186, + '=': 187, + ',': 188, + 'dash': 189, + '-': 189, + 'period': 190, + '.': 190, + 'forward-slash': 191, + '/': 191, + '`': 192, + '[': 219, + '\\': 220, + ']': 221, + '\'': 222, + 'left window key': 91, + 'right window key': 92, + 'select key': 93, + 'f1': 112, + 'f2': 113, + 'f3': 114, + 'f4': 115, + 'f5': 116, + 'f6': 117, + 'f7': 118, + 'f8': 119, + 'f9': 120, + 'f10': 121, + 'f11': 122, + 'f12': 123 + }, + selectText: function (el, start, end) { + if (supportsSelection(el)) { + if (!end) { + syn.__tryFocus(el); + el.setSelectionRange(start, start); + } else { + el.selectionStart = start; + el.selectionEnd = end; + } + } else if (el.createTextRange) { + var r = el.createTextRange(); + r.moveStart('character', start); + end = end || start; + r.moveEnd('character', end - el.value.length); + r.select(); + } + }, + getText: function (el) { + if (syn.typeable.test(el)) { + var sel = getSelection(el); + return el.value.substring(sel.start, sel.end); + } + var win = syn.helpers.getWindow(el); + if (win.getSelection) { + return win.getSelection().toString(); + } else if (win.document.getSelection) { + return win.document.getSelection().toString(); + } else { + return win.document.selection.createRange().text; + } + }, + getSelection: getSelection + }); + h.extend(syn.key, { + data: function (key) { + if (syn.key.browser[key]) { + return syn.key.browser[key]; + } + for (var kind in syn.key.kinds) { + if (h.inArray(key, syn.key.kinds[kind]) > -1) { + return syn.key.browser[kind]; + } + } + return syn.key.browser.character; + }, + isSpecial: function (keyCode) { + var specials = syn.key.kinds.special; + for (var i = 0; i < specials.length; i++) { + if (syn.keycodes[specials[i]] === keyCode) { + return specials[i]; + } + } + }, + options: function (key, event) { + var keyData = syn.key.data(key); + if (!keyData[event]) { + return null; + } + var charCode = keyData[event][0], keyCode = keyData[event][1], result = { key: key }; + if (keyCode === 'key') { + result.keyCode = syn.keycodes[key]; + } else if (keyCode === 'char') { + result.keyCode = key.charCodeAt(0); + } else { + result.keyCode = keyCode; + } + if (charCode === 'char') { + result.charCode = key.charCodeAt(0); + } else if (charCode !== null) { + result.charCode = charCode; + } + if (result.keyCode) { + result.which = result.keyCode; + } else { + result.which = result.charCode; + } + return result; + }, + kinds: { + special: [ + 'shift', + 'ctrl', + 'alt', + 'meta', + 'caps' + ], + specialChars: ['\b'], + navigation: [ + 'page-up', + 'page-down', + 'end', + 'home', + 'left', + 'up', + 'right', + 'down', + 'insert', + 'delete' + ], + 'function': [ + 'f1', + 'f2', + 'f3', + 'f4', + 'f5', + 'f6', + 'f7', + 'f8', + 'f9', + 'f10', + 'f11', + 'f12' + ] + }, + getDefault: function (key) { + if (syn.key.defaults[key]) { + return syn.key.defaults[key]; + } + for (var kind in syn.key.kinds) { + if (h.inArray(key, syn.key.kinds[kind]) > -1 && syn.key.defaults[kind]) { + return syn.key.defaults[kind]; + } + } + return syn.key.defaults.character; + }, + defaults: { + 'character': function (options, scope, key, force, sel) { + if (/num\d+/.test(key)) { + key = key.match(/\d+/)[0]; + } + if (force || !syn.support.keyCharacters && syn.typeable.test(this)) { + var current = getText(this), before = current.substr(0, sel.start), after = current.substr(sel.end), character = key; + setText(this, before + character + after); + var charLength = character === '\n' && syn.support.textareaCarriage ? 2 : character.length; + syn.selectText(this, before.length + charLength); + } + }, + 'c': function (options, scope, key, force, sel) { + if (syn.key.ctrlKey) { + syn.key.clipboard = syn.getText(this); + } else { + syn.key.defaults.character.apply(this, arguments); + } + }, + 'v': function (options, scope, key, force, sel) { + if (syn.key.ctrlKey) { + syn.key.defaults.character.call(this, options, scope, syn.key.clipboard, true, sel); + } else { + syn.key.defaults.character.apply(this, arguments); + } + }, + 'a': function (options, scope, key, force, sel) { + if (syn.key.ctrlKey) { + syn.selectText(this, 0, getText(this).length); + } else { + syn.key.defaults.character.apply(this, arguments); + } + }, + 'home': function () { + syn.onParents(this, function (el) { + if (el.scrollHeight !== el.clientHeight) { + el.scrollTop = 0; + return false; + } + }); + }, + 'end': function () { + syn.onParents(this, function (el) { + if (el.scrollHeight !== el.clientHeight) { + el.scrollTop = el.scrollHeight; + return false; + } + }); + }, + 'page-down': function () { + syn.onParents(this, function (el) { + if (el.scrollHeight !== el.clientHeight) { + var ch = el.clientHeight; + el.scrollTop += ch; + return false; + } + }); + }, + 'page-up': function () { + syn.onParents(this, function (el) { + if (el.scrollHeight !== el.clientHeight) { + var ch = el.clientHeight; + el.scrollTop -= ch; + return false; + } + }); + }, + '\b': function (options, scope, key, force, sel) { + if (!syn.support.backspaceWorks && syn.typeable.test(this)) { + var current = getText(this), before = current.substr(0, sel.start), after = current.substr(sel.end); + if (sel.start === sel.end && sel.start > 0) { + setText(this, before.substring(0, before.length - 1) + after); + syn.selectText(this, sel.start - 1); + } else { + setText(this, before + after); + syn.selectText(this, sel.start); + } + } + }, + 'delete': function (options, scope, key, force, sel) { + if (!syn.support.backspaceWorks && syn.typeable.test(this)) { + var current = getText(this), before = current.substr(0, sel.start), after = current.substr(sel.end); + if (sel.start === sel.end && sel.start <= getText(this).length - 1) { + setText(this, before + after.substring(1)); + } else { + setText(this, before + after); + } + syn.selectText(this, sel.start); + } + }, + '\r': function (options, scope, key, force, sel) { + var nodeName = this.nodeName.toLowerCase(); + if (nodeName === 'input') { + syn.trigger(this, 'change', {}); + } + if (!syn.support.keypressSubmits && nodeName === 'input') { + var form = syn.closest(this, 'form'); + if (form) { + syn.trigger(form, 'submit', {}); + } + } + if (!syn.support.keyCharacters && nodeName === 'textarea') { + syn.key.defaults.character.call(this, options, scope, '\n', undefined, sel); + } + if (!syn.support.keypressOnAnchorClicks && nodeName === 'a') { + syn.trigger(this, 'click', {}); + } + }, + '\t': function (options, scope) { + var focusEls = getFocusable(this), current = null, i = 0, el, firstNotIndexed, orders = []; + for (; i < focusEls.length; i++) { + orders.push([ + focusEls[i], + i + ]); + } + var sort = function (order1, order2) { + var el1 = order1[0], el2 = order2[0], tab1 = syn.tabIndex(el1) || 0, tab2 = syn.tabIndex(el2) || 0; + if (tab1 === tab2) { + return order1[1] - order2[1]; + } else { + if (tab1 === 0) { + return 1; + } else if (tab2 === 0) { + return -1; + } else { + return tab1 - tab2; + } + } + }; + orders.sort(sort); + var ordersLength = orders.length; + for (i = 0; i < ordersLength; i++) { + el = orders[i][0]; + if (this === el) { + var nextIndex = i; + if (syn.key.shiftKey) { + nextIndex--; + current = nextIndex >= 0 && orders[nextIndex][0] || orders[ordersLength - 1][0]; + } else { + nextIndex++; + current = nextIndex < ordersLength && orders[nextIndex][0] || orders[0][0]; + } + } + } + if (!current) { + current = firstNotIndexed; + } else { + syn.__tryFocus(current); + } + return current; + }, + 'left': function (options, scope, key, force, sel) { + if (syn.typeable.test(this)) { + if (syn.key.shiftKey) { + syn.selectText(this, sel.start === 0 ? 0 : sel.start - 1, sel.end); + } else { + syn.selectText(this, sel.start === 0 ? 0 : sel.start - 1); + } + } + }, + 'right': function (options, scope, key, force, sel) { + if (syn.typeable.test(this)) { + if (syn.key.shiftKey) { + syn.selectText(this, sel.start, sel.end + 1 > getText(this).length ? getText(this).length : sel.end + 1); + } else { + syn.selectText(this, sel.end + 1 > getText(this).length ? getText(this).length : sel.end + 1); + } + } + }, + 'up': function () { + if (/select/i.test(this.nodeName)) { + this.selectedIndex = this.selectedIndex ? this.selectedIndex - 1 : 0; + } + }, + 'down': function () { + if (/select/i.test(this.nodeName)) { + syn.changeOnBlur(this, 'selectedIndex', this.selectedIndex); + this.selectedIndex = this.selectedIndex + 1; + } + }, + 'shift': function () { + return null; + }, + 'ctrl': function () { + return null; + }, + 'alt': function () { + return null; + }, + 'meta': function () { + return null; + } + } + }); + h.extend(syn.create, { + keydown: { + setup: function (type, options, element) { + if (h.inArray(options, syn.key.kinds.special) !== -1) { + syn.key[options + 'Key'] = element; + } + } + }, + keypress: { + setup: function (type, options, element) { + if (syn.support.keyCharacters && !syn.support.keysOnNotFocused) { + syn.__tryFocus(element); + } + } + }, + keyup: { + setup: function (type, options, element) { + if (h.inArray(options, syn.key.kinds.special) !== -1) { + syn.key[options + 'Key'] = null; + } + } + }, + key: { + options: function (type, options, element) { + options = typeof options !== 'object' ? { character: options } : options; + options = h.extend({}, options); + if (options.character) { + h.extend(options, syn.key.options(options.character, type)); + delete options.character; + } + options = h.extend({ + ctrlKey: !!syn.key.ctrlKey, + altKey: !!syn.key.altKey, + shiftKey: !!syn.key.shiftKey, + metaKey: !!syn.key.metaKey + }, options); + return options; + }, + event: function (type, options, element) { + var doc = h.getWindow(element).document || document, event; + if (typeof KeyboardEvent !== 'undefined') { + var keyboardEventKeys = syn.key.keyboardEventKeys; + if (options.key && keyboardEventKeys[options.key]) { + options.key = keyboardEventKeys[options.key]; + } + event = new KeyboardEvent(type, options); + event.synthetic = true; + return event; + } else if (doc.createEvent) { + try { + event = doc.createEvent('KeyEvents'); + event.initKeyEvent(type, true, true, window, options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, options.keyCode, options.charCode); + } catch (e) { + event = h.createBasicStandardEvent(type, options, doc); + } + event.synthetic = true; + return event; + } else { + try { + event = h.createEventObject.apply(this, arguments); + h.extend(event, options); + } catch (e) { + } + return event; + } + } + } + }); + var convert = { + 'enter': '\r', + 'backspace': '\b', + 'tab': '\t', + 'space': ' ' + }; + h.extend(syn.init.prototype, { + _key: function (element, options, callback) { + if (/-up$/.test(options) && h.inArray(options.replace('-up', ''), syn.key.kinds.special) !== -1) { + syn.trigger(element, 'keyup', options.replace('-up', '')); + return callback(true, element); + } + var activeElement = h.getWindow(element).document.activeElement, caret = syn.typeable.test(element) && getSelection(element), key = convert[options] || options, runDefaults = syn.trigger(element, 'keydown', key), getDefault = syn.key.getDefault, prevent = syn.key.browser.prevent, defaultResult, keypressOptions = syn.key.options(key, 'keypress'); + if (runDefaults) { + if (!keypressOptions) { + defaultResult = getDefault(key).call(element, keypressOptions, h.getWindow(element), key, undefined, caret); + } else { + if (activeElement !== h.getWindow(element).document.activeElement) { + element = h.getWindow(element).document.activeElement; + } + runDefaults = syn.trigger(element, 'keypress', keypressOptions); + if (runDefaults) { + defaultResult = getDefault(key).call(element, keypressOptions, h.getWindow(element), key, undefined, caret); + } + } + } else { + if (keypressOptions && h.inArray('keypress', prevent.keydown) === -1) { + if (activeElement !== h.getWindow(element).document.activeElement) { + element = h.getWindow(element).document.activeElement; + } + syn.trigger(element, 'keypress', keypressOptions); + } + } + if (defaultResult && defaultResult.nodeName) { + element = defaultResult; + } + if (defaultResult !== null) { + syn.schedule(function () { + if (key === '\r' && element.nodeName.toLowerCase() === 'input') { + } else if (syn.support.oninput) { + syn.trigger(element, 'input', syn.key.options(key, 'input')); + } + syn.trigger(element, 'keyup', syn.key.options(key, 'keyup')); + callback(runDefaults, element); + }, 1); + } else { + callback(runDefaults, element); + } + return element; + }, + _type: function (element, options, callback) { + var parts = (options + '').match(/(\[[^\]]+\])|([^\[])/g), self = this, runNextPart = function (runDefaults, el) { + var part = parts.shift(); + if (!part) { + callback(runDefaults, el); + return; + } + el = el || element; + if (part.length > 1) { + part = part.substr(1, part.length - 2); + } + self._key(el, part, runNextPart); + }; + runNextPart(); + } + }); +}); +/*syn@0.14.1#key.support*/ +define('syn/key.support', [ + 'require', + 'exports', + 'module', + 'syn/synthetic', + 'syn/key' +], function (require, exports, module) { + var syn = require('syn/synthetic'); + require('syn/key'); + if (!syn.config.support) { + (function checkForSupport() { + if (!document.body) { + return syn.schedule(checkForSupport, 1); + } + var div = document.createElement('div'), checkbox, submit, form, anchor, textarea, inputter, one, doc; + doc = document.documentElement; + div.innerHTML = '
' + '' + '' + '' + '' + '' + '' + '' + '' + '
'; + doc.insertBefore(div, doc.firstElementChild || doc.children[0]); + form = div.firstChild; + checkbox = form.childNodes[0]; + submit = form.childNodes[2]; + anchor = form.getElementsByTagName('a')[0]; + textarea = form.getElementsByTagName('textarea')[0]; + inputter = form.childNodes[3]; + one = form.childNodes[4]; + form.onsubmit = function (ev) { + if (ev.preventDefault) { + ev.preventDefault(); + } + syn.support.keypressSubmits = true; + ev.returnValue = false; + return false; + }; + syn.__tryFocus(inputter); + syn.trigger(inputter, 'keypress', '\r'); + syn.trigger(inputter, 'keypress', 'a'); + syn.support.keyCharacters = inputter.value === 'a'; + inputter.value = 'a'; + syn.trigger(inputter, 'keypress', '\b'); + syn.support.backspaceWorks = inputter.value === ''; + inputter.onchange = function () { + syn.support.focusChanges = true; + }; + syn.__tryFocus(inputter); + syn.trigger(inputter, 'keypress', 'a'); + syn.__tryFocus(form.childNodes[5]); + syn.trigger(inputter, 'keypress', 'b'); + syn.support.keysOnNotFocused = inputter.value === 'ab'; + syn.bind(anchor, 'click', function (ev) { + if (ev.preventDefault) { + ev.preventDefault(); + } + syn.support.keypressOnAnchorClicks = true; + ev.returnValue = false; + return false; + }); + syn.trigger(anchor, 'keypress', '\r'); + syn.support.textareaCarriage = textarea.value.length === 4; + syn.support.oninput = 'oninput' in one; + doc.removeChild(div); + syn.support.ready++; + }()); + } else { + syn.helpers.extend(syn.support, syn.config.support); + } +}); +/*syn@0.14.1#drag*/ +define('syn/drag', [ + 'require', + 'exports', + 'module', + 'syn/synthetic' +], function (require, exports, module) { + var syn = require('syn/synthetic'); + var elementFromPoint = function (point, win) { + var clientX = point.clientX; + var clientY = point.clientY; + if (point == null) { + return null; + } + if (syn.support.elementFromPage) { + var off = syn.helpers.scrollOffset(win); + clientX = clientX + off.left; + clientY = clientY + off.top; + } + return win.document.elementFromPoint(Math.round(clientX), Math.round(clientY)); + }; + var DragonDrop = { + html5drag: false, + focusWindow: null, + dragAndDrop: function (focusWindow, fromPoint, toPoint, duration, callback) { + this.currentDataTransferItem = null; + this.focusWindow = focusWindow; + this._mouseOver(fromPoint); + this._mouseEnter(fromPoint); + this._mouseMove(fromPoint); + this._mouseDown(fromPoint); + this._dragStart(fromPoint); + this._drag(fromPoint); + this._dragEnter(fromPoint); + this._dragOver(fromPoint); + DragonDrop.startMove(fromPoint, toPoint, duration, function () { + DragonDrop._dragLeave(fromPoint); + DragonDrop._dragEnd(fromPoint); + DragonDrop._mouseOut(fromPoint); + DragonDrop._mouseLeave(fromPoint); + DragonDrop._drop(toPoint); + DragonDrop._dragEnd(toPoint); + DragonDrop._mouseOver(toPoint); + DragonDrop._mouseEnter(toPoint); + DragonDrop._mouseMove(toPoint); + DragonDrop._mouseOut(toPoint); + DragonDrop._mouseLeave(toPoint); + callback(); + DragonDrop.cleanup(); + }); + }, + _dragStart: function (node) { + var options = { + bubbles: false, + cancelable: false + }; + this.createAndDispatchEvent(node, 'dragstart', options); + }, + _drag: function (node) { + var options = { + bubbles: true, + cancelable: true + }; + this.createAndDispatchEvent(node, 'drag', options); + }, + _dragEnter: function (node) { + var options = { + bubbles: true, + cancelable: true + }; + this.createAndDispatchEvent(node, 'dragenter', options); + }, + _dragOver: function (node) { + var options = { + bubbles: true, + cancelable: true + }; + this.createAndDispatchEvent(node, 'dragover', options); + }, + _dragLeave: function (node) { + var options = { + bubbles: true, + cancelable: false + }; + this.createAndDispatchEvent(node, 'dragleave', options); + }, + _drop: function (node) { + var options = { + bubbles: true, + cancelable: true, + buttons: 1 + }; + this.createAndDispatchEvent(node, 'drop', options); + }, + _dragEnd: function (node) { + var options = { + bubbles: true, + cancelable: false + }; + this.createAndDispatchEvent(node, 'dragend', options); + }, + _mouseDown: function (node, options) { + this.createAndDispatchEvent(node, 'mousedown', options); + }, + _mouseMove: function (node, options) { + this.createAndDispatchEvent(node, 'mousemove', options); + }, + _mouseEnter: function (node, options) { + this.createAndDispatchEvent(node, 'mouseenter', options); + }, + _mouseOver: function (node, options) { + this.createAndDispatchEvent(node, 'mouseover', options); + }, + _mouseOut: function (node, options) { + this.createAndDispatchEvent(node, 'mouseout', options); + }, + _mouseLeave: function (node, options) { + this.createAndDispatchEvent(node, 'mouseleave', options); + }, + createAndDispatchEvent: function (point, eventName, options) { + if (point) { + var targetElement = elementFromPoint(point, this.focusWindow); + syn.trigger(targetElement, eventName, options); + } + }, + getDataTransferObject: function () { + if (!this.currentDataTransferItem) { + return this.currentDataTransferItem = this.createDataTransferObject(); + } else { + return this.currentDataTransferItem; + } + }, + cleanup: function () { + this.currentDataTransferItem = null; + this.focusWindow = null; + }, + createDataTransferObject: function () { + var dataTransfer = { + dropEffect: 'none', + effectAllowed: 'uninitialized', + files: [], + items: [], + types: [], + data: [], + setData: function (dataFlavor, value) { + var tempdata = {}; + tempdata.dataFlavor = dataFlavor; + tempdata.val = value; + this.data.push(tempdata); + }, + getData: function (dataFlavor) { + for (var i = 0; i < this.data.length; i++) { + var tempdata = this.data[i]; + if (tempdata.dataFlavor === dataFlavor) { + return tempdata.val; + } + } + } + }; + return dataTransfer; + }, + startMove: function (start, end, duration, callback) { + var startTime = new Date(); + var distX = end.clientX - start.clientX; + var distY = end.clientY - start.clientY; + var win = this.focusWindow; + var current = start; + var cursor = win.document.createElement('div'); + var calls = 0; + var move; + move = function onmove() { + var now = new Date(); + var scrollOffset = syn.helpers.scrollOffset(win); + var fraction = (calls === 0 ? 0 : now - startTime) / duration; + var options = { + clientX: distX * fraction + start.clientX, + clientY: distY * fraction + start.clientY + }; + calls++; + if (fraction < 1) { + syn.helpers.extend(cursor.style, { + left: options.clientX + scrollOffset.left + 2 + 'px', + top: options.clientY + scrollOffset.top + 2 + 'px' + }); + current = DragonDrop.mouseMove(options, current); + syn.schedule(onmove, 15); + } else { + current = DragonDrop.mouseMove(end, current); + win.document.body.removeChild(cursor); + callback(); + } + }; + syn.helpers.extend(cursor.style, { + height: '5px', + width: '5px', + backgroundColor: 'red', + position: 'absolute', + zIndex: 19999, + fontSize: '1px' + }); + win.document.body.appendChild(cursor); + move(); + }, + mouseMove: function (thisPoint, previousPoint) { + var thisElement = elementFromPoint(thisPoint, this.focusWindow); + var previousElement = elementFromPoint(previousPoint, this.focusWindow); + var options = syn.helpers.extend({}, thisPoint); + if (thisElement !== previousElement) { + options.relatedTarget = thisElement; + this._dragLeave(previousPoint, options); + options.relatedTarget = previousElement; + this._dragEnter(thisPoint, options); + } + this._dragOver(thisPoint, options); + return thisPoint; + } + }; + function createDragEvent(eventName, options, element) { + var dragEvent = syn.create.mouse.event(eventName, options, element); + dragEvent.dataTransfer = DragonDrop.getDataTransferObject(); + return syn.dispatch(dragEvent, element, eventName, false); + } + syn.create.dragstart = { event: createDragEvent }; + syn.create.dragenter = { event: createDragEvent }; + syn.create.dragover = { event: createDragEvent }; + syn.create.dragleave = { event: createDragEvent }; + syn.create.drag = { event: createDragEvent }; + syn.create.drop = { event: createDragEvent }; + syn.create.dragend = { event: createDragEvent }; + (function dragSupport() { + if (!document.body) { + syn.schedule(dragSupport, 1); + return; + } + var div = document.createElement('div'); + document.body.appendChild(div); + syn.helpers.extend(div.style, { + width: '100px', + height: '10000px', + backgroundColor: 'blue', + position: 'absolute', + top: '10px', + left: '0px', + zIndex: 19999 + }); + document.body.scrollTop = 11; + if (!document.elementFromPoint) { + return; + } + var el = document.elementFromPoint(3, 1); + if (el === div) { + syn.support.elementFromClient = true; + } else { + syn.support.elementFromPage = true; + } + document.body.removeChild(div); + document.body.scrollTop = 0; + }()); + var mouseMove = function (point, win, last) { + var el = elementFromPoint(point, win); + if (last !== el && el && last) { + var options = syn.helpers.extend({}, point); + options.relatedTarget = el; + if (syn.support.pointerEvents) { + syn.trigger(last, 'pointerout', options); + syn.trigger(last, 'pointerleave', options); + } + syn.trigger(last, 'mouseout', options); + syn.trigger(last, 'mouseleave', options); + options.relatedTarget = last; + if (syn.support.pointerEvents) { + syn.trigger(el, 'pointerover', options); + syn.trigger(el, 'pointerenter', options); + } + syn.trigger(el, 'mouseover', options); + syn.trigger(el, 'mouseenter', options); + } + if (syn.support.pointerEvents) { + syn.trigger(el || win, 'pointermove', point); + } + if (syn.support.touchEvents) { + syn.trigger(el || win, 'touchmove', point); + } + if (DragonDrop.html5drag) { + if (!syn.support.pointerEvents) { + syn.trigger(el || win, 'mousemove', point); + } + } else { + syn.trigger(el || win, 'mousemove', point); + } + return el; + }, createEventAtPoint = function (event, point, win) { + var el = elementFromPoint(point, win); + syn.trigger(el || win, event, point); + return el; + }, startMove = function (win, start, end, duration, callback) { + var startTime = new Date(), distX = end.clientX - start.clientX, distY = end.clientY - start.clientY, current = elementFromPoint(start, win), cursor = win.document.createElement('div'), calls = 0, move; + move = function onmove() { + var now = new Date(), scrollOffset = syn.helpers.scrollOffset(win), fraction = (calls === 0 ? 0 : now - startTime) / duration, options = { + clientX: distX * fraction + start.clientX, + clientY: distY * fraction + start.clientY + }; + calls++; + if (fraction < 1) { + syn.helpers.extend(cursor.style, { + left: options.clientX + scrollOffset.left + 2 + 'px', + top: options.clientY + scrollOffset.top + 2 + 'px' + }); + current = mouseMove(options, win, current); + syn.schedule(onmove, 15); + } else { + current = mouseMove(end, win, current); + win.document.body.removeChild(cursor); + callback(); + } + }; + syn.helpers.extend(cursor.style, { + height: '5px', + width: '5px', + backgroundColor: 'red', + position: 'absolute', + zIndex: 19999, + fontSize: '1px' + }); + win.document.body.appendChild(cursor); + move(); + }, startDrag = function (win, fromPoint, toPoint, duration, callback) { + if (syn.support.pointerEvents) { + createEventAtPoint('pointerover', fromPoint, win); + createEventAtPoint('pointerenter', fromPoint, win); + } + createEventAtPoint('mouseover', fromPoint, win); + createEventAtPoint('mouseenter', fromPoint, win); + if (syn.support.pointerEvents) { + createEventAtPoint('pointermove', fromPoint, win); + } + createEventAtPoint('mousemove', fromPoint, win); + if (syn.support.pointerEvents) { + createEventAtPoint('pointerdown', fromPoint, win); + } + if (syn.support.touchEvents) { + createEventAtPoint('touchstart', fromPoint, win); + } + createEventAtPoint('mousedown', fromPoint, win); + startMove(win, fromPoint, toPoint, duration, function () { + if (syn.support.pointerEvents) { + createEventAtPoint('pointerup', toPoint, win); + } + if (syn.support.touchEvents) { + createEventAtPoint('touchend', toPoint, win); + } + createEventAtPoint('mouseup', toPoint, win); + if (syn.support.pointerEvents) { + createEventAtPoint('pointerleave', toPoint, win); + } + createEventAtPoint('mouseleave', toPoint, win); + callback(); + }); + }, center = function (el) { + var j = syn.jquery()(el), o = j.offset(); + return { + pageX: o.left + j.outerWidth() / 2, + pageY: o.top + j.outerHeight() / 2 + }; + }, convertOption = function (option, win, from) { + var page = /(\d+)[x ](\d+)/, client = /(\d+)X(\d+)/, relative = /([+-]\d+)[xX ]([+-]\d+)/, parts; + if (typeof option === 'string' && relative.test(option) && from) { + var cent = center(from); + parts = option.match(relative); + option = { + pageX: cent.pageX + parseInt(parts[1]), + pageY: cent.pageY + parseInt(parts[2]) + }; + } + if (typeof option === 'string' && page.test(option)) { + parts = option.match(page); + option = { + pageX: parseInt(parts[1]), + pageY: parseInt(parts[2]) + }; + } + if (typeof option === 'string' && client.test(option)) { + parts = option.match(client); + option = { + clientX: parseInt(parts[1]), + clientY: parseInt(parts[2]) + }; + } + if (typeof option === 'string') { + option = syn.jquery()(option, win.document)[0]; + } + if (option.nodeName) { + option = center(option); + } + if (option.pageX != null) { + var off = syn.helpers.scrollOffset(win); + option = { + clientX: option.pageX - off.left, + clientY: option.pageY - off.top + }; + } + return option; + }, adjust = function (from, to, win) { + if (from.clientY < 0) { + var off = syn.helpers.scrollOffset(win); + var top = off.top + from.clientY - 100, diff = top - off.top; + if (top > 0) { + } else { + top = 0; + diff = -off.top; + } + from.clientY = from.clientY - diff; + to.clientY = to.clientY - diff; + syn.helpers.scrollOffset(win, { + top: top, + left: off.left + }); + } + }; + syn.helpers.extend(syn.init.prototype, { + _move: function (from, options, callback) { + var win = syn.helpers.getWindow(from); + var sourceCoordinates = convertOption(options.from || from, win, from); + var destinationCoordinates = convertOption(options.to || options, win, from); + DragonDrop.html5drag = syn.support.pointerEvents; + if (options.adjust !== false) { + adjust(sourceCoordinates, destinationCoordinates, win); + } + startMove(win, sourceCoordinates, destinationCoordinates, options.duration || 500, callback); + }, + _drag: function (from, options, callback) { + var win = syn.helpers.getWindow(from); + var sourceCoordinates = convertOption(options.from || from, win, from); + var destinationCoordinates = convertOption(options.to || options, win, from); + if (options.adjust !== false) { + adjust(sourceCoordinates, destinationCoordinates, win); + } + DragonDrop.html5drag = from.draggable; + if (DragonDrop.html5drag) { + DragonDrop.dragAndDrop(win, sourceCoordinates, destinationCoordinates, options.duration || 500, callback); + } else { + startDrag(win, sourceCoordinates, destinationCoordinates, options.duration || 500, callback); + } + } + }); +}); +/*syn@0.14.1#syn*/ +define('syn', [ + 'require', + 'exports', + 'module', + 'syn/synthetic', + 'syn/keyboard-event-keys', + 'syn/mouse.support', + 'syn/browsers', + 'syn/key.support', + 'syn/drag' +], function (require, exports, module) { + var syn = require('syn/synthetic'); + require('syn/keyboard-event-keys'); + require('syn/mouse.support'); + require('syn/browsers'); + require('syn/key.support'); + require('syn/drag'); + window.syn = syn; + module.exports = syn; +}); +/*[global-shim-end]*/ +(function(global) { // jshint ignore:line + global._define = global.define; + global.define = global.define.orig; +} +)(typeof self == "object" && self.Object == Object ? self : (typeof process === "object" && Object.prototype.toString.call(process) === "[object process]") ? global : window); \ No newline at end of file diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index 61f0d31..d7babee 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -1,21 +1,1345 @@ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. + * + * @noinspection PhpLanguageLevelInspection */ namespace Mink\WebdriverClassDriver; use Behat\Mink\Driver\CoreDriver; +use Behat\Mink\Exception\DriverException; +use Behat\Mink\Selector\Xpath\Escaper; +use Facebook\WebDriver\Exception\NoSuchCookieException; +use Facebook\WebDriver\Exception\NoSuchElementException; +use Facebook\WebDriver\Exception\WebDriverException; +use Facebook\WebDriver\Remote\DesiredCapabilities; +use Facebook\WebDriver\Remote\RemoteWebDriver; +use Facebook\WebDriver\Remote\RemoteWebElement; +use Facebook\WebDriver\WebDriverBy; +use Facebook\WebDriver\WebDriverDimension; +use JetBrains\PhpStorm\Language; +use JsonException; +use Throwable; class WebdriverClassicDriver extends CoreDriver { + public const DEFAULT_BROWSER = 'chrome'; + + public const DEFAULT_CAPABILITIES = [ + 'default' => [ + 'platform' => 'ANY', + 'name' => 'Behat Test', + 'deviceOrientation' => 'landscape', + 'deviceType' => 'desktop', + ], + 'chrome' => [ + 'goog:chromeOptions' => [ + 'excludeSwitches' => ['enable-automation'], + ], + ], + 'firefox' => [ + ], + ]; + + private const W3C_WINDOW_HANDLE_PREFIX = 'w3cwh:'; + + private ?RemoteWebDriver $webDriver = null; + + private string $browserName; + + private DesiredCapabilities $desiredCapabilities; + + private bool $started = false; + + private array $timeouts = []; + + private Escaper $xpathEscaper; + + private string $webDriverHost; + + private ?string $initialWindowName = null; + + /** + * @throws DriverException + */ + public function __construct( + string $browserName = null, + array $desiredCapabilities = null, + string $webDriverHost = null, + Escaper $xpathEscaper = null + ) { + $this->browserName = $browserName ?? self::DEFAULT_BROWSER; + $this->setDesiredCapabilities($this->initCapabilities($desiredCapabilities ?? [])); + $this->webDriverHost = $webDriverHost ?? 'http://localhost:4444/wd/hub'; + $this->xpathEscaper = $xpathEscaper ?? new Escaper(); + } + + // + + /** + * {@inheritdoc} + * @throws DriverException + */ + public function start(): void + { + try { + $this->webDriver = RemoteWebDriver::create($this->webDriverHost, $this->desiredCapabilities); + $this->applyTimeouts(); + $this->initialWindowName = $this->getWindowName(); + $this->started = true; + } catch (Throwable $e) { + throw new DriverException("Could not open connection: {$e->getMessage()}", 0, $e); + } + } + + /** + * Sets the timeouts to apply to the webdriver session + * + * @param array $timeouts The session timeout settings: Array of {script, implicit, page} => time in milliseconds + * @throws DriverException + * @api + */ + public function setTimeouts(array $timeouts): void + { + $this->timeouts = $timeouts; + + if ($this->isStarted()) { + $this->applyTimeouts(); + } + } + + /** + * {@inheritdoc} + */ public function isStarted(): bool { - return false; + return $this->started; + } + + /** + * {@inheritdoc} + * @throws DriverException + */ + public function stop(): void + { + if (!$this->webDriver) { + throw new DriverException('Could not connect to a Selenium / WebDriver server'); + } + + try { + $this->started = false; + $this->webDriver->quit(); + } catch (Throwable $e) { + throw new DriverException('Could not close connection', 0, $e); + } + } + + /** + * {@inheritdoc} + */ + public function reset(): void + { + $this->webDriver->manage()->deleteAllCookies(); + } + + /** + * {@inheritdoc} + */ + public function visit($url): void + { + $this->webDriver->navigate()->to($url); + } + + /** + * {@inheritdoc} + */ + public function getCurrentUrl(): string + { + return $this->webDriver->getCurrentURL(); + } + + /** + * {@inheritdoc} + */ + public function reload(): void + { + $this->webDriver->navigate()->refresh(); + } + + /** + * {@inheritdoc} + */ + public function forward(): void + { + $this->webDriver->navigate()->forward(); + } + + /** + * {@inheritdoc} + */ + public function back(): void + { + $this->webDriver->navigate()->back(); + } + + /** + * {@inheritdoc} + * @throws DriverException + */ + public function switchToWindow($name = null): void + { + if ($name === null) { + $name = $this->initialWindowName; + } + + if (is_string($name)) { + $name = $this->getWindowHandleFromName($name); + } + + $this->webDriver->switchTo()->window($name); + } + + /** + * {@inheritdoc} + */ + public function switchToIFrame($name = null): void + { + $frameQuery = $name; + if ($name && is_string($name) && $this->webDriver->isW3cCompliant()) { + try { + $frameQuery = $this->webDriver->findElement(WebDriverBy::id($name)); + } catch (NoSuchElementException $e) { + $frameQuery = $this->webDriver->findElement(WebDriverBy::name($name)); + } + } + + $this->webDriver->switchTo()->frame($frameQuery); + } + + /** + * {@inheritdoc} + */ + public function setCookie($name, $value = null): void + { + if (null === $value) { + $this->webDriver->manage()->deleteCookieNamed($name); + + return; + } + + $cookieArray = [ + 'name' => $name, + 'value' => rawurlencode($value), + 'secure' => false, + ]; + + $this->webDriver->manage()->addCookie($cookieArray); + } + + /** + * {@inheritdoc} + */ + public function getCookie($name): ?string + { + try { + $result = $this->webDriver->manage()->getCookieNamed($name); + } catch (NoSuchCookieException $e) { + $result = null; + } + if ($result === null) { + return null; + } + + $result = $result->getValue(); + if ($result === null) { + return null; + } + + return rawurldecode($result); + } + + /** + * {@inheritdoc} + */ + public function getContent(): string + { + return $this->webDriver->getPageSource(); + } + + /** + * {@inheritdoc} + */ + public function getScreenshot(): string + { + return $this->webDriver->takeScreenshot(); + } + + /** + * {@inheritdoc} + */ + public function getWindowNames(): array + { + $origWindow = $this->webDriver->getWindowHandle(); + + try { + $result = []; + foreach ($this->webDriver->getWindowHandles() as $tempWindow) { + $this->webDriver->switchTo()->window($tempWindow); + $result[] = $this->getWindowName(); + } + return $result; + } finally { + $this->webDriver->switchTo()->window($origWindow); + } + } + + /** + * {@inheritdoc} + */ + public function getWindowName(): string + { + $name = (string)$this->evaluateScript('window.name'); + + if ($name === '') { + $name = self::W3C_WINDOW_HANDLE_PREFIX . $this->webDriver->getWindowHandle(); + } + + return $name; + } + + /** + * {@inheritdoc} + */ + public function findElementXpaths( + #[Language('XPath')] + $xpath + ): array { + $nodes = $this->webDriver->findElements(WebDriverBy::xpath($xpath)); + + $elements = []; + foreach ($nodes as $i => $node) { + $elements[] = sprintf('(%s)[%d]', $xpath, $i + 1); + } + + return $elements; + } + + /** + * {@inheritdoc} + * @throws NoSuchElementException + */ + public function getTagName( + #[Language('XPath')] + $xpath + ): string { + return $this->findElement($xpath)->getTagName(); + } + + /** + * {@inheritdoc} + * @throws NoSuchElementException + */ + public function getText( + #[Language('XPath')] + $xpath + ): string { + return str_replace(["\r", "\n"], ' ', $this->findElement($xpath)->getText()); + } + + /** + * {@inheritdoc} + * @throws NoSuchElementException + */ + public function getHtml( + #[Language('XPath')] + $xpath + ): string { + return $this->executeJsOnXpath($xpath, 'return arguments[0].innerHTML;'); + } + + /** + * {@inheritdoc} + * @throws NoSuchElementException + */ + public function getOuterHtml( + #[Language('XPath')] + $xpath + ): string { + return $this->executeJsOnXpath($xpath, 'return arguments[0].outerHTML;'); + } + + /** + * {@inheritdoc} + * @throws JsonException + * @throws NoSuchElementException + */ + public function getAttribute( + #[Language('XPath')] + $xpath, + $name + ): ?string { + $escapedName = json_encode((string)$name, JSON_THROW_ON_ERROR); + $script = "return arguments[0].getAttribute($escapedName)"; + + return $this->executeJsOnXpath($xpath, $script); + } + + /** + * {@inheritdoc} + * @throws NoSuchElementException + */ + public function getValue( + #[Language('XPath')] + $xpath + ) { + $element = $this->findElement($xpath); + $elementName = strtolower($element->getTagName() ?? ''); + $elementType = strtolower($element->getAttribute('type') ?? ''); + + // Getting the value of a checkbox returns its value if selected. + if ('input' === $elementName && 'checkbox' === $elementType) { + return $element->isSelected() ? $element->getAttribute('value') : null; + } + + if ('input' === $elementName && 'radio' === $elementType) { + $script = <<executeJsOnElement($element, $script); + } + + // Using $element->attribute('value') on a select only returns the first selected option + // even when it is a multiple select, so a custom retrieval is needed. + if ('select' === $elementName && $element->getAttribute('multiple')) { + $script = <<executeJsOnElement($element, $script); + } + + // use textarea.value rather than textarea.getAttribute(value) for chrome 91+ support + if ('textarea' === $elementName) { + $script = <<executeJsOnElement($element, $script); + } + + return $element->getAttribute('value'); + } + + /** + * {@inheritdoc} + * @throws DriverException + * @throws NoSuchElementException + * @throws JsonException + */ + public function setValue( + #[Language('XPath')] + $xpath, + $value + ): void { + $element = $this->findElement($xpath); + $elementName = strtolower($element->getTagName() ?? ''); + + switch ($elementName) { + case 'select': + if (is_array($value)) { + $this->deselectAllOptions($element); + foreach ($value as $option) { + $this->selectOptionOnElement($element, $option, true); + } + return; + } + $this->selectOptionOnElement($element, $value); + return; + + case 'textarea': + $element->clear(); + $element->sendKeys($value); + break; + + case 'input': + $elementType = strtolower($element->getAttribute('type') ?? ''); + switch ($elementType) { + case 'submit': + case 'image': + case 'button': + case 'reset': + $message = 'Cannot set value an element with XPath "%s" as it is not a select, textarea or textbox'; + throw new DriverException(sprintf($message, $xpath)); + + case 'color': + // one cannot simply type into a color field, nor clear it + $this->executeJsOnElement( + $element, + 'arguments[0].value = ' . json_encode($value, JSON_THROW_ON_ERROR) + ); + break; + + case 'date': + case 'time': + try { + $element->clear(); + $element->sendKeys($value); + } catch (WebDriverException $ex) { + // fix for Selenium 2 compatibility, since it's not able to clear these specific fields + $this->executeJsOnElement( + $element, + 'arguments[0].value = ' . json_encode($value, JSON_THROW_ON_ERROR) + ); + } + break; + + case 'checkbox': + if ($element->isSelected() xor $value) { + $this->clickOnElement($element); + } + return; + + case 'radio': + $this->selectRadioValue($element, $value); + return; + + case 'file': + // @todo - Check if this is correct way to upload files + $element->sendKeys($value); + // $element->postValue(['value' => [(string)$value]]); + return; + + default: + $element->clear(); + $element->sendKeys($value); + } + } + + $this->trigger($xpath, 'blur'); + } + + /** + * {@inheritdoc} + * @throws DriverException + * @throws NoSuchElementException + */ + public function check( + #[Language('XPath')] + $xpath + ): void { + $element = $this->findElement($xpath); + $this->ensureInputType($element, $xpath, 'checkbox', 'check'); + + if ($element->isSelected()) { + return; + } + + $this->clickOnElement($element); + } + + /** + * {@inheritdoc} + * @throws DriverException + * @throws NoSuchElementException + */ + public function uncheck( + #[Language('XPath')] + $xpath + ): void { + $element = $this->findElement($xpath); + $this->ensureInputType($element, $xpath, 'checkbox', 'uncheck'); + + if (!$element->isSelected()) { + return; + } + + $this->clickOnElement($element); + } + + /** + * {@inheritdoc} + * @throws NoSuchElementException + */ + public function isChecked( + #[Language('XPath')] + $xpath + ): bool { + return $this->findElement($xpath)->isSelected(); + } + + /** + * {@inheritdoc} + * @throws DriverException + * @throws NoSuchElementException + */ + public function selectOption( + #[Language('XPath')] + $xpath, + $value, + $multiple = false + ): void { + $element = $this->findElement($xpath); + $tagName = strtolower($element->getTagName() ?? ''); + + if ('input' === $tagName && 'radio' === strtolower($element->getAttribute('type') ?? '')) { + $this->selectRadioValue($element, $value); + + return; + } + + if ('select' === $tagName) { + $this->selectOptionOnElement($element, $value, $multiple); + + return; + } + + $message = 'Impossible to select an option on the element with XPath "%s" as it is not a select or radio input'; + throw new DriverException(sprintf($message, $xpath)); + } + + /** + * {@inheritdoc} + * @throws NoSuchElementException + */ + public function isSelected( + #[Language('XPath')] + $xpath + ): bool { + return $this->findElement($xpath)->isSelected(); + } + + /** + * {@inheritdoc} + * @throws NoSuchElementException + */ + public function click( + #[Language('XPath')] + $xpath + ): void { + $this->clickOnElement($this->findElement($xpath)); + } + + private function clickOnElement(RemoteWebElement $element): void + { + $element->getLocationOnScreenOnceScrolledIntoView(); + $element->click(); + } + + /** + * {@inheritdoc} + * @throws NoSuchElementException + */ + public function doubleClick( + #[Language('XPath')] + $xpath + ): void { + $this->doubleClickOnElement($this->findElement($xpath)); + } + + private function doubleClickOnElement(RemoteWebElement $element): void + { + $element->getLocationOnScreenOnceScrolledIntoView(); + $this->webDriver->getMouse()->doubleClick($element->getCoordinates()); + } + + /** + * {@inheritdoc} + * @throws NoSuchElementException + */ + public function rightClick( + #[Language('XPath')] + $xpath + ): void { + $this->rightClickOnElement($this->findElement($xpath)); + } + + /** + * {@inheritdoc} + * @throws DriverException + * @throws NoSuchElementException + */ + public function attachFile( + #[Language('XPath')] + $xpath, + #[Language('file-reference')] + $path + ): void { + $element = $this->findElement($xpath); + $this->ensureInputType($element, $xpath, 'file', 'attach a file on'); + + // @todo - Check if this is the correct way to upload files + $element->sendKeys($path); + } + + /** + * {@inheritdoc} + * @throws NoSuchElementException + */ + public function isVisible( + #[Language('XPath')] + $xpath + ): bool { + return $this->findElement($xpath)->isDisplayed(); + } + + /** + * {@inheritdoc} + * @throws NoSuchElementException + */ + public function mouseOver( + #[Language('XPath')] + $xpath + ): void { + $this->mouseOverElement($this->findElement($xpath)); + } + + /** + * {@inheritdoc} + * @throws NoSuchElementException + */ + public function focus( + #[Language('XPath')] + $xpath + ): void { + $this->trigger($xpath, 'focus'); + } + + /** + * {@inheritdoc} + * @throws NoSuchElementException + */ + public function blur( + #[Language('XPath')] + $xpath + ): void { + $this->trigger($xpath, 'blur'); + } + + /** + * {@inheritdoc} + * @throws JsonException + * @throws NoSuchElementException + */ + public function keyPress( + #[Language('XPath')] + $xpath, + $char, + $modifier = null + ): void { + $options = $this->charToSynOptions($char, $modifier); + $this->trigger($xpath, 'keypress', $options); + } + + /** + * {@inheritdoc} + * @throws JsonException + * @throws NoSuchElementException + */ + public function keyDown( + #[Language('XPath')] + $xpath, + $char, + $modifier = null + ): void { + $options = $this->charToSynOptions($char, $modifier); + $this->trigger($xpath, 'keydown', $options); + } + + /** + * {@inheritdoc} + * @throws JsonException + * @throws NoSuchElementException + */ + public function keyUp( + #[Language('XPath')] + $xpath, + $char, + $modifier = null + ): void { + $options = $this->charToSynOptions($char, $modifier); + $this->trigger($xpath, 'keyup', $options); + } + + /** + * {@inheritdoc} + * @throws NoSuchElementException + */ + public function dragTo( + #[Language('XPath')] + $sourceXpath, + #[Language('XPath')] + $destinationXpath + ): void { + $source = $this->findElement($sourceXpath); + $destination = $this->findElement($destinationXpath); + $this->webDriver->action()->dragAndDrop($source, $destination)->perform(); + } + + /** + * {@inheritdoc} + */ + public function executeScript( + #[Language('JavaScript')] + $script + ): void { + if (preg_match('/^function[\s(]/', $script ?? '')) { + $script = preg_replace('/;$/', '', $script ?? ''); + $script = '(' . $script . ')'; + } + + $this->webDriver->executeScript($script); + } + + /** + * {@inheritdoc} + */ + public function evaluateScript( + #[Language('JavaScript')] + $script + ) { + if (strncmp(ltrim((string)$script), 'return ', 7) !== 0) { + $script = "return $script;"; + } + + return $this->webDriver->executeScript($script); + } + + /** + * {@inheritdoc} + */ + public function wait( + $timeout, + #[Language('JavaScript')] + $condition + ): bool { + $start = microtime(true); + $end = $start + $timeout / 1000.0; + + do { + $result = $this->evaluateScript($condition); + usleep(100000); + } while (microtime(true) < $end && !$result); + + return (bool)$result; + } + + /** + * {@inheritdoc} + * @throws DriverException + */ + public function resizeWindow($width, $height, $name = null): void + { + $this->withWindow( + $name, + fn() => $this + ->webDriver + ->manage() + ->window() + ->setSize(new WebDriverDimension($width, $height)) + ); + } + + /** + * {@inheritdoc} + * @throws NoSuchElementException + */ + public function submitForm( + #[Language('XPath')] + $xpath + ): void { + $this->findElement($xpath)->submit(); + } + + /** + * {@inheritdoc} + * @throws DriverException + */ + public function maximizeWindow($name = null): void + { + $this->withWindow( + $name, + fn() => $this + ->webDriver + ->manage() + ->window() + ->maximize() + ); + } + + // + + // + + /** + * @api + */ + public function getBrowserName(): string + { + return $this->browserName; + } + + /** + * Sets the desired capabilities - called on construction. + * + * @see http://code.google.com/p/selenium/wiki/DesiredCapabilities + * + * @api + * @param array|DesiredCapabilities $desiredCapabilities + * @throws DriverException + */ + public function setDesiredCapabilities($desiredCapabilities): self + { + if ($this->isStarted()) { + throw new DriverException('Unable to set desiredCapabilities, the session has already started'); + } + + if (is_array($desiredCapabilities)) { + $desiredCapabilities = new DesiredCapabilities($desiredCapabilities); + } + + $this->desiredCapabilities = $desiredCapabilities; + + return $this; + } + + /** + * Gets the final desired capabilities (as sent to Selenium). + * + * @see http://code.google.com/p/selenium/wiki/DesiredCapabilities + * + * @api + */ + public function getDesiredCapabilities(): array + { + return $this->desiredCapabilities->toArray(); + } + + /** + * Globally press a key i.e. not typing into an element. + * + * @api + */ + public function globalKeyPress($char, $modifier = null): void + { + $keyboard = $this->webDriver->getKeyboard(); + if ($modifier) { + $keyboard->pressKey($modifier); + } + $keyboard->pressKey($char); + if ($modifier) { + $keyboard->releaseKey($modifier); + } + } + + /** + * Drag and drop an element by x,y pixels. + * + * @throws NoSuchElementException + * @api + */ + public function dragBy( + #[Language('XPath')] + $sourceXpath, + int $xOffset, + int $yOffset + ): void { + $source = $this->findElement($sourceXpath); + $this->webDriver->action()->dragAndDropBy($source, $xOffset, $yOffset)->perform(); + } + + /** + * Returns Session ID of WebDriver or `null`, when session not started yet. + * + * @return string|null + * @api + */ + public function getWebDriverSessionId(): ?string + { + return $this->isStarted() + ? $this->webDriver->getSessionID() + : null; + } + + // + + // + + /** + * Detect and assign appropriate browser capabilities + * + * @see https://github.com/SeleniumHQ/selenium/wiki/DesiredCapabilities + */ + private function initCapabilities(array $desiredCapabilities = []): DesiredCapabilities + { + // Build base capabilities + $browserName = $this->browserName; + if ($browserName && method_exists(DesiredCapabilities::class, $browserName)) { + /** @var DesiredCapabilities $caps */ + $caps = DesiredCapabilities::$browserName(); + } else { + $caps = new DesiredCapabilities(); + } + + // Set defaults + $defaults = array_merge( + self::DEFAULT_CAPABILITIES['default'], + self::DEFAULT_CAPABILITIES[$browserName] ?? [] + ); + foreach ($defaults as $key => $value) { + if (is_null($caps->getCapability($key))) { + $caps->setCapability($key, $value); + } + } + + // Merge in other requested types + foreach ($desiredCapabilities as $key => $value) { + $caps->setCapability($key, $value); + } + + return $caps; + } + + private function withSyn(): self + { + $hasSyn = $this->evaluateScript( + 'return window.syn !== undefined && window.syn.trigger !== undefined' + ); + + if (!$hasSyn) { + $synJs = file_get_contents(__DIR__ . '/../resources/syn.js'); + $this->webDriver->executeScript($synJs); + } + + return $this; + } + + /** + * @param int|string $char + * @throws JsonException + */ + private function charToSynOptions($char, ?string $modifier = null): string + { + if (is_int($char)) { + $charCode = $char; + $char = chr($charCode); + } else { + $charCode = ord($char); + } + + $options = [ + 'key' => $char, + 'which' => $charCode, + 'charCode' => $charCode, + 'keyCode' => $charCode, + ]; + + if ($modifier) { + $options[$modifier . 'Key'] = true; + } + + return json_encode($options, JSON_THROW_ON_ERROR); + } + + /** + * Executes JS on a given element - pass in a js script string and argument[0] will + * be replaced with a reference to the result of the $xpath query + * + * @param string $xpath the xpath to search with + * @param string $script the script to execute + * @param bool $sync whether to run the script synchronously (default is TRUE) + * + * @return mixed + * @throws NoSuchElementException + * @example $this->executeJsOnXpath($xpath, 'return argument[0].childNodes.length'); + */ + private function executeJsOnXpath( + #[Language('XPath')] + string $xpath, + #[Language('JavaScript')] + string $script, + bool $sync = true + ) { + return $this->executeJsOnElement($this->findElement($xpath), $script, $sync); + } + + /** + * Executes JS on a given element - pass in a js script string and argument[0] will contain a reference to the element + * + * @param RemoteWebElement $element the webdriver element + * @param string $script the script to execute + * @param Boolean $sync whether to run the script synchronously (default is TRUE) + * @return mixed + * @example $this->executeJsOnXpath($xpath, 'return argument[0].childNodes.length'); + */ + private function executeJsOnElement( + RemoteWebElement $element, + #[Language('JavaScript')] + string $script, + bool $sync = true + ) { + if ($sync) { + return $this->webDriver->executeScript($script, [$element]); + } + return $this->webDriver->executeAsyncScript($script, [$element]); + } + + /** + * @throws DriverException + */ + private function applyTimeouts(): void + { + try { + $timeouts = $this->webDriver->manage()->timeouts(); + foreach ($this->timeouts as $type => $param) { + switch ($type) { + case 'script': + $timeouts->setScriptTimeout($param / 1000); + break; + case 'implicit': + $timeouts->implicitlyWait($param / 1000); + break; + case 'page': + $timeouts->pageLoadTimeout($param / 1000); + break; + default: + throw new DriverException("Invalid timeout type: $type"); + } + } + } catch (Throwable $e) { + throw new DriverException("Error setting timeout: {$e->getMessage()}", 0, $e); + } + } + + /** + * @throws DriverException + */ + private function getWindowHandleFromName(string $name): string + { + // if name is actually prefixed window handle, just remove the prefix + if (strpos($name, self::W3C_WINDOW_HANDLE_PREFIX) === 0) { + return substr($name, strlen(self::W3C_WINDOW_HANDLE_PREFIX)); + } + + // ..otherwise check if any existing window has the specified name + + $origWindowHandle = $this->webDriver->getWindowHandle(); + + try { + foreach ($this->webDriver->getWindowHandles() as $handle) { + $this->webDriver->switchTo()->window($handle); + if ($this->evaluateScript('window.name') === $name) { + return $handle; + } + } + + throw new DriverException("Could not find handle of window named \"$name\""); + } finally { + $this->webDriver->switchTo()->window($origWindowHandle); + } + } + + private function rightClickOnElement(RemoteWebElement $element): void + { + $element->getLocationOnScreenOnceScrolledIntoView(); + $this->webDriver->getMouse()->contextClick($element->getCoordinates()); } + + private function mouseOverElement(RemoteWebElement $element): void + { + $element->getLocationOnScreenOnceScrolledIntoView(); + $this->webDriver->getMouse()->mouseMove($element->getCoordinates()); + } + + /** + * @return mixed + * @throws DriverException + */ + private function withWindow(?string $name, callable $callback) + { + $origName = $this->getWindowName(); + + try { + if ($origName !== $name) { + $this->switchToWindow($name); + } + + return $callback(); + } finally { + if ($origName !== $name) { + $this->switchToWindow($origName); + } + } + } + + /** + * @throws NoSuchElementException + */ + private function findElement( + #[Language('XPath')] + string $xpath, + RemoteWebElement $parent = null + ): RemoteWebElement { + $finder = WebDriverBy::xpath($xpath); + return $parent + ? $parent->findElement($finder) + : $this->webDriver->findElement($finder); + } + + /** + * @throws DriverException + */ + private function selectRadioValue(RemoteWebElement $element, string $value): void + { + // short-circuit when we already have the right button of the group to avoid XPath queries + if ($element->getAttribute('value') === $value) { + $element->click(); + + return; + } + + $name = $element->getAttribute('name'); + + if (!$name) { + throw new DriverException(sprintf('The radio button does not have the value "%s"', $value)); + } + + $formId = $element->getAttribute('form'); + + try { + $escapedName = $this->xpathEscaper->escapeLiteral($name); + $escapedValue = $this->xpathEscaper->escapeLiteral($value); + if (null !== $formId) { + $escapedFormId = $this->xpathEscaper->escapeLiteral($formId); + $input = $this->findElement( + <<<"XPATH" + //form[@id=$escapedFormId]//input[@type="radio" and not(@form) and @name=$escapedName and @value=$escapedValue] + | + //input[@type="radio" and @form=$escapedFormId and @name=$escapedName and @value=$escapedValue] + XPATH + ); + } else { + $input = $this->findElement( + "./ancestor::form//input[@type=\"radio\" and not(@form) and @name=$escapedName and @value=$escapedValue]", + $element + ); + } + } catch (NoSuchElementException $e) { + $message = sprintf('The radio group "%s" does not have an option "%s"', $name, $value); + + throw new DriverException($message, 0, $e); + } + + $input->click(); + } + + /** + * @throws NoSuchElementException + */ + private function selectOptionOnElement(RemoteWebElement $element, string $value, bool $multiple = false): void + { + $escapedValue = $this->xpathEscaper->escapeLiteral($value); + // The value of an option is the normalized version of its text when it has no value attribute + $optionQuery = sprintf( + './/option[@value = %s or (not(@value) and normalize-space(.) = %s)]', + $escapedValue, + $escapedValue + ); + $option = $this->findElement($optionQuery, $element); // Avoids selecting values from other select boxes + + if ($multiple || !$element->getAttribute('multiple')) { + if (!$option->isSelected()) { + $option->click(); + } + + return; + } + + // Deselect all options before selecting the new one + $this->deselectAllOptions($element); + $option->click(); + } + + /** + * Deselects all options of a multiple select + * + * Note: this implementation does not trigger a change event after deselecting the elements. + * + * @param RemoteWebElement $element + */ + private function deselectAllOptions(RemoteWebElement $element): void + { + $script = <<executeJsOnElement($element, $script); + } + + /** + * @throws DriverException + */ + private function ensureInputType( + RemoteWebElement $element, + #[Language('XPath')] + string $xpath, + string $type, + string $action + ): void { + if ($element->getTagName() !== 'input' || $element->getAttribute('type') !== $type) { + throw new DriverException( + "Impossible to $action the element with XPath \"$xpath\" as it is not a $type input" + ); + } + } + + /** + * @throws NoSuchElementException + */ + private function trigger( + #[Language('XPath')] + string $xpath, + string $event, + #[Language('JSON')] + string $options = '{}' + ): void { + $this->withSyn()->executeJsOnXpath($xpath, "window.syn.trigger(arguments[0], '$event', $options)"); + } + + // } diff --git a/tests/Custom/ChromeDesiredCapabilitiesTest.php b/tests/Custom/ChromeDesiredCapabilitiesTest.php new file mode 100644 index 0000000..dd29d02 --- /dev/null +++ b/tests/Custom/ChromeDesiredCapabilitiesTest.php @@ -0,0 +1,71 @@ +getSession()->getDriver(); + if ($driver instanceof WebdriverClassicDriver && $driver->getBrowserName() !== 'chrome') { + $this->markTestSkipped('This test only applies to Chrome'); + } + } + + protected function tearDown(): void + { + if ($this->getSession()->isStarted()) { + $this->getSession()->stop(); + } + + parent::tearDown(); + } + + public function testGetDesiredCapabilities(): void + { + $caps = [ + 'browserName' => 'chrome', + 'version' => '30', + 'platform' => 'ANY', + 'browserVersion' => '30', + 'browser' => 'chrome', + 'name' => 'PhpWebDriver Mink Driver Test', + 'deviceOrientation' => 'portrait', + 'deviceType' => 'tablet', + 'goog:chromeOptions' => [ + 'prefs' => [], + ], + ]; + + $driver = new WebdriverClassicDriver('chrome', $caps); + $this->assertNotEmpty($driver->getDesiredCapabilities(), 'desiredCapabilities empty'); + $this->assertIsArray($driver->getDesiredCapabilities()); + $this->assertEquals($caps, $driver->getDesiredCapabilities()); + } + + public function testSetDesiredCapabilities(): void + { + $this->expectException(DriverException::class); + $this->expectExceptionMessage('Unable to set desiredCapabilities, the session has already started'); + $caps = [ + 'browserName' => 'chrome', + 'version' => '30', + 'platform' => 'ANY', + 'browserVersion' => '30', + 'browser' => 'chrome', + 'name' => 'PhpWebDriver Mink Driver Test', + 'deviceOrientation' => 'portrait', + 'deviceType' => 'tablet', + ]; + $session = $this->getSession(); + $session->start(); + $driver = $session->getDriver(); + $driver->setDesiredCapabilities($caps); + } +} diff --git a/tests/Custom/FirefoxDesiredCapabilitiesTest.php b/tests/Custom/FirefoxDesiredCapabilitiesTest.php new file mode 100644 index 0000000..be6b60a --- /dev/null +++ b/tests/Custom/FirefoxDesiredCapabilitiesTest.php @@ -0,0 +1,71 @@ +getSession()->getDriver(); + if ($driver instanceof WebdriverClassicDriver && $driver->getBrowserName() !== 'firefox') { + $this->markTestSkipped('This test only applies to Firefox'); + } + } + + protected function tearDown(): void + { + if ($this->getSession()->isStarted()) { + $this->getSession()->stop(); + } + + parent::tearDown(); + } + + public function testGetDesiredCapabilities(): void + { + $caps = [ + 'browserName' => 'firefox', + 'version' => '30', + 'platform' => 'ANY', + 'browserVersion' => '30', + 'browser' => 'firefox', + 'name' => 'PhpWebDriver Mink Driver Test', + 'deviceOrientation' => 'portrait', + 'deviceType' => 'tablet', + 'moz:firefoxOptions' => [ + 'prefs' => [], + ], + ]; + + $driver = new WebdriverClassicDriver('firefox', $caps); + $this->assertNotEmpty($driver->getDesiredCapabilities(), 'desiredCapabilities empty'); + $this->assertIsArray($driver->getDesiredCapabilities()); + $this->assertEquals($caps, $driver->getDesiredCapabilities()); + } + + public function testSetDesiredCapabilities(): void + { + $this->expectException(DriverException::class); + $this->expectExceptionMessage('Unable to set desiredCapabilities, the session has already started'); + $caps = [ + 'browserName' => 'firefox', + 'version' => '30', + 'platform' => 'ANY', + 'browserVersion' => '30', + 'browser' => 'firefox', + 'name' => 'PhpWebDriver Mink Driver Test', + 'deviceOrientation' => 'portrait', + 'deviceType' => 'tablet', + ]; + $session = $this->getSession(); + $session->start(); + $driver = $session->getDriver(); + $driver->setDesiredCapabilities($caps); + } +} diff --git a/tests/Custom/TimeoutTest.php b/tests/Custom/TimeoutTest.php new file mode 100644 index 0000000..59ba06d --- /dev/null +++ b/tests/Custom/TimeoutTest.php @@ -0,0 +1,59 @@ +getSession(); + + // Stop the session instead of only resetting it, as timeouts are not reset (they are configuring the session itself) + if ($session->isStarted()) { + $session->stop(); + } + + // Reset the array of timeouts to avoid impacting other tests + $session->getDriver()->setTimeouts([]); + + parent::resetSessions(); + } + + public function testInvalidTimeoutSettingThrowsException(): void + { + $this->expectException(DriverException::class); + $this->getSession()->start(); + + $this->getSession()->getDriver()->setTimeouts(['invalid' => 0]); + } + + public function testShortTimeoutDoesNotWaitForElementToAppear(): void + { + $this->getSession()->getDriver()->setTimeouts(['implicit' => 0]); + + $this->getSession()->visit($this->pathTo('/js_test.html')); + + $this->findById('waitable')->click(); + + $element = $this->getSession()->getPage()->find('css', '#waitable > div'); + + $this->assertNull($element); + } + + public function testLongTimeoutWaitsForElementToAppear(): void + { + $this->getSession()->getDriver()->setTimeouts(['implicit' => 5000]); + + $this->getSession()->visit($this->pathTo('/js_test.html')); + $this->findById('waitable')->click(); + $element = $this->getSession()->getPage()->find('css', '#waitable > div'); + + $this->assertNotNull($element); + } +} diff --git a/tests/Custom/WebDriverTest.php b/tests/Custom/WebDriverTest.php new file mode 100644 index 0000000..57b954f --- /dev/null +++ b/tests/Custom/WebDriverTest.php @@ -0,0 +1,32 @@ +getSession()->start(); + } + + protected function tearDown(): void + { + $this->getSession()->stop(); + + parent::tearDown(); + } + + public function testGetWebDriverSessionId(): void + { + $driver = $this->getSession()->getDriver(); + $this->assertNotEmpty($driver->getWebDriverSessionId(), 'Started session has an ID'); + + $driver = new WebdriverClassicDriver(); + $this->assertNull($driver->getWebDriverSessionId(), 'Not started session don\'t have an ID'); + } +} diff --git a/tests/Custom/WindowNameTest.php b/tests/Custom/WindowNameTest.php new file mode 100644 index 0000000..0293cef --- /dev/null +++ b/tests/Custom/WindowNameTest.php @@ -0,0 +1,33 @@ +getSession()->start(); + } + + protected function tearDown(): void + { + $this->getSession()->stop(); + + parent::tearDown(); + } + + public function testWindowNames(): void + { + $windowNames = $this->getSession()->getWindowNames(); + $this->assertArrayHasKey(0, $windowNames); + + $windowName = $this->getSession()->getWindowName(); + + $this->assertIsString($windowName); + $this->assertContains($windowName, $windowNames, 'The current window name is one of the available window names.'); + } +} diff --git a/tests/WebdriverClassicConfig.php b/tests/WebdriverClassicConfig.php index 2bf8c52..aa7af4c 100644 --- a/tests/WebdriverClassicConfig.php +++ b/tests/WebdriverClassicConfig.php @@ -2,8 +2,9 @@ namespace Mink\WebdriverClassDriver\Tests; -use Behat\Mink\Driver\DriverInterface; use Behat\Mink\Tests\Driver\AbstractConfig; +use Behat\Mink\Tests\Driver\Basic\BasicAuthTest; +use Behat\Mink\Tests\Driver\Js\WindowTest; use Mink\WebdriverClassDriver\WebdriverClassicDriver; class WebdriverClassicConfig extends AbstractConfig @@ -13,22 +14,44 @@ public static function getInstance(): self return new self(); } - public function createDriver(): DriverInterface + /** + * {@inheritdoc} + */ + public function createDriver() { - return new WebdriverClassicDriver(); + $browser = getenv('WEB_FIXTURES_BROWSER') ?: null; + $seleniumHost = $_SERVER['DRIVER_URL']; + + return new WebdriverClassicDriver($browser, null, $seleniumHost); + } + + public function mapRemoteFilePath($file): string + { + if (!isset($_SERVER['TEST_MACHINE_BASE_PATH'])) { + $_SERVER['TEST_MACHINE_BASE_PATH'] = realpath( + dirname(__DIR__) . '/vendor/mink/driver-testsuite/web-fixtures' + ) . DIRECTORY_SEPARATOR; + } + + return parent::mapRemoteFilePath($file); } public function skipMessage($testCase, $test): ?string { - /** @phpstan-ignore-next-line */ - if (true) { - return 'TODO: implement the initial driver'; + if ($testCase === BasicAuthTest::class && $test === 'testBasicAuthInUrl') { + return 'This driver has mixed support for basic auth modals, depending on browser type and selenium version.'; + } + + if ($testCase === WindowTest::class && $test === 'testWindowMaximize') { + return 'There is no sane way to find if a window is indeed maximized; this test is quite broken.'; } - /** @phpstan-ignore-next-line */ return parent::skipMessage($testCase, $test); } + /** + * {@inheritdoc} + */ protected function supportsCss(): bool { return true; diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..b455e12 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,18 @@ +start(); + +register_shutdown_function( + static fn() => $minkTestServer->stop() +); From ad14001703e6332558c1afe5507729a91b3dbf3f Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Mon, 12 Jun 2023 22:42:48 +0200 Subject: [PATCH 02/47] Enable phpunit bridge --- phpunit.xml.dist | 2 -- 1 file changed, 2 deletions(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 83494c6..a4d4c23 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -29,8 +29,6 @@ - - From 6ede6584018e791dce278963a650f43ca9a58484 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Mon, 12 Jun 2023 22:44:35 +0200 Subject: [PATCH 03/47] Reverted license change --- LICENSE.md => LICENSE | 0 README.md | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename LICENSE.md => LICENSE (100%) diff --git a/LICENSE.md b/LICENSE similarity index 100% rename from LICENSE.md rename to LICENSE diff --git a/README.md b/README.md index 20a9b84..8ca0211 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Latest Unstable Version](https://poser.pugx.org/mink/webdriver-classic-driver/v/unstable)](https://packagist.org/packages/mink/webdriver-classic-driver) [![Total Downloads](https://poser.pugx.org/mink/webdriver-classic-driver/downloads)](https://packagist.org/packages/mink/webdriver-classic-driver) [![CI](https://github.com/minkphp/webdriver-classic-driver/actions/workflows/ci.yml/badge.svg)](https://github.com/minkphp/webdriver-classic-driver/actions/workflows/ci.yml) -[![License](https://poser.pugx.org/mink/webdriver-classic-driver/license)](https://github.com/minkphp/webdriver-classic-driver/blob/main/LICENSE.md) +[![License](https://poser.pugx.org/mink/webdriver-classic-driver/license)](https://github.com/minkphp/webdriver-classic-driver/blob/main/LICENSE) [![codecov](https://codecov.io/gh/minkphp/webdriver-classic-driver/branch/main/graph/badge.svg?token=11hgqXqod9)](https://codecov.io/gh/minkphp/webdriver-classic-driver) ## Installation From c48e7fce7533b0a9567f4e153db18e9b18f6659b Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Mon, 12 Jun 2023 22:53:54 +0200 Subject: [PATCH 04/47] Improve workflow --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be0978f..f5c391d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,9 +60,7 @@ jobs: with: coverage: "xdebug" php-version: "${{ matrix.php }}" - tools: composer ini-file: development - ini-values: date.timezone=Europe/Berlin, error_reporting=-1, display_errors=On env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From d4ca8309495bad42315d3d1d910c2b3ddcf63219 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Mon, 12 Jun 2023 23:55:01 +0200 Subject: [PATCH 05/47] Fix deprecation notice --- tests/WebdriverClassicConfig.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/WebdriverClassicConfig.php b/tests/WebdriverClassicConfig.php index aa7af4c..4a4f310 100644 --- a/tests/WebdriverClassicConfig.php +++ b/tests/WebdriverClassicConfig.php @@ -2,6 +2,7 @@ namespace Mink\WebdriverClassDriver\Tests; +use Behat\Mink\Driver\DriverInterface; use Behat\Mink\Tests\Driver\AbstractConfig; use Behat\Mink\Tests\Driver\Basic\BasicAuthTest; use Behat\Mink\Tests\Driver\Js\WindowTest; @@ -17,7 +18,7 @@ public static function getInstance(): self /** * {@inheritdoc} */ - public function createDriver() + public function createDriver(): DriverInterface { $browser = getenv('WEB_FIXTURES_BROWSER') ?: null; $seleniumHost = $_SERVER['DRIVER_URL']; From 40f600ce97d12f029fd0f50c6c0f3527610b3410 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Tue, 13 Jun 2023 01:40:44 +0200 Subject: [PATCH 06/47] Remove unused flows/logic --- src/WebdriverClassicDriver.php | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index d7babee..3d28afe 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -1078,7 +1078,6 @@ private function charToSynOptions($char, ?string $modifier = null): string * * @param string $xpath the xpath to search with * @param string $script the script to execute - * @param bool $sync whether to run the script synchronously (default is TRUE) * * @return mixed * @throws NoSuchElementException @@ -1088,10 +1087,9 @@ private function executeJsOnXpath( #[Language('XPath')] string $xpath, #[Language('JavaScript')] - string $script, - bool $sync = true + string $script ) { - return $this->executeJsOnElement($this->findElement($xpath), $script, $sync); + return $this->executeJsOnElement($this->findElement($xpath), $script); } /** @@ -1099,20 +1097,15 @@ private function executeJsOnXpath( * * @param RemoteWebElement $element the webdriver element * @param string $script the script to execute - * @param Boolean $sync whether to run the script synchronously (default is TRUE) * @return mixed * @example $this->executeJsOnXpath($xpath, 'return argument[0].childNodes.length'); */ private function executeJsOnElement( RemoteWebElement $element, #[Language('JavaScript')] - string $script, - bool $sync = true + string $script ) { - if ($sync) { - return $this->webDriver->executeScript($script, [$element]); - } - return $this->webDriver->executeAsyncScript($script, [$element]); + return $this->webDriver->executeScript($script, [$element]); } /** @@ -1183,10 +1176,9 @@ private function mouseOverElement(RemoteWebElement $element): void } /** - * @return mixed * @throws DriverException */ - private function withWindow(?string $name, callable $callback) + private function withWindow(?string $name, callable $callback): void { $origName = $this->getWindowName(); @@ -1195,7 +1187,7 @@ private function withWindow(?string $name, callable $callback) $this->switchToWindow($name); } - return $callback(); + $callback(); } finally { if ($origName !== $name) { $this->switchToWindow($origName); From 0130058ab5edd4e2fc53f79171a3a020312d971d Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Tue, 13 Jun 2023 02:02:59 +0200 Subject: [PATCH 07/47] Fix exception throwing conformance --- src/WebdriverClassicDriver.php | 122 +++++++++++++++++---------------- 1 file changed, 64 insertions(+), 58 deletions(-) diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index 3d28afe..82ea8a6 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -332,7 +332,7 @@ public function findElementXpaths( /** * {@inheritdoc} - * @throws NoSuchElementException + * @throws DriverException */ public function getTagName( #[Language('XPath')] @@ -343,7 +343,7 @@ public function getTagName( /** * {@inheritdoc} - * @throws NoSuchElementException + * @throws DriverException */ public function getText( #[Language('XPath')] @@ -354,7 +354,7 @@ public function getText( /** * {@inheritdoc} - * @throws NoSuchElementException + * @throws DriverException */ public function getHtml( #[Language('XPath')] @@ -365,7 +365,7 @@ public function getHtml( /** * {@inheritdoc} - * @throws NoSuchElementException + * @throws DriverException */ public function getOuterHtml( #[Language('XPath')] @@ -376,15 +376,14 @@ public function getOuterHtml( /** * {@inheritdoc} - * @throws JsonException - * @throws NoSuchElementException + * @throws DriverException */ public function getAttribute( #[Language('XPath')] $xpath, $name ): ?string { - $escapedName = json_encode((string)$name, JSON_THROW_ON_ERROR); + $escapedName = $this->jsonEncode($name, 'get attribute', 'attribute name'); $script = "return arguments[0].getAttribute($escapedName)"; return $this->executeJsOnXpath($xpath, $script); @@ -392,7 +391,7 @@ public function getAttribute( /** * {@inheritdoc} - * @throws NoSuchElementException + * @throws DriverException */ public function getValue( #[Language('XPath')] @@ -465,8 +464,6 @@ public function getValue( /** * {@inheritdoc} * @throws DriverException - * @throws NoSuchElementException - * @throws JsonException */ public function setValue( #[Language('XPath')] @@ -507,7 +504,7 @@ public function setValue( // one cannot simply type into a color field, nor clear it $this->executeJsOnElement( $element, - 'arguments[0].value = ' . json_encode($value, JSON_THROW_ON_ERROR) + 'arguments[0].value = ' . $this->jsonEncode($value, 'set value', 'value') ); break; @@ -520,7 +517,7 @@ public function setValue( // fix for Selenium 2 compatibility, since it's not able to clear these specific fields $this->executeJsOnElement( $element, - 'arguments[0].value = ' . json_encode($value, JSON_THROW_ON_ERROR) + 'arguments[0].value = ' . $this->jsonEncode($value, 'set value', 'value') ); } break; @@ -553,7 +550,6 @@ public function setValue( /** * {@inheritdoc} * @throws DriverException - * @throws NoSuchElementException */ public function check( #[Language('XPath')] @@ -572,7 +568,6 @@ public function check( /** * {@inheritdoc} * @throws DriverException - * @throws NoSuchElementException */ public function uncheck( #[Language('XPath')] @@ -590,7 +585,7 @@ public function uncheck( /** * {@inheritdoc} - * @throws NoSuchElementException + * @throws DriverException */ public function isChecked( #[Language('XPath')] @@ -602,7 +597,6 @@ public function isChecked( /** * {@inheritdoc} * @throws DriverException - * @throws NoSuchElementException */ public function selectOption( #[Language('XPath')] @@ -631,7 +625,7 @@ public function selectOption( /** * {@inheritdoc} - * @throws NoSuchElementException + * @throws DriverException */ public function isSelected( #[Language('XPath')] @@ -642,7 +636,7 @@ public function isSelected( /** * {@inheritdoc} - * @throws NoSuchElementException + * @throws DriverException */ public function click( #[Language('XPath')] @@ -651,15 +645,9 @@ public function click( $this->clickOnElement($this->findElement($xpath)); } - private function clickOnElement(RemoteWebElement $element): void - { - $element->getLocationOnScreenOnceScrolledIntoView(); - $element->click(); - } - /** * {@inheritdoc} - * @throws NoSuchElementException + * @throws DriverException */ public function doubleClick( #[Language('XPath')] @@ -668,15 +656,9 @@ public function doubleClick( $this->doubleClickOnElement($this->findElement($xpath)); } - private function doubleClickOnElement(RemoteWebElement $element): void - { - $element->getLocationOnScreenOnceScrolledIntoView(); - $this->webDriver->getMouse()->doubleClick($element->getCoordinates()); - } - /** * {@inheritdoc} - * @throws NoSuchElementException + * @throws DriverException */ public function rightClick( #[Language('XPath')] @@ -688,7 +670,6 @@ public function rightClick( /** * {@inheritdoc} * @throws DriverException - * @throws NoSuchElementException */ public function attachFile( #[Language('XPath')] @@ -705,7 +686,7 @@ public function attachFile( /** * {@inheritdoc} - * @throws NoSuchElementException + * @throws DriverException */ public function isVisible( #[Language('XPath')] @@ -716,7 +697,7 @@ public function isVisible( /** * {@inheritdoc} - * @throws NoSuchElementException + * @throws DriverException */ public function mouseOver( #[Language('XPath')] @@ -727,7 +708,7 @@ public function mouseOver( /** * {@inheritdoc} - * @throws NoSuchElementException + * @throws DriverException */ public function focus( #[Language('XPath')] @@ -738,7 +719,7 @@ public function focus( /** * {@inheritdoc} - * @throws NoSuchElementException + * @throws DriverException */ public function blur( #[Language('XPath')] @@ -749,8 +730,7 @@ public function blur( /** * {@inheritdoc} - * @throws JsonException - * @throws NoSuchElementException + * @throws DriverException */ public function keyPress( #[Language('XPath')] @@ -764,8 +744,7 @@ public function keyPress( /** * {@inheritdoc} - * @throws JsonException - * @throws NoSuchElementException + * @throws DriverException */ public function keyDown( #[Language('XPath')] @@ -779,8 +758,7 @@ public function keyDown( /** * {@inheritdoc} - * @throws JsonException - * @throws NoSuchElementException + * @throws DriverException */ public function keyUp( #[Language('XPath')] @@ -794,7 +772,7 @@ public function keyUp( /** * {@inheritdoc} - * @throws NoSuchElementException + * @throws DriverException */ public function dragTo( #[Language('XPath')] @@ -873,7 +851,7 @@ public function resizeWindow($width, $height, $name = null): void /** * {@inheritdoc} - * @throws NoSuchElementException + * @throws DriverException */ public function submitForm( #[Language('XPath')] @@ -966,7 +944,7 @@ public function globalKeyPress($char, $modifier = null): void /** * Drag and drop an element by x,y pixels. * - * @throws NoSuchElementException + * @throws DriverException * @api */ public function dragBy( @@ -1047,7 +1025,7 @@ private function withSyn(): self /** * @param int|string $char - * @throws JsonException + * @throws DriverException */ private function charToSynOptions($char, ?string $modifier = null): string { @@ -1069,7 +1047,7 @@ private function charToSynOptions($char, ?string $modifier = null): string $options[$modifier . 'Key'] = true; } - return json_encode($options, JSON_THROW_ON_ERROR); + return $this->jsonEncode($options, 'build Syn payload', 'options'); } /** @@ -1080,7 +1058,7 @@ private function charToSynOptions($char, ?string $modifier = null): string * @param string $script the script to execute * * @return mixed - * @throws NoSuchElementException + * @throws DriverException * @example $this->executeJsOnXpath($xpath, 'return argument[0].childNodes.length'); */ private function executeJsOnXpath( @@ -1163,6 +1141,18 @@ private function getWindowHandleFromName(string $name): string } } + private function clickOnElement(RemoteWebElement $element): void + { + $element->getLocationOnScreenOnceScrolledIntoView(); + $element->click(); + } + + private function doubleClickOnElement(RemoteWebElement $element): void + { + $element->getLocationOnScreenOnceScrolledIntoView(); + $this->webDriver->getMouse()->doubleClick($element->getCoordinates()); + } + private function rightClickOnElement(RemoteWebElement $element): void { $element->getLocationOnScreenOnceScrolledIntoView(); @@ -1196,17 +1186,21 @@ private function withWindow(?string $name, callable $callback): void } /** - * @throws NoSuchElementException + * @throws DriverException */ private function findElement( #[Language('XPath')] string $xpath, RemoteWebElement $parent = null ): RemoteWebElement { - $finder = WebDriverBy::xpath($xpath); - return $parent - ? $parent->findElement($finder) - : $this->webDriver->findElement($finder); + try { + $finder = WebDriverBy::xpath($xpath); + return $parent + ? $parent->findElement($finder) + : $this->webDriver->findElement($finder); + } catch (Throwable $e) { + throw new DriverException("Failed to find element: {$e->getMessage()}", 0, $e); + } } /** @@ -1247,9 +1241,8 @@ private function selectRadioValue(RemoteWebElement $element, string $value): voi $element ); } - } catch (NoSuchElementException $e) { + } catch (DriverException $e) { $message = sprintf('The radio group "%s" does not have an option "%s"', $name, $value); - throw new DriverException($message, 0, $e); } @@ -1257,7 +1250,7 @@ private function selectRadioValue(RemoteWebElement $element, string $value): voi } /** - * @throws NoSuchElementException + * @throws DriverException */ private function selectOptionOnElement(RemoteWebElement $element, string $value, bool $multiple = false): void { @@ -1321,7 +1314,7 @@ private function ensureInputType( } /** - * @throws NoSuchElementException + * @throws DriverException */ private function trigger( #[Language('XPath')] @@ -1333,5 +1326,18 @@ private function trigger( $this->withSyn()->executeJsOnXpath($xpath, "window.syn.trigger(arguments[0], '$event', $options)"); } + /** + * @param mixed $value + * @throws DriverException + */ + private function jsonEncode($value, string $action, string $field): string + { + try { + return json_encode($value, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new DriverException("Cannot $action, $field not serializable: {$e->getMessage()}", 0, $e); + } + } + // } From b9f6b6f9a606f83aa909ad27df90712b53661dc1 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Tue, 13 Jun 2023 02:07:24 +0200 Subject: [PATCH 08/47] Remove started prop" --- src/WebdriverClassicDriver.php | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index 82ea8a6..4475e96 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -55,8 +55,6 @@ class WebdriverClassicDriver extends CoreDriver private DesiredCapabilities $desiredCapabilities; - private bool $started = false; - private array $timeouts = []; private Escaper $xpathEscaper; @@ -88,13 +86,16 @@ public function __construct( */ public function start(): void { + if ($this->isStarted()) { + throw new DriverException('Driver has already been started'); + } + try { $this->webDriver = RemoteWebDriver::create($this->webDriverHost, $this->desiredCapabilities); $this->applyTimeouts(); $this->initialWindowName = $this->getWindowName(); - $this->started = true; } catch (Throwable $e) { - throw new DriverException("Could not open connection: {$e->getMessage()}", 0, $e); + throw new DriverException("Could not start driver: {$e->getMessage()}", 0, $e); } } @@ -119,7 +120,7 @@ public function setTimeouts(array $timeouts): void */ public function isStarted(): bool { - return $this->started; + return $this->webDriver !== null; } /** @@ -129,12 +130,12 @@ public function isStarted(): bool public function stop(): void { if (!$this->webDriver) { - throw new DriverException('Could not connect to a Selenium / WebDriver server'); + throw new DriverException('Driver has not been started'); } try { - $this->started = false; $this->webDriver->quit(); + $this->webDriver = null; } catch (Throwable $e) { throw new DriverException('Could not close connection', 0, $e); } From e2c230d28c6e1486c0f3a2a0126c0029d896ed1f Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Tue, 13 Jun 2023 02:15:42 +0200 Subject: [PATCH 09/47] Guard webDriver prop access --- src/WebdriverClassicDriver.php | 152 +++++++++++++++++++++------------ 1 file changed, 99 insertions(+), 53 deletions(-) diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index 4475e96..1b1f598 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -134,7 +134,7 @@ public function stop(): void } try { - $this->webDriver->quit(); + $this->getWebDriver()->quit(); $this->webDriver = null; } catch (Throwable $e) { throw new DriverException('Could not close connection', 0, $e); @@ -143,50 +143,56 @@ public function stop(): void /** * {@inheritdoc} + * @throws DriverException */ public function reset(): void { - $this->webDriver->manage()->deleteAllCookies(); + $this->getWebDriver()->manage()->deleteAllCookies(); } /** * {@inheritdoc} + * @throws DriverException */ public function visit($url): void { - $this->webDriver->navigate()->to($url); + $this->getWebDriver()->navigate()->to($url); } /** * {@inheritdoc} + * @throws DriverException */ public function getCurrentUrl(): string { - return $this->webDriver->getCurrentURL(); + return $this->getWebDriver()->getCurrentURL(); } /** * {@inheritdoc} + * @throws DriverException */ public function reload(): void { - $this->webDriver->navigate()->refresh(); + $this->getWebDriver()->navigate()->refresh(); } /** * {@inheritdoc} + * @throws DriverException */ public function forward(): void { - $this->webDriver->navigate()->forward(); + $this->getWebDriver()->navigate()->forward(); } /** * {@inheritdoc} + * @throws DriverException */ public function back(): void { - $this->webDriver->navigate()->back(); + $this->getWebDriver()->navigate()->back(); } /** @@ -203,33 +209,35 @@ public function switchToWindow($name = null): void $name = $this->getWindowHandleFromName($name); } - $this->webDriver->switchTo()->window($name); + $this->getWebDriver()->switchTo()->window($name); } /** * {@inheritdoc} + * @throws DriverException */ public function switchToIFrame($name = null): void { $frameQuery = $name; - if ($name && is_string($name) && $this->webDriver->isW3cCompliant()) { + if ($name && is_string($name) && $this->getWebDriver()->isW3cCompliant()) { try { - $frameQuery = $this->webDriver->findElement(WebDriverBy::id($name)); + $frameQuery = $this->getWebDriver()->findElement(WebDriverBy::id($name)); } catch (NoSuchElementException $e) { - $frameQuery = $this->webDriver->findElement(WebDriverBy::name($name)); + $frameQuery = $this->getWebDriver()->findElement(WebDriverBy::name($name)); } } - $this->webDriver->switchTo()->frame($frameQuery); + $this->getWebDriver()->switchTo()->frame($frameQuery); } /** * {@inheritdoc} + * @throws DriverException */ public function setCookie($name, $value = null): void { if (null === $value) { - $this->webDriver->manage()->deleteCookieNamed($name); + $this->getWebDriver()->manage()->deleteCookieNamed($name); return; } @@ -240,16 +248,17 @@ public function setCookie($name, $value = null): void 'secure' => false, ]; - $this->webDriver->manage()->addCookie($cookieArray); + $this->getWebDriver()->manage()->addCookie($cookieArray); } /** * {@inheritdoc} + * @throws DriverException */ public function getCookie($name): ?string { try { - $result = $this->webDriver->manage()->getCookieNamed($name); + $result = $this->getWebDriver()->manage()->getCookieNamed($name); } catch (NoSuchCookieException $e) { $result = null; } @@ -267,48 +276,52 @@ public function getCookie($name): ?string /** * {@inheritdoc} + * @throws DriverException */ public function getContent(): string { - return $this->webDriver->getPageSource(); + return $this->getWebDriver()->getPageSource(); } /** * {@inheritdoc} + * @throws DriverException */ public function getScreenshot(): string { - return $this->webDriver->takeScreenshot(); + return $this->getWebDriver()->takeScreenshot(); } /** * {@inheritdoc} + * @throws DriverException */ public function getWindowNames(): array { - $origWindow = $this->webDriver->getWindowHandle(); + $origWindow = $this->getWebDriver()->getWindowHandle(); try { $result = []; - foreach ($this->webDriver->getWindowHandles() as $tempWindow) { - $this->webDriver->switchTo()->window($tempWindow); + foreach ($this->getWebDriver()->getWindowHandles() as $tempWindow) { + $this->getWebDriver()->switchTo()->window($tempWindow); $result[] = $this->getWindowName(); } return $result; } finally { - $this->webDriver->switchTo()->window($origWindow); + $this->getWebDriver()->switchTo()->window($origWindow); } } /** * {@inheritdoc} + * @throws DriverException */ public function getWindowName(): string { $name = (string)$this->evaluateScript('window.name'); if ($name === '') { - $name = self::W3C_WINDOW_HANDLE_PREFIX . $this->webDriver->getWindowHandle(); + $name = self::W3C_WINDOW_HANDLE_PREFIX . $this->getWebDriver()->getWindowHandle(); } return $name; @@ -316,12 +329,13 @@ public function getWindowName(): string /** * {@inheritdoc} + * @throws DriverException */ public function findElementXpaths( #[Language('XPath')] $xpath ): array { - $nodes = $this->webDriver->findElements(WebDriverBy::xpath($xpath)); + $nodes = $this->getWebDriver()->findElements(WebDriverBy::xpath($xpath)); $elements = []; foreach ($nodes as $i => $node) { @@ -783,11 +797,12 @@ public function dragTo( ): void { $source = $this->findElement($sourceXpath); $destination = $this->findElement($destinationXpath); - $this->webDriver->action()->dragAndDrop($source, $destination)->perform(); + $this->getWebDriver()->action()->dragAndDrop($source, $destination)->perform(); } /** * {@inheritdoc} + * @throws DriverException */ public function executeScript( #[Language('JavaScript')] @@ -798,11 +813,12 @@ public function executeScript( $script = '(' . $script . ')'; } - $this->webDriver->executeScript($script); + $this->getWebDriver()->executeScript($script); } /** * {@inheritdoc} + * @throws DriverException */ public function evaluateScript( #[Language('JavaScript')] @@ -812,11 +828,12 @@ public function evaluateScript( $script = "return $script;"; } - return $this->webDriver->executeScript($script); + return $this->getWebDriver()->executeScript($script); } /** * {@inheritdoc} + * @throws DriverException */ public function wait( $timeout, @@ -882,6 +899,8 @@ public function maximizeWindow($name = null): void // /** + * Returns the browser name. + * * @api */ public function getBrowserName(): string @@ -889,6 +908,20 @@ public function getBrowserName(): string return $this->browserName; } + /** + * Returns Session ID of WebDriver or `null`, when session not started yet. + * + * @api + * @return string|null + * @throws DriverException + */ + public function getWebDriverSessionId(): ?string + { + return $this->isStarted() + ? $this->getWebDriver()->getSessionID() + : null; + } + /** * Sets the desired capabilities - called on construction. * @@ -929,10 +962,11 @@ public function getDesiredCapabilities(): array * Globally press a key i.e. not typing into an element. * * @api + * @throws DriverException */ public function globalKeyPress($char, $modifier = null): void { - $keyboard = $this->webDriver->getKeyboard(); + $keyboard = $this->getWebDriver()->getKeyboard(); if ($modifier) { $keyboard->pressKey($modifier); } @@ -945,8 +979,8 @@ public function globalKeyPress($char, $modifier = null): void /** * Drag and drop an element by x,y pixels. * - * @throws DriverException * @api + * @throws DriverException */ public function dragBy( #[Language('XPath')] @@ -955,25 +989,24 @@ public function dragBy( int $yOffset ): void { $source = $this->findElement($sourceXpath); - $this->webDriver->action()->dragAndDropBy($source, $xOffset, $yOffset)->perform(); + $this->getWebDriver()->action()->dragAndDropBy($source, $xOffset, $yOffset)->perform(); } + // + + // + /** - * Returns Session ID of WebDriver or `null`, when session not started yet. - * - * @return string|null - * @api + * @throws DriverException */ - public function getWebDriverSessionId(): ?string + private function getWebDriver(): RemoteWebDriver { - return $this->isStarted() - ? $this->webDriver->getSessionID() - : null; - } - - // + if (!$this->isStarted()) { + throw new DriverException('Driver has not been started'); + } - // + return $this->webDriver; + } /** * Detect and assign appropriate browser capabilities @@ -1010,6 +1043,9 @@ private function initCapabilities(array $desiredCapabilities = []): DesiredCapab return $caps; } + /** + * @throws DriverException + */ private function withSyn(): self { $hasSyn = $this->evaluateScript( @@ -1018,7 +1054,7 @@ private function withSyn(): self if (!$hasSyn) { $synJs = file_get_contents(__DIR__ . '/../resources/syn.js'); - $this->webDriver->executeScript($synJs); + $this->getWebDriver()->executeScript($synJs); } return $this; @@ -1077,6 +1113,7 @@ private function executeJsOnXpath( * @param RemoteWebElement $element the webdriver element * @param string $script the script to execute * @return mixed + * @throws DriverException * @example $this->executeJsOnXpath($xpath, 'return argument[0].childNodes.length'); */ private function executeJsOnElement( @@ -1084,7 +1121,7 @@ private function executeJsOnElement( #[Language('JavaScript')] string $script ) { - return $this->webDriver->executeScript($script, [$element]); + return $this->getWebDriver()->executeScript($script, [$element]); } /** @@ -1093,7 +1130,7 @@ private function executeJsOnElement( private function applyTimeouts(): void { try { - $timeouts = $this->webDriver->manage()->timeouts(); + $timeouts = $this->getWebDriver()->manage()->timeouts(); foreach ($this->timeouts as $type => $param) { switch ($type) { case 'script': @@ -1126,11 +1163,11 @@ private function getWindowHandleFromName(string $name): string // ..otherwise check if any existing window has the specified name - $origWindowHandle = $this->webDriver->getWindowHandle(); + $origWindowHandle = $this->getWebDriver()->getWindowHandle(); try { - foreach ($this->webDriver->getWindowHandles() as $handle) { - $this->webDriver->switchTo()->window($handle); + foreach ($this->getWebDriver()->getWindowHandles() as $handle) { + $this->getWebDriver()->switchTo()->window($handle); if ($this->evaluateScript('window.name') === $name) { return $handle; } @@ -1138,7 +1175,7 @@ private function getWindowHandleFromName(string $name): string throw new DriverException("Could not find handle of window named \"$name\""); } finally { - $this->webDriver->switchTo()->window($origWindowHandle); + $this->getWebDriver()->switchTo()->window($origWindowHandle); } } @@ -1148,22 +1185,31 @@ private function clickOnElement(RemoteWebElement $element): void $element->click(); } + /** + * @throws DriverException + */ private function doubleClickOnElement(RemoteWebElement $element): void { $element->getLocationOnScreenOnceScrolledIntoView(); - $this->webDriver->getMouse()->doubleClick($element->getCoordinates()); + $this->getWebDriver()->getMouse()->doubleClick($element->getCoordinates()); } + /** + * @throws DriverException + */ private function rightClickOnElement(RemoteWebElement $element): void { $element->getLocationOnScreenOnceScrolledIntoView(); - $this->webDriver->getMouse()->contextClick($element->getCoordinates()); + $this->getWebDriver()->getMouse()->contextClick($element->getCoordinates()); } + /** + * @throws DriverException + */ private function mouseOverElement(RemoteWebElement $element): void { $element->getLocationOnScreenOnceScrolledIntoView(); - $this->webDriver->getMouse()->mouseMove($element->getCoordinates()); + $this->getWebDriver()->getMouse()->mouseMove($element->getCoordinates()); } /** @@ -1198,7 +1244,7 @@ private function findElement( $finder = WebDriverBy::xpath($xpath); return $parent ? $parent->findElement($finder) - : $this->webDriver->findElement($finder); + : $this->getWebDriver()->findElement($finder); } catch (Throwable $e) { throw new DriverException("Failed to find element: {$e->getMessage()}", 0, $e); } @@ -1282,7 +1328,7 @@ private function selectOptionOnElement(RemoteWebElement $element, string $value, * * Note: this implementation does not trigger a change event after deselecting the elements. * - * @param RemoteWebElement $element + * @throws DriverException */ private function deselectAllOptions(RemoteWebElement $element): void { From c5496ca49f460f845d54f365898d9656ca0dc06d Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Tue, 13 Jun 2023 02:21:28 +0200 Subject: [PATCH 10/47] Disable param alignment --- .editorconfig | 3 +++ src/WebdriverClassicDriver.php | 24 ++++++++++++------------ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.editorconfig b/.editorconfig index 01909e6..9cd5d20 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,3 +11,6 @@ trim_trailing_whitespace = true [.github/workflows/*.yml] indent_size = 2 + +[*.php] +ij_php_align_multiline_parameters = false diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index 1b1f598..619235c 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -67,9 +67,9 @@ class WebdriverClassicDriver extends CoreDriver * @throws DriverException */ public function __construct( - string $browserName = null, - array $desiredCapabilities = null, - string $webDriverHost = null, + string $browserName = null, + array $desiredCapabilities = null, + string $webDriverHost = null, Escaper $xpathEscaper = null ) { $this->browserName = $browserName ?? self::DEFAULT_BROWSER; @@ -911,9 +911,9 @@ public function getBrowserName(): string /** * Returns Session ID of WebDriver or `null`, when session not started yet. * - * @api * @return string|null * @throws DriverException + * @api */ public function getWebDriverSessionId(): ?string { @@ -961,8 +961,8 @@ public function getDesiredCapabilities(): array /** * Globally press a key i.e. not typing into an element. * - * @api * @throws DriverException + * @api */ public function globalKeyPress($char, $modifier = null): void { @@ -979,12 +979,12 @@ public function globalKeyPress($char, $modifier = null): void /** * Drag and drop an element by x,y pixels. * - * @api * @throws DriverException + * @api */ public function dragBy( #[Language('XPath')] - $sourceXpath, + $sourceXpath, int $xOffset, int $yOffset ): void { @@ -1119,7 +1119,7 @@ private function executeJsOnXpath( private function executeJsOnElement( RemoteWebElement $element, #[Language('JavaScript')] - string $script + string $script ) { return $this->getWebDriver()->executeScript($script, [$element]); } @@ -1237,7 +1237,7 @@ private function withWindow(?string $name, callable $callback): void */ private function findElement( #[Language('XPath')] - string $xpath, + string $xpath, RemoteWebElement $parent = null ): RemoteWebElement { try { @@ -1349,9 +1349,9 @@ private function deselectAllOptions(RemoteWebElement $element): void private function ensureInputType( RemoteWebElement $element, #[Language('XPath')] - string $xpath, - string $type, - string $action + string $xpath, + string $type, + string $action ): void { if ($element->getTagName() !== 'input' || $element->getAttribute('type') !== $type) { throw new DriverException( From cc7683f37444d5aaff865526b08a6a105162d4ed Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Tue, 13 Jun 2023 19:59:05 +0200 Subject: [PATCH 11/47] Remove continue-on-error --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5c391d..329ad56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,8 +37,6 @@ jobs: tests: name: Tests runs-on: ubuntu-latest - # We cannot trigger events in old chrome, so we allow the run to fail - continue-on-error: ${{ matrix.browser == 'chrome' && matrix.selenium == '2.53.1' }} strategy: matrix: php: [ '7.4', '8.0', '8.1', '8.2' ] From 6196f75d590f584558565c24d29c9501a3c767ce Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Tue, 13 Jun 2023 20:40:54 +0200 Subject: [PATCH 12/47] Update test skipping --- tests/WebdriverClassicConfig.php | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/WebdriverClassicConfig.php b/tests/WebdriverClassicConfig.php index 4a4f310..5493372 100644 --- a/tests/WebdriverClassicConfig.php +++ b/tests/WebdriverClassicConfig.php @@ -4,7 +4,8 @@ use Behat\Mink\Driver\DriverInterface; use Behat\Mink\Tests\Driver\AbstractConfig; -use Behat\Mink\Tests\Driver\Basic\BasicAuthTest; +use Behat\Mink\Tests\Driver\Basic\HeaderTest; +use Behat\Mink\Tests\Driver\Basic\StatusCodeTest; use Behat\Mink\Tests\Driver\Js\WindowTest; use Mink\WebdriverClassDriver\WebdriverClassicDriver; @@ -39,14 +40,24 @@ public function mapRemoteFilePath($file): string public function skipMessage($testCase, $test): ?string { - if ($testCase === BasicAuthTest::class && $test === 'testBasicAuthInUrl') { - return 'This driver has mixed support for basic auth modals, depending on browser type and selenium version.'; + if ( + $testCase === WindowTest::class + && $test === 'testWindowMaximize' + && getenv('GITHUB_ACTIONS') === 'true' + ) { + return 'Maximizing the window does not work when running the browser in Xvfb.'; } - if ($testCase === WindowTest::class && $test === 'testWindowMaximize') { - return 'There is no sane way to find if a window is indeed maximized; this test is quite broken.'; + if ($testCase === HeaderTest::class) { + return 'Headers are not supported.'; } + if ($testCase === StatusCodeTest::class) { + return 'Checking status code is not supported.'; + } + + // TODO skip event tests for old chrome + return parent::skipMessage($testCase, $test); } From 83f00296fd60d8a87c255f74b976af9befc2bc9e Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Tue, 13 Jun 2023 21:02:52 +0200 Subject: [PATCH 13/47] More test skipping fixes --- .github/workflows/ci.yml | 3 ++- tests/WebdriverClassicConfig.php | 45 +++++++++++++++++++++----------- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 329ad56..ae5b39f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,9 +72,10 @@ jobs: - name: Run tests env: - WEB_FIXTURES_BROWSER: ${{ matrix.browser }} + SELENIUM_VERSION: ${{ matrix.selenium }} DRIVER_URL: http://172.18.0.2:4444/wd/hub WEB_FIXTURES_HOST: http://host.docker.internal:8002 + WEB_FIXTURES_BROWSER: ${{ matrix.browser }} DRIVER_MACHINE_BASE_PATH: /fixtures/ run: | vendor/bin/phpunit -v --coverage-clover=coverage.xml --colors=always --testdox diff --git a/tests/WebdriverClassicConfig.php b/tests/WebdriverClassicConfig.php index 5493372..8d3b0fa 100644 --- a/tests/WebdriverClassicConfig.php +++ b/tests/WebdriverClassicConfig.php @@ -3,9 +3,12 @@ namespace Mink\WebdriverClassDriver\Tests; use Behat\Mink\Driver\DriverInterface; +use Behat\Mink\Exception\DriverException; use Behat\Mink\Tests\Driver\AbstractConfig; +use Behat\Mink\Tests\Driver\Basic\BasicAuthTest; use Behat\Mink\Tests\Driver\Basic\HeaderTest; use Behat\Mink\Tests\Driver\Basic\StatusCodeTest; +use Behat\Mink\Tests\Driver\Js\EventsTest; use Behat\Mink\Tests\Driver\Js\WindowTest; use Mink\WebdriverClassDriver\WebdriverClassicDriver; @@ -18,6 +21,7 @@ public static function getInstance(): self /** * {@inheritdoc} + * @throws DriverException */ public function createDriver(): DriverInterface { @@ -40,25 +44,25 @@ public function mapRemoteFilePath($file): string public function skipMessage($testCase, $test): ?string { - if ( - $testCase === WindowTest::class - && $test === 'testWindowMaximize' - && getenv('GITHUB_ACTIONS') === 'true' - ) { - return 'Maximizing the window does not work when running the browser in Xvfb.'; - } + switch (true) { + case $testCase === WindowTest::class && $test === 'testWindowMaximize' && $this->isXvfb(): + return 'Maximizing the window does not work when running the browser in Xvfb.'; - if ($testCase === HeaderTest::class) { - return 'Headers are not supported.'; - } + case $testCase === BasicAuthTest::class: + return 'Basic auth is not supported.'; - if ($testCase === StatusCodeTest::class) { - return 'Checking status code is not supported.'; - } + case $testCase === HeaderTest::class: + return 'Headers are not supported.'; - // TODO skip event tests for old chrome + case $testCase === StatusCodeTest::class: + return 'Checking status code is not supported.'; - return parent::skipMessage($testCase, $test); + case $testCase === EventsTest::class && $test === 'testKeyboardEvents' && $this->isOldChrome(): + return 'Old Chrome does not allow triggering events.'; + + default: + return parent::skipMessage($testCase, $test); + } } /** @@ -68,4 +72,15 @@ protected function supportsCss(): bool { return true; } + + private function isXvfb(): bool + { + return getenv('GITHUB_ACTIONS') === 'true'; + } + + private function isOldChrome(): bool + { + return getenv('WEB_FIXTURES_BROWSER') === 'chrome' + && version_compare(getenv('SELENIUM_VERSION'), '3', '<'); + } } From d30dac875737cdd42a774e21c8f79358f04e11f7 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Wed, 14 Jun 2023 22:07:41 +0200 Subject: [PATCH 14/47] Add jetbrains attribute classes for dev --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 9615e6d..e0839ff 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,8 @@ "phpstan/phpstan": "^1.10", "phpstan/phpstan-phpunit": "^1.3", "phpunit/phpunit": "^9.6.8", - "symfony/error-handler": "^5.4 || ^6.0" + "symfony/error-handler": "^5.4 || ^6.0", + "jetbrains/phpstorm-attributes": "^1.0" }, "autoload": { "psr-4": { From c62968ea593c0c420feff35f5d454eb311509318 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Wed, 14 Jun 2023 22:08:15 +0200 Subject: [PATCH 15/47] Improve timeouts test --- tests/Custom/TimeoutTest.php | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/Custom/TimeoutTest.php b/tests/Custom/TimeoutTest.php index 59ba06d..5ea16f1 100644 --- a/tests/Custom/TimeoutTest.php +++ b/tests/Custom/TimeoutTest.php @@ -4,6 +4,7 @@ use Behat\Mink\Exception\DriverException; use Behat\Mink\Tests\Driver\TestCase; +use Mink\WebdriverClassDriver\WebdriverClassicDriver; class TimeoutTest extends TestCase { @@ -20,22 +21,24 @@ protected function resetSessions(): void } // Reset the array of timeouts to avoid impacting other tests - $session->getDriver()->setTimeouts([]); + $this->getDriver()->setTimeouts([]); parent::resetSessions(); } public function testInvalidTimeoutSettingThrowsException(): void { - $this->expectException(DriverException::class); $this->getSession()->start(); + $driver = $this->getDriver(); + + $this->expectException(DriverException::class); - $this->getSession()->getDriver()->setTimeouts(['invalid' => 0]); + $driver->setTimeouts(['invalid' => 0]); } public function testShortTimeoutDoesNotWaitForElementToAppear(): void { - $this->getSession()->getDriver()->setTimeouts(['implicit' => 0]); + $this->getDriver()->setTimeouts(['implicit' => 0]); $this->getSession()->visit($this->pathTo('/js_test.html')); @@ -48,7 +51,7 @@ public function testShortTimeoutDoesNotWaitForElementToAppear(): void public function testLongTimeoutWaitsForElementToAppear(): void { - $this->getSession()->getDriver()->setTimeouts(['implicit' => 5000]); + $this->getDriver()->setTimeouts(['implicit' => 5000]); $this->getSession()->visit($this->pathTo('/js_test.html')); $this->findById('waitable')->click(); @@ -56,4 +59,10 @@ public function testLongTimeoutWaitsForElementToAppear(): void $this->assertNotNull($element); } + + private function getDriver(): WebdriverClassicDriver + { + /** @phpstan-ignore-next-line */ + return $this->getSession()->getDriver(); + } } From 75afba401856202c485276ad058c4af681132661 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Wed, 14 Jun 2023 22:18:48 +0200 Subject: [PATCH 16/47] Fix phpstan hints --- src/WebdriverClassicDriver.php | 71 ++++++++++++++++---------------- tests/WebdriverClassicConfig.php | 2 +- 2 files changed, 37 insertions(+), 36 deletions(-) diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index 619235c..831d89c 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -99,22 +99,6 @@ public function start(): void } } - /** - * Sets the timeouts to apply to the webdriver session - * - * @param array $timeouts The session timeout settings: Array of {script, implicit, page} => time in milliseconds - * @throws DriverException - * @api - */ - public function setTimeouts(array $timeouts): void - { - $this->timeouts = $timeouts; - - if ($this->isStarted()) { - $this->applyTimeouts(); - } - } - /** * {@inheritdoc} */ @@ -209,7 +193,7 @@ public function switchToWindow($name = null): void $name = $this->getWindowHandleFromName($name); } - $this->getWebDriver()->switchTo()->window($name); + $this->getWebDriver()->switchTo()->window((string)$name); } /** @@ -414,7 +398,7 @@ public function getValue( ) { $element = $this->findElement($xpath); $elementName = strtolower($element->getTagName() ?? ''); - $elementType = strtolower($element->getAttribute('type') ?? ''); + $elementType = strtolower((string)$element->getAttribute('type')); // Getting the value of a checkbox returns its value if selected. if ('input' === $elementName && 'checkbox' === $elementType) { @@ -497,7 +481,7 @@ public function setValue( } return; } - $this->selectOptionOnElement($element, $value); + $this->selectOptionOnElement($element, (string)$value); return; case 'textarea': @@ -506,7 +490,7 @@ public function setValue( break; case 'input': - $elementType = strtolower($element->getAttribute('type') ?? ''); + $elementType = strtolower((string)$element->getAttribute('type')); switch ($elementType) { case 'submit': case 'image': @@ -544,7 +528,7 @@ public function setValue( return; case 'radio': - $this->selectRadioValue($element, $value); + $this->selectRadioValue($element, (string)$value); return; case 'file': @@ -622,15 +606,13 @@ public function selectOption( $element = $this->findElement($xpath); $tagName = strtolower($element->getTagName() ?? ''); - if ('input' === $tagName && 'radio' === strtolower($element->getAttribute('type') ?? '')) { + if ($tagName === 'input' && strtolower((string)$element->getAttribute('type')) === 'radio') { $this->selectRadioValue($element, $value); - return; } if ('select' === $tagName) { $this->selectOptionOnElement($element, $value, $multiple); - return; } @@ -860,7 +842,7 @@ public function resizeWindow($width, $height, $name = null): void $this->withWindow( $name, fn() => $this - ->webDriver + ->getWebDriver() ->manage() ->window() ->setSize(new WebDriverDimension($width, $height)) @@ -887,7 +869,7 @@ public function maximizeWindow($name = null): void $this->withWindow( $name, fn() => $this - ->webDriver + ->getWebDriver() ->manage() ->window() ->maximize() @@ -946,6 +928,22 @@ public function setDesiredCapabilities($desiredCapabilities): self return $this; } + /** + * Sets the timeouts to apply to the webdriver session + * + * @param array $timeouts The session timeout settings: Array of {script, implicit, page} => time in milliseconds + * @throws DriverException + * @api + */ + public function setTimeouts(array $timeouts): void + { + $this->timeouts = $timeouts; + + if ($this->isStarted()) { + $this->applyTimeouts(); + } + } + /** * Gets the final desired capabilities (as sent to Selenium). * @@ -964,7 +962,7 @@ public function getDesiredCapabilities(): array * @throws DriverException * @api */ - public function globalKeyPress($char, $modifier = null): void + public function globalKeyPress(string $char, ?string $modifier = null): void { $keyboard = $this->getWebDriver()->getKeyboard(); if ($modifier) { @@ -984,7 +982,7 @@ public function globalKeyPress($char, $modifier = null): void */ public function dragBy( #[Language('XPath')] - $sourceXpath, + string $sourceXpath, int $xOffset, int $yOffset ): void { @@ -1001,11 +999,8 @@ public function dragBy( */ private function getWebDriver(): RemoteWebDriver { - if (!$this->isStarted()) { - throw new DriverException('Driver has not been started'); - } - - return $this->webDriver; + return $this->webDriver + ?? throw new DriverException('Driver has not been started'); } /** @@ -1053,7 +1048,8 @@ private function withSyn(): self ); if (!$hasSyn) { - $synJs = file_get_contents(__DIR__ . '/../resources/syn.js'); + $synJs = file_get_contents(__DIR__ . '/../resources/syn.js') + ?: throw new DriverException('Could not load syn.js resource'); $this->getWebDriver()->executeScript($synJs); } @@ -1263,12 +1259,17 @@ private function selectRadioValue(RemoteWebElement $element, string $value): voi } $name = $element->getAttribute('name'); - if (!$name) { throw new DriverException(sprintf('The radio button does not have the value "%s"', $value)); } + if ($name === true) { + $name = ''; + } $formId = $element->getAttribute('form'); + if ($formId === true) { + $formId = ''; + } try { $escapedName = $this->xpathEscaper->escapeLiteral($name); diff --git a/tests/WebdriverClassicConfig.php b/tests/WebdriverClassicConfig.php index 8d3b0fa..340be6b 100644 --- a/tests/WebdriverClassicConfig.php +++ b/tests/WebdriverClassicConfig.php @@ -81,6 +81,6 @@ private function isXvfb(): bool private function isOldChrome(): bool { return getenv('WEB_FIXTURES_BROWSER') === 'chrome' - && version_compare(getenv('SELENIUM_VERSION'), '3', '<'); + && version_compare(getenv('SELENIUM_VERSION') ?: '', '3', '<'); } } From 7574b2ba457bf5bdba5f70e5cae91d7524b6932c Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Wed, 14 Jun 2023 22:30:49 +0200 Subject: [PATCH 17/47] Wrap errors when capturing throwables; fix deprecation notice --- src/ErrorException.php | 14 ++++++++++++++ src/WebdriverClassicDriver.php | 15 +++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 src/ErrorException.php diff --git a/src/ErrorException.php b/src/ErrorException.php new file mode 100644 index 0000000..c1b0d2f --- /dev/null +++ b/src/ErrorException.php @@ -0,0 +1,14 @@ +getMessage(), $e->getCode(), $e); + } +} diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index 831d89c..bfd3429 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -15,6 +15,7 @@ use Behat\Mink\Driver\CoreDriver; use Behat\Mink\Exception\DriverException; use Behat\Mink\Selector\Xpath\Escaper; +use Exception; use Facebook\WebDriver\Exception\NoSuchCookieException; use Facebook\WebDriver\Exception\NoSuchElementException; use Facebook\WebDriver\Exception\WebDriverException; @@ -95,7 +96,7 @@ public function start(): void $this->applyTimeouts(); $this->initialWindowName = $this->getWindowName(); } catch (Throwable $e) { - throw new DriverException("Could not start driver: {$e->getMessage()}", 0, $e); + throw new DriverException("Could not start driver: {$e->getMessage()}", 0, $this->wrapError($e)); } } @@ -121,7 +122,7 @@ public function stop(): void $this->getWebDriver()->quit(); $this->webDriver = null; } catch (Throwable $e) { - throw new DriverException('Could not close connection', 0, $e); + throw new DriverException('Could not close connection', 0, $this->wrapError($e)); } } @@ -800,6 +801,7 @@ public function executeScript( /** * {@inheritdoc} + * @return mixed * @throws DriverException */ public function evaluateScript( @@ -1143,7 +1145,7 @@ private function applyTimeouts(): void } } } catch (Throwable $e) { - throw new DriverException("Error setting timeout: {$e->getMessage()}", 0, $e); + throw new DriverException("Error setting timeout: {$e->getMessage()}", 0, $this->wrapError($e)); } } @@ -1242,7 +1244,7 @@ private function findElement( ? $parent->findElement($finder) : $this->getWebDriver()->findElement($finder); } catch (Throwable $e) { - throw new DriverException("Failed to find element: {$e->getMessage()}", 0, $e); + throw new DriverException("Failed to find element: {$e->getMessage()}", 0, $this->wrapError($e)); } } @@ -1387,5 +1389,10 @@ private function jsonEncode($value, string $action, string $field): string } } + private function wrapError(Throwable $e): Exception + { + return $e instanceof Exception ? $e : new ErrorException($e); + } + // } From f1530488ec607cb48bef938bb58d3657b32bb30b Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Wed, 14 Jun 2023 22:40:11 +0200 Subject: [PATCH 18/47] More phpstan fixes --- src/WebdriverClassicDriver.php | 3 +++ tests/Custom/ChromeDesiredCapabilitiesTest.php | 9 +++++++-- tests/Custom/FirefoxDesiredCapabilitiesTest.php | 9 +++++++-- tests/Custom/WebDriverTest.php | 12 +++++++++--- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index bfd3429..cbe87b8 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -529,6 +529,9 @@ public function setValue( return; case 'radio': + if (is_array($value)) { + throw new DriverException('Cannot select multiple radio buttons; value cannot be an array'); + } $this->selectRadioValue($element, (string)$value); return; diff --git a/tests/Custom/ChromeDesiredCapabilitiesTest.php b/tests/Custom/ChromeDesiredCapabilitiesTest.php index dd29d02..d773fe2 100644 --- a/tests/Custom/ChromeDesiredCapabilitiesTest.php +++ b/tests/Custom/ChromeDesiredCapabilitiesTest.php @@ -65,7 +65,12 @@ public function testSetDesiredCapabilities(): void ]; $session = $this->getSession(); $session->start(); - $driver = $session->getDriver(); - $driver->setDesiredCapabilities($caps); + $this->getDriver()->setDesiredCapabilities($caps); + } + + private function getDriver(): WebdriverClassicDriver + { + /** @phpstan-ignore-next-line */ + return $this->getSession()->getDriver(); } } diff --git a/tests/Custom/FirefoxDesiredCapabilitiesTest.php b/tests/Custom/FirefoxDesiredCapabilitiesTest.php index be6b60a..a4c102c 100644 --- a/tests/Custom/FirefoxDesiredCapabilitiesTest.php +++ b/tests/Custom/FirefoxDesiredCapabilitiesTest.php @@ -65,7 +65,12 @@ public function testSetDesiredCapabilities(): void ]; $session = $this->getSession(); $session->start(); - $driver = $session->getDriver(); - $driver->setDesiredCapabilities($caps); + $this->getDriver()->setDesiredCapabilities($caps); + } + + private function getDriver(): WebdriverClassicDriver + { + /** @phpstan-ignore-next-line */ + return $this->getSession()->getDriver(); } } diff --git a/tests/Custom/WebDriverTest.php b/tests/Custom/WebDriverTest.php index 57b954f..a690ddf 100644 --- a/tests/Custom/WebDriverTest.php +++ b/tests/Custom/WebDriverTest.php @@ -23,10 +23,16 @@ protected function tearDown(): void public function testGetWebDriverSessionId(): void { - $driver = $this->getSession()->getDriver(); - $this->assertNotEmpty($driver->getWebDriverSessionId(), 'Started session has an ID'); + $driver = $this->getDriver(); + $this->assertNotEmpty($driver->getWebDriverSessionId(), 'Started session should have an ID'); $driver = new WebdriverClassicDriver(); - $this->assertNull($driver->getWebDriverSessionId(), 'Not started session don\'t have an ID'); + $this->assertNull($driver->getWebDriverSessionId(), 'Non-started session should not have an ID'); + } + + private function getDriver(): WebdriverClassicDriver + { + /** @phpstan-ignore-next-line */ + return $this->getSession()->getDriver(); } } From d6f875575894e82f4abf91264815ddbbca7f1138 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Wed, 14 Jun 2023 22:43:18 +0200 Subject: [PATCH 19/47] Avoid fancy syntax --- src/WebdriverClassicDriver.php | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index cbe87b8..41b2780 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -1004,8 +1004,11 @@ public function dragBy( */ private function getWebDriver(): RemoteWebDriver { - return $this->webDriver - ?? throw new DriverException('Driver has not been started'); + if ($this->webDriver) { + return $this->webDriver; + } + + throw new DriverException('Driver has not been started'); } /** @@ -1051,13 +1054,16 @@ private function withSyn(): self $hasSyn = $this->evaluateScript( 'return window.syn !== undefined && window.syn.trigger !== undefined' ); + if ($hasSyn) { + return $this; + } - if (!$hasSyn) { - $synJs = file_get_contents(__DIR__ . '/../resources/syn.js') - ?: throw new DriverException('Could not load syn.js resource'); - $this->getWebDriver()->executeScript($synJs); + $synJs = file_get_contents(__DIR__ . '/../resources/syn.js'); + if (!$synJs) { + throw new DriverException('Could not load syn.js resource'); } + $this->getWebDriver()->executeScript($synJs); return $this; } From 70826c50c175696b4fee3a6d17d221d757175a9d Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Wed, 14 Jun 2023 22:48:42 +0200 Subject: [PATCH 20/47] Remove github secret; ignore indirect deprecations --- .github/workflows/ci.yml | 2 -- phpunit.xml.dist | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae5b39f..10ea1c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,8 +59,6 @@ jobs: coverage: "xdebug" php-version: "${{ matrix.php }}" ini-file: development - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Install dependencies run: | diff --git a/phpunit.xml.dist b/phpunit.xml.dist index a4d4c23..2121f84 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -29,6 +29,8 @@ + + From d82433c4f69f5123726488139da49a44f0553dc3 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Wed, 14 Jun 2023 23:17:43 +0200 Subject: [PATCH 21/47] Remove redundant phpdoc --- src/WebdriverClassicDriver.php | 48 +--------------------------------- 1 file changed, 1 insertion(+), 47 deletions(-) diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index 41b2780..9b9686e 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -83,7 +83,6 @@ public function __construct( /** * {@inheritdoc} - * @throws DriverException */ public function start(): void { @@ -110,7 +109,6 @@ public function isStarted(): bool /** * {@inheritdoc} - * @throws DriverException */ public function stop(): void { @@ -137,7 +135,6 @@ public function reset(): void /** * {@inheritdoc} - * @throws DriverException */ public function visit($url): void { @@ -146,7 +143,6 @@ public function visit($url): void /** * {@inheritdoc} - * @throws DriverException */ public function getCurrentUrl(): string { @@ -155,7 +151,6 @@ public function getCurrentUrl(): string /** * {@inheritdoc} - * @throws DriverException */ public function reload(): void { @@ -164,7 +159,6 @@ public function reload(): void /** * {@inheritdoc} - * @throws DriverException */ public function forward(): void { @@ -173,7 +167,6 @@ public function forward(): void /** * {@inheritdoc} - * @throws DriverException */ public function back(): void { @@ -182,7 +175,6 @@ public function back(): void /** * {@inheritdoc} - * @throws DriverException */ public function switchToWindow($name = null): void { @@ -199,12 +191,11 @@ public function switchToWindow($name = null): void /** * {@inheritdoc} - * @throws DriverException */ public function switchToIFrame($name = null): void { $frameQuery = $name; - if ($name && is_string($name) && $this->getWebDriver()->isW3cCompliant()) { + if ($name && $this->getWebDriver()->isW3cCompliant()) { try { $frameQuery = $this->getWebDriver()->findElement(WebDriverBy::id($name)); } catch (NoSuchElementException $e) { @@ -217,7 +208,6 @@ public function switchToIFrame($name = null): void /** * {@inheritdoc} - * @throws DriverException */ public function setCookie($name, $value = null): void { @@ -238,7 +228,6 @@ public function setCookie($name, $value = null): void /** * {@inheritdoc} - * @throws DriverException */ public function getCookie($name): ?string { @@ -261,7 +250,6 @@ public function getCookie($name): ?string /** * {@inheritdoc} - * @throws DriverException */ public function getContent(): string { @@ -270,7 +258,6 @@ public function getContent(): string /** * {@inheritdoc} - * @throws DriverException */ public function getScreenshot(): string { @@ -279,7 +266,6 @@ public function getScreenshot(): string /** * {@inheritdoc} - * @throws DriverException */ public function getWindowNames(): array { @@ -299,7 +285,6 @@ public function getWindowNames(): array /** * {@inheritdoc} - * @throws DriverException */ public function getWindowName(): string { @@ -314,7 +299,6 @@ public function getWindowName(): string /** * {@inheritdoc} - * @throws DriverException */ public function findElementXpaths( #[Language('XPath')] @@ -332,7 +316,6 @@ public function findElementXpaths( /** * {@inheritdoc} - * @throws DriverException */ public function getTagName( #[Language('XPath')] @@ -343,7 +326,6 @@ public function getTagName( /** * {@inheritdoc} - * @throws DriverException */ public function getText( #[Language('XPath')] @@ -354,7 +336,6 @@ public function getText( /** * {@inheritdoc} - * @throws DriverException */ public function getHtml( #[Language('XPath')] @@ -365,7 +346,6 @@ public function getHtml( /** * {@inheritdoc} - * @throws DriverException */ public function getOuterHtml( #[Language('XPath')] @@ -376,7 +356,6 @@ public function getOuterHtml( /** * {@inheritdoc} - * @throws DriverException */ public function getAttribute( #[Language('XPath')] @@ -391,7 +370,6 @@ public function getAttribute( /** * {@inheritdoc} - * @throws DriverException */ public function getValue( #[Language('XPath')] @@ -463,7 +441,6 @@ public function getValue( /** * {@inheritdoc} - * @throws DriverException */ public function setValue( #[Language('XPath')] @@ -552,7 +529,6 @@ public function setValue( /** * {@inheritdoc} - * @throws DriverException */ public function check( #[Language('XPath')] @@ -570,7 +546,6 @@ public function check( /** * {@inheritdoc} - * @throws DriverException */ public function uncheck( #[Language('XPath')] @@ -588,7 +563,6 @@ public function uncheck( /** * {@inheritdoc} - * @throws DriverException */ public function isChecked( #[Language('XPath')] @@ -599,7 +573,6 @@ public function isChecked( /** * {@inheritdoc} - * @throws DriverException */ public function selectOption( #[Language('XPath')] @@ -626,7 +599,6 @@ public function selectOption( /** * {@inheritdoc} - * @throws DriverException */ public function isSelected( #[Language('XPath')] @@ -637,7 +609,6 @@ public function isSelected( /** * {@inheritdoc} - * @throws DriverException */ public function click( #[Language('XPath')] @@ -648,7 +619,6 @@ public function click( /** * {@inheritdoc} - * @throws DriverException */ public function doubleClick( #[Language('XPath')] @@ -659,7 +629,6 @@ public function doubleClick( /** * {@inheritdoc} - * @throws DriverException */ public function rightClick( #[Language('XPath')] @@ -670,7 +639,6 @@ public function rightClick( /** * {@inheritdoc} - * @throws DriverException */ public function attachFile( #[Language('XPath')] @@ -687,7 +655,6 @@ public function attachFile( /** * {@inheritdoc} - * @throws DriverException */ public function isVisible( #[Language('XPath')] @@ -698,7 +665,6 @@ public function isVisible( /** * {@inheritdoc} - * @throws DriverException */ public function mouseOver( #[Language('XPath')] @@ -709,7 +675,6 @@ public function mouseOver( /** * {@inheritdoc} - * @throws DriverException */ public function focus( #[Language('XPath')] @@ -720,7 +685,6 @@ public function focus( /** * {@inheritdoc} - * @throws DriverException */ public function blur( #[Language('XPath')] @@ -731,7 +695,6 @@ public function blur( /** * {@inheritdoc} - * @throws DriverException */ public function keyPress( #[Language('XPath')] @@ -745,7 +708,6 @@ public function keyPress( /** * {@inheritdoc} - * @throws DriverException */ public function keyDown( #[Language('XPath')] @@ -759,7 +721,6 @@ public function keyDown( /** * {@inheritdoc} - * @throws DriverException */ public function keyUp( #[Language('XPath')] @@ -773,7 +734,6 @@ public function keyUp( /** * {@inheritdoc} - * @throws DriverException */ public function dragTo( #[Language('XPath')] @@ -788,7 +748,6 @@ public function dragTo( /** * {@inheritdoc} - * @throws DriverException */ public function executeScript( #[Language('JavaScript')] @@ -805,7 +764,6 @@ public function executeScript( /** * {@inheritdoc} * @return mixed - * @throws DriverException */ public function evaluateScript( #[Language('JavaScript')] @@ -820,7 +778,6 @@ public function evaluateScript( /** * {@inheritdoc} - * @throws DriverException */ public function wait( $timeout, @@ -840,7 +797,6 @@ public function wait( /** * {@inheritdoc} - * @throws DriverException */ public function resizeWindow($width, $height, $name = null): void { @@ -856,7 +812,6 @@ public function resizeWindow($width, $height, $name = null): void /** * {@inheritdoc} - * @throws DriverException */ public function submitForm( #[Language('XPath')] @@ -867,7 +822,6 @@ public function submitForm( /** * {@inheritdoc} - * @throws DriverException */ public function maximizeWindow($name = null): void { From 36ba41a42c8acfd78a2a7a16666d784af7233708 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Thu, 15 Jun 2023 00:41:13 +0200 Subject: [PATCH 22/47] Update dep requirement; remove error wrapping --- composer.json | 2 +- src/WebdriverClassicDriver.php | 14 ++++---------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/composer.json b/composer.json index e0839ff..e608342 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ "require": { "php": ">=7.4", "ext-json": "*", - "behat/mink": "^1.9@dev", + "behat/mink": "^1.11@dev", "php-webdriver/webdriver": "^1.14" }, "require-dev": { diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index 9b9686e..f93b590 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -15,7 +15,6 @@ use Behat\Mink\Driver\CoreDriver; use Behat\Mink\Exception\DriverException; use Behat\Mink\Selector\Xpath\Escaper; -use Exception; use Facebook\WebDriver\Exception\NoSuchCookieException; use Facebook\WebDriver\Exception\NoSuchElementException; use Facebook\WebDriver\Exception\WebDriverException; @@ -95,7 +94,7 @@ public function start(): void $this->applyTimeouts(); $this->initialWindowName = $this->getWindowName(); } catch (Throwable $e) { - throw new DriverException("Could not start driver: {$e->getMessage()}", 0, $this->wrapError($e)); + throw new DriverException("Could not start driver: {$e->getMessage()}", 0, $e); } } @@ -120,7 +119,7 @@ public function stop(): void $this->getWebDriver()->quit(); $this->webDriver = null; } catch (Throwable $e) { - throw new DriverException('Could not close connection', 0, $this->wrapError($e)); + throw new DriverException('Could not close connection', 0, $e); } } @@ -1108,7 +1107,7 @@ private function applyTimeouts(): void } } } catch (Throwable $e) { - throw new DriverException("Error setting timeout: {$e->getMessage()}", 0, $this->wrapError($e)); + throw new DriverException("Error setting timeout: {$e->getMessage()}", 0, $e); } } @@ -1207,7 +1206,7 @@ private function findElement( ? $parent->findElement($finder) : $this->getWebDriver()->findElement($finder); } catch (Throwable $e) { - throw new DriverException("Failed to find element: {$e->getMessage()}", 0, $this->wrapError($e)); + throw new DriverException("Failed to find element: {$e->getMessage()}", 0, $e); } } @@ -1352,10 +1351,5 @@ private function jsonEncode($value, string $action, string $field): string } } - private function wrapError(Throwable $e): Exception - { - return $e instanceof Exception ? $e : new ErrorException($e); - } - // } From 9d39c98650f0d57c6f7a40446c1cd87f980af04e Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Thu, 15 Jun 2023 00:44:33 +0200 Subject: [PATCH 23/47] Add act comment --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10ea1c7..97dd5c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,7 @@ jobs: - name: Upload coverage uses: codecov/codecov-action@v3 + # See https://github.com/nektos/act#skipping-steps if: ${{ !env.ACT }} with: files: coverage.xml From 90a18c7e0e6c8ffd8c8f5f8daae44ca31ec26053 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Thu, 15 Jun 2023 00:56:35 +0200 Subject: [PATCH 24/47] Reset window state --- src/WebdriverClassicDriver.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index f93b590..2c4ebfd 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -129,6 +129,15 @@ public function stop(): void */ public function reset(): void { + // switch to default window.. + $this->switchToWindow(); + // ..and close all other windows + foreach ($this->getWindowNames() as $name) { + if ($name !== $this->initialWindowName) { + $this->withWindow($name, fn() => $this->getWebDriver()->close()); + } + } + $this->getWebDriver()->manage()->deleteAllCookies(); } From 0fb929d4ae81efe5e22fb10724e5d05338599084 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Thu, 15 Jun 2023 01:16:55 +0200 Subject: [PATCH 25/47] Use builtin classes --- src/WebdriverClassicDriver.php | 108 +++++++++++---------------------- 1 file changed, 34 insertions(+), 74 deletions(-) diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index 2c4ebfd..c82e763 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -14,7 +14,6 @@ use Behat\Mink\Driver\CoreDriver; use Behat\Mink\Exception\DriverException; -use Behat\Mink\Selector\Xpath\Escaper; use Facebook\WebDriver\Exception\NoSuchCookieException; use Facebook\WebDriver\Exception\NoSuchElementException; use Facebook\WebDriver\Exception\WebDriverException; @@ -23,6 +22,8 @@ use Facebook\WebDriver\Remote\RemoteWebElement; use Facebook\WebDriver\WebDriverBy; use Facebook\WebDriver\WebDriverDimension; +use Facebook\WebDriver\WebDriverRadios; +use Facebook\WebDriver\WebDriverSelect; use JetBrains\PhpStorm\Language; use JsonException; use Throwable; @@ -57,8 +58,6 @@ class WebdriverClassicDriver extends CoreDriver private array $timeouts = []; - private Escaper $xpathEscaper; - private string $webDriverHost; private ?string $initialWindowName = null; @@ -69,13 +68,11 @@ class WebdriverClassicDriver extends CoreDriver public function __construct( string $browserName = null, array $desiredCapabilities = null, - string $webDriverHost = null, - Escaper $xpathEscaper = null + string $webDriverHost = null ) { $this->browserName = $browserName ?? self::DEFAULT_BROWSER; $this->setDesiredCapabilities($this->initCapabilities($desiredCapabilities ?? [])); $this->webDriverHost = $webDriverHost ?? 'http://localhost:4444/wd/hub'; - $this->xpathEscaper = $xpathEscaper ?? new Escaper(); } // @@ -1224,50 +1221,17 @@ private function findElement( */ private function selectRadioValue(RemoteWebElement $element, string $value): void { - // short-circuit when we already have the right button of the group to avoid XPath queries - if ($element->getAttribute('value') === $value) { - $element->click(); - - return; - } - - $name = $element->getAttribute('name'); - if (!$name) { - throw new DriverException(sprintf('The radio button does not have the value "%s"', $value)); - } - if ($name === true) { - $name = ''; - } - - $formId = $element->getAttribute('form'); - if ($formId === true) { - $formId = ''; - } - try { - $escapedName = $this->xpathEscaper->escapeLiteral($name); - $escapedValue = $this->xpathEscaper->escapeLiteral($value); - if (null !== $formId) { - $escapedFormId = $this->xpathEscaper->escapeLiteral($formId); - $input = $this->findElement( - <<<"XPATH" - //form[@id=$escapedFormId]//input[@type="radio" and not(@form) and @name=$escapedName and @value=$escapedValue] - | - //input[@type="radio" and @form=$escapedFormId and @name=$escapedName and @value=$escapedValue] - XPATH - ); - } else { - $input = $this->findElement( - "./ancestor::form//input[@type=\"radio\" and not(@form) and @name=$escapedName and @value=$escapedValue]", - $element - ); - } - } catch (DriverException $e) { - $message = sprintf('The radio group "%s" does not have an option "%s"', $name, $value); + (new WebDriverRadios($element))->selectByValue($value); + } catch (Throwable $e) { + $message = sprintf( + 'Cannot select radio button of group "%s" with value "%s": %s', + $element->getAttribute('name'), + $value, + $e->getMessage() + ); throw new DriverException($message, 0, $e); } - - $input->click(); } /** @@ -1275,26 +1239,21 @@ private function selectRadioValue(RemoteWebElement $element, string $value): voi */ private function selectOptionOnElement(RemoteWebElement $element, string $value, bool $multiple = false): void { - $escapedValue = $this->xpathEscaper->escapeLiteral($value); - // The value of an option is the normalized version of its text when it has no value attribute - $optionQuery = sprintf( - './/option[@value = %s or (not(@value) and normalize-space(.) = %s)]', - $escapedValue, - $escapedValue - ); - $option = $this->findElement($optionQuery, $element); // Avoids selecting values from other select boxes - - if ($multiple || !$element->getAttribute('multiple')) { - if (!$option->isSelected()) { - $option->click(); + try { + $select = new WebDriverSelect($element); + if (!$multiple || !$select->isMultiple()) { + $select->deselectAll(); } - - return; + $select->selectByValue($value); + } catch (Throwable $e) { + $message = sprintf( + 'Cannot select option "%s" of "%s": %s', + $value, + $element->getAttribute('name'), + $e->getMessage(), + ); + throw new DriverException($message, 0, $e); } - - // Deselect all options before selecting the new one - $this->deselectAllOptions($element); - $option->click(); } /** @@ -1306,15 +1265,16 @@ private function selectOptionOnElement(RemoteWebElement $element, string $value, */ private function deselectAllOptions(RemoteWebElement $element): void { - $script = <<executeJsOnElement($element, $script); + try { + (new WebDriverSelect($element))->deselectAll(); + } catch (Throwable $e) { + $message = sprintf( + 'Cannot deselect all options of "%s": %s', + $element->getAttribute('name'), + $e->getMessage() + ); + throw new DriverException($message, 0, $e); + } } /** From 3044bf01eb33e4cb8f67cab8a058e2089eb66e08 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Thu, 15 Jun 2023 01:29:54 +0200 Subject: [PATCH 26/47] Compatibility fix --- src/WebdriverClassicDriver.php | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index c82e763..48269d5 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -385,11 +385,11 @@ public function getValue( $elementType = strtolower((string)$element->getAttribute('type')); // Getting the value of a checkbox returns its value if selected. - if ('input' === $elementName && 'checkbox' === $elementType) { + if ($elementName === 'input' && $elementType === 'checkbox') { return $element->isSelected() ? $element->getAttribute('value') : null; } - if ('input' === $elementName && 'radio' === $elementType) { + if ($elementName === 'input' && $elementType === 'radio') { $script = <<attribute('value') on a select only returns the first selected option // even when it is a multiple select, so a custom retrieval is needed. - if ('select' === $elementName && $element->getAttribute('multiple')) { + if ($elementName === 'select' && $element->getAttribute('multiple')) { $script = <<selectOptionOnElement($element, $value, $multiple); return; } @@ -1241,10 +1241,14 @@ private function selectOptionOnElement(RemoteWebElement $element, string $value, { try { $select = new WebDriverSelect($element); - if (!$multiple || !$select->isMultiple()) { + if (!$multiple && $select->isMultiple()) { $select->deselectAll(); } - $select->selectByValue($value); + try { + $select->selectByValue($value); + } catch (NoSuchElementException) { + $select->selectByVisibleText($value); + } } catch (Throwable $e) { $message = sprintf( 'Cannot select option "%s" of "%s": %s', From 326aecbc24bcb8831106225be7c95ebc98f207e8 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Thu, 15 Jun 2023 01:35:47 +0200 Subject: [PATCH 27/47] Improve tests --- .../Custom/ChromeDesiredCapabilitiesTest.php | 15 ++++++------ .../Custom/FirefoxDesiredCapabilitiesTest.php | 15 ++++++------ tests/Custom/TimeoutTest.php | 23 +++++++++---------- tests/Custom/WebDriverTest.php | 9 ++------ 4 files changed, 27 insertions(+), 35 deletions(-) diff --git a/tests/Custom/ChromeDesiredCapabilitiesTest.php b/tests/Custom/ChromeDesiredCapabilitiesTest.php index d773fe2..7310898 100644 --- a/tests/Custom/ChromeDesiredCapabilitiesTest.php +++ b/tests/Custom/ChromeDesiredCapabilitiesTest.php @@ -44,6 +44,7 @@ public function testGetDesiredCapabilities(): void ]; $driver = new WebdriverClassicDriver('chrome', $caps); + $this->assertNotEmpty($driver->getDesiredCapabilities(), 'desiredCapabilities empty'); $this->assertIsArray($driver->getDesiredCapabilities()); $this->assertEquals($caps, $driver->getDesiredCapabilities()); @@ -51,8 +52,6 @@ public function testGetDesiredCapabilities(): void public function testSetDesiredCapabilities(): void { - $this->expectException(DriverException::class); - $this->expectExceptionMessage('Unable to set desiredCapabilities, the session has already started'); $caps = [ 'browserName' => 'chrome', 'version' => '30', @@ -65,12 +64,12 @@ public function testSetDesiredCapabilities(): void ]; $session = $this->getSession(); $session->start(); - $this->getDriver()->setDesiredCapabilities($caps); - } + $driver = $this->getSession()->getDriver(); + assert($driver instanceof WebdriverClassicDriver); - private function getDriver(): WebdriverClassicDriver - { - /** @phpstan-ignore-next-line */ - return $this->getSession()->getDriver(); + $this->expectException(DriverException::class); + $this->expectExceptionMessage('Unable to set desiredCapabilities, the session has already started'); + + $driver->setDesiredCapabilities($caps); } } diff --git a/tests/Custom/FirefoxDesiredCapabilitiesTest.php b/tests/Custom/FirefoxDesiredCapabilitiesTest.php index a4c102c..ebe2efb 100644 --- a/tests/Custom/FirefoxDesiredCapabilitiesTest.php +++ b/tests/Custom/FirefoxDesiredCapabilitiesTest.php @@ -44,6 +44,7 @@ public function testGetDesiredCapabilities(): void ]; $driver = new WebdriverClassicDriver('firefox', $caps); + $this->assertNotEmpty($driver->getDesiredCapabilities(), 'desiredCapabilities empty'); $this->assertIsArray($driver->getDesiredCapabilities()); $this->assertEquals($caps, $driver->getDesiredCapabilities()); @@ -51,8 +52,6 @@ public function testGetDesiredCapabilities(): void public function testSetDesiredCapabilities(): void { - $this->expectException(DriverException::class); - $this->expectExceptionMessage('Unable to set desiredCapabilities, the session has already started'); $caps = [ 'browserName' => 'firefox', 'version' => '30', @@ -65,12 +64,12 @@ public function testSetDesiredCapabilities(): void ]; $session = $this->getSession(); $session->start(); - $this->getDriver()->setDesiredCapabilities($caps); - } + $driver = $this->getSession()->getDriver(); + assert($driver instanceof WebdriverClassicDriver); - private function getDriver(): WebdriverClassicDriver - { - /** @phpstan-ignore-next-line */ - return $this->getSession()->getDriver(); + $this->expectException(DriverException::class); + $this->expectExceptionMessage('Unable to set desiredCapabilities, the session has already started'); + + $driver->setDesiredCapabilities($caps); } } diff --git a/tests/Custom/TimeoutTest.php b/tests/Custom/TimeoutTest.php index 5ea16f1..1f0ecb3 100644 --- a/tests/Custom/TimeoutTest.php +++ b/tests/Custom/TimeoutTest.php @@ -14,6 +14,8 @@ class TimeoutTest extends TestCase protected function resetSessions(): void { $session = $this->getSession(); + $driver = $this->getSession()->getDriver(); + assert($driver instanceof WebdriverClassicDriver); // Stop the session instead of only resetting it, as timeouts are not reset (they are configuring the session itself) if ($session->isStarted()) { @@ -21,7 +23,7 @@ protected function resetSessions(): void } // Reset the array of timeouts to avoid impacting other tests - $this->getDriver()->setTimeouts([]); + $driver->setTimeouts([]); parent::resetSessions(); } @@ -29,7 +31,8 @@ protected function resetSessions(): void public function testInvalidTimeoutSettingThrowsException(): void { $this->getSession()->start(); - $driver = $this->getDriver(); + $driver = $this->getSession()->getDriver(); + assert($driver instanceof WebdriverClassicDriver); $this->expectException(DriverException::class); @@ -38,12 +41,12 @@ public function testInvalidTimeoutSettingThrowsException(): void public function testShortTimeoutDoesNotWaitForElementToAppear(): void { - $this->getDriver()->setTimeouts(['implicit' => 0]); + $driver = $this->getSession()->getDriver(); + assert($driver instanceof WebdriverClassicDriver); + $driver->setTimeouts(['implicit' => 0]); $this->getSession()->visit($this->pathTo('/js_test.html')); - $this->findById('waitable')->click(); - $element = $this->getSession()->getPage()->find('css', '#waitable > div'); $this->assertNull($element); @@ -51,7 +54,9 @@ public function testShortTimeoutDoesNotWaitForElementToAppear(): void public function testLongTimeoutWaitsForElementToAppear(): void { - $this->getDriver()->setTimeouts(['implicit' => 5000]); + $driver = $this->getSession()->getDriver(); + assert($driver instanceof WebdriverClassicDriver); + $driver->setTimeouts(['implicit' => 5000]); $this->getSession()->visit($this->pathTo('/js_test.html')); $this->findById('waitable')->click(); @@ -59,10 +64,4 @@ public function testLongTimeoutWaitsForElementToAppear(): void $this->assertNotNull($element); } - - private function getDriver(): WebdriverClassicDriver - { - /** @phpstan-ignore-next-line */ - return $this->getSession()->getDriver(); - } } diff --git a/tests/Custom/WebDriverTest.php b/tests/Custom/WebDriverTest.php index a690ddf..9314b02 100644 --- a/tests/Custom/WebDriverTest.php +++ b/tests/Custom/WebDriverTest.php @@ -23,16 +23,11 @@ protected function tearDown(): void public function testGetWebDriverSessionId(): void { - $driver = $this->getDriver(); + $driver = $this->getSession()->getDriver(); + assert($driver instanceof WebdriverClassicDriver); $this->assertNotEmpty($driver->getWebDriverSessionId(), 'Started session should have an ID'); $driver = new WebdriverClassicDriver(); $this->assertNull($driver->getWebDriverSessionId(), 'Non-started session should not have an ID'); } - - private function getDriver(): WebdriverClassicDriver - { - /** @phpstan-ignore-next-line */ - return $this->getSession()->getDriver(); - } } From 580090eebd1a54ec7042124992b6c2791f4726f2 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Thu, 15 Jun 2023 01:40:27 +0200 Subject: [PATCH 28/47] Remove unused class; fix syntax error --- src/ErrorException.php | 14 -------------- src/WebdriverClassicDriver.php | 2 +- 2 files changed, 1 insertion(+), 15 deletions(-) delete mode 100644 src/ErrorException.php diff --git a/src/ErrorException.php b/src/ErrorException.php deleted file mode 100644 index c1b0d2f..0000000 --- a/src/ErrorException.php +++ /dev/null @@ -1,14 +0,0 @@ -getMessage(), $e->getCode(), $e); - } -} diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index 48269d5..4840be0 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -1246,7 +1246,7 @@ private function selectOptionOnElement(RemoteWebElement $element, string $value, } try { $select->selectByValue($value); - } catch (NoSuchElementException) { + } catch (NoSuchElementException $e) { $select->selectByVisibleText($value); } } catch (Throwable $e) { From 92d57876465c67ba8ebfa7c9e413df36d66f60c9 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Thu, 15 Jun 2023 18:18:15 +0200 Subject: [PATCH 29/47] Declare arg types; use builtin classes; various fixes --- src/WebdriverClassicDriver.php | 243 ++++++++++++++----------------- tests/WebdriverClassicConfig.php | 2 +- 2 files changed, 111 insertions(+), 134 deletions(-) diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index 4840be0..073c7e0 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -22,6 +22,7 @@ use Facebook\WebDriver\Remote\RemoteWebElement; use Facebook\WebDriver\WebDriverBy; use Facebook\WebDriver\WebDriverDimension; +use Facebook\WebDriver\WebDriverElement; use Facebook\WebDriver\WebDriverRadios; use Facebook\WebDriver\WebDriverSelect; use JetBrains\PhpStorm\Language; @@ -66,13 +67,13 @@ class WebdriverClassicDriver extends CoreDriver * @throws DriverException */ public function __construct( - string $browserName = null, - array $desiredCapabilities = null, - string $webDriverHost = null + string $browserName = self::DEFAULT_BROWSER, + array $desiredCapabilities = [], + string $webDriverHost = 'http://localhost:4444/wd/hub' ) { - $this->browserName = $browserName ?? self::DEFAULT_BROWSER; - $this->setDesiredCapabilities($this->initCapabilities($desiredCapabilities ?? [])); - $this->webDriverHost = $webDriverHost ?? 'http://localhost:4444/wd/hub'; + $this->browserName = $browserName; + $this->setDesiredCapabilities($this->initCapabilities($desiredCapabilities)); + $this->webDriverHost = $webDriverHost; } // @@ -141,7 +142,7 @@ public function reset(): void /** * {@inheritdoc} */ - public function visit($url): void + public function visit(string $url): void { $this->getWebDriver()->navigate()->to($url); } @@ -181,7 +182,7 @@ public function back(): void /** * {@inheritdoc} */ - public function switchToWindow($name = null): void + public function switchToWindow(?string $name = null): void { if ($name === null) { $name = $this->initialWindowName; @@ -197,7 +198,7 @@ public function switchToWindow($name = null): void /** * {@inheritdoc} */ - public function switchToIFrame($name = null): void + public function switchToIFrame(?string $name = null): void { $frameQuery = $name; if ($name && $this->getWebDriver()->isW3cCompliant()) { @@ -214,7 +215,7 @@ public function switchToIFrame($name = null): void /** * {@inheritdoc} */ - public function setCookie($name, $value = null): void + public function setCookie(string $name, ?string $value = null): void { if (null === $value) { $this->getWebDriver()->manage()->deleteCookieNamed($name); @@ -234,7 +235,7 @@ public function setCookie($name, $value = null): void /** * {@inheritdoc} */ - public function getCookie($name): ?string + public function getCookie(string $name): ?string { try { $result = $this->getWebDriver()->manage()->getCookieNamed($name); @@ -245,12 +246,7 @@ public function getCookie($name): ?string return null; } - $result = $result->getValue(); - if ($result === null) { - return null; - } - - return rawurldecode($result); + return rawurldecode($result->getValue()); } /** @@ -305,9 +301,9 @@ public function getWindowName(): string /** * {@inheritdoc} */ - public function findElementXpaths( + protected function findElementXpaths( #[Language('XPath')] - $xpath + string $xpath ): array { $nodes = $this->getWebDriver()->findElements(WebDriverBy::xpath($xpath)); @@ -324,7 +320,7 @@ public function findElementXpaths( */ public function getTagName( #[Language('XPath')] - $xpath + string $xpath ): string { return $this->findElement($xpath)->getTagName(); } @@ -334,7 +330,7 @@ public function getTagName( */ public function getText( #[Language('XPath')] - $xpath + string $xpath ): string { return str_replace(["\r", "\n"], ' ', $this->findElement($xpath)->getText()); } @@ -344,7 +340,7 @@ public function getText( */ public function getHtml( #[Language('XPath')] - $xpath + string $xpath ): string { return $this->executeJsOnXpath($xpath, 'return arguments[0].innerHTML;'); } @@ -354,7 +350,7 @@ public function getHtml( */ public function getOuterHtml( #[Language('XPath')] - $xpath + string $xpath ): string { return $this->executeJsOnXpath($xpath, 'return arguments[0].outerHTML;'); } @@ -364,8 +360,8 @@ public function getOuterHtml( */ public function getAttribute( #[Language('XPath')] - $xpath, - $name + string $xpath, + string $name ): ?string { $escapedName = $this->jsonEncode($name, 'get attribute', 'attribute name'); $script = "return arguments[0].getAttribute($escapedName)"; @@ -378,70 +374,43 @@ public function getAttribute( */ public function getValue( #[Language('XPath')] - $xpath + string $xpath ) { $element = $this->findElement($xpath); $elementName = strtolower($element->getTagName() ?? ''); $elementType = strtolower((string)$element->getAttribute('type')); + $widgetType = $elementName === 'input' ? $elementType : $elementName; - // Getting the value of a checkbox returns its value if selected. - if ($elementName === 'input' && $elementType === 'checkbox') { - return $element->isSelected() ? $element->getAttribute('value') : null; - } - - if ($elementName === 'input' && $elementType === 'radio') { - $script = <<executeJsOnElement($element, $script); - } - - // Using $element->attribute('value') on a select only returns the first selected option - // even when it is a multiple select, so a custom retrieval is needed. - if ($elementName === 'select' && $element->getAttribute('multiple')) { - $script = <<getFirstSelectedOption()->getAttribute('value'); + } catch (NoSuchElementException $e) { + return null; } - } - - return value; - JS; - - return $this->executeJsOnElement($element, $script); - } - // use textarea.value rather than textarea.getAttribute(value) for chrome 91+ support - if ($elementName === 'textarea') { - $script = <<executeJsOnElement($element, $script); + case $widgetType === 'checkbox': + // WebDriverCheckboxes is not suitable since it _always_ behaves as a group + return $element->isSelected() ? $element->getAttribute('value') : null; + + case $widgetType === 'select': + $selectElement = new WebDriverSelect($element); + $selectedOptions = array_map( + static fn(WebDriverElement $option) => $option->getAttribute('value'), + $selectElement->getAllSelectedOptions() + ); + return $selectElement->isMultiple() ? $selectedOptions : ($selectedOptions[0] ?? ''); + + default: + return $this->getWebDriver()->isW3cCompliant() + ? $element->getDomProperty('value') + : $this->executeJsOnElement($element, 'return arguments[0].value'); + } + } catch (Throwable $e) { + throw new DriverException("Cannot retrieve $widgetType value: {$e->getMessage()}", 0, $e); } - - return $element->getAttribute('value'); } /** @@ -449,13 +418,21 @@ public function getValue( */ public function setValue( #[Language('XPath')] - $xpath, + string $xpath, $value ): void { $element = $this->findElement($xpath); $elementName = strtolower($element->getTagName() ?? ''); switch ($elementName) { + case 'textarea': + if (!is_string($value)) { + throw new DriverException('Textarea value must be a string'); + } + $element->clear(); + $element->sendKeys($value); + break; + case 'select': if (is_array($value)) { $this->deselectAllOptions($element); @@ -467,11 +444,6 @@ public function setValue( $this->selectOptionOnElement($element, (string)$value); return; - case 'textarea': - $element->clear(); - $element->sendKeys($value); - break; - case 'input': $elementType = strtolower((string)$element->getAttribute('type')); switch ($elementType) { @@ -479,10 +451,13 @@ public function setValue( case 'image': case 'button': case 'reset': - $message = 'Cannot set value an element with XPath "%s" as it is not a select, textarea or textbox'; + $message = 'Cannot set value on element with XPath "%s" as it is not a select, textarea or textbox'; throw new DriverException(sprintf($message, $xpath)); case 'color': + if (!is_string($value)) { + throw new DriverException('Color value must be a string'); + } // one cannot simply type into a color field, nor clear it $this->executeJsOnElement( $element, @@ -511,21 +486,26 @@ public function setValue( return; case 'radio': - if (is_array($value)) { - throw new DriverException('Cannot select multiple radio buttons; value cannot be an array'); + if (!is_string($value)) { + throw new DriverException('Value must be a string'); } - $this->selectRadioValue($element, (string)$value); + $this->selectRadioValue($element, $value); return; case 'file': - // @todo - Check if this is correct way to upload files + if (!is_string($value)) { + throw new DriverException('Value must be a string'); + } $element->sendKeys($value); - // $element->postValue(['value' => [(string)$value]]); - return; + break; default: + if (!is_string($value)) { + throw new DriverException('Value must be a string'); + } $element->clear(); $element->sendKeys($value); + break; } } @@ -537,7 +517,7 @@ public function setValue( */ public function check( #[Language('XPath')] - $xpath + string $xpath ): void { $element = $this->findElement($xpath); $this->ensureInputType($element, $xpath, 'checkbox', 'check'); @@ -554,7 +534,7 @@ public function check( */ public function uncheck( #[Language('XPath')] - $xpath + string $xpath ): void { $element = $this->findElement($xpath); $this->ensureInputType($element, $xpath, 'checkbox', 'uncheck'); @@ -571,7 +551,7 @@ public function uncheck( */ public function isChecked( #[Language('XPath')] - $xpath + string $xpath ): bool { return $this->findElement($xpath)->isSelected(); } @@ -581,9 +561,9 @@ public function isChecked( */ public function selectOption( #[Language('XPath')] - $xpath, - $value, - $multiple = false + string $xpath, + string $value, + bool $multiple = false ): void { $element = $this->findElement($xpath); $tagName = strtolower($element->getTagName() ?? ''); @@ -607,7 +587,7 @@ public function selectOption( */ public function isSelected( #[Language('XPath')] - $xpath + string $xpath ): bool { return $this->findElement($xpath)->isSelected(); } @@ -617,7 +597,7 @@ public function isSelected( */ public function click( #[Language('XPath')] - $xpath + string $xpath ): void { $this->clickOnElement($this->findElement($xpath)); } @@ -627,7 +607,7 @@ public function click( */ public function doubleClick( #[Language('XPath')] - $xpath + string $xpath ): void { $this->doubleClickOnElement($this->findElement($xpath)); } @@ -637,7 +617,7 @@ public function doubleClick( */ public function rightClick( #[Language('XPath')] - $xpath + string $xpath ): void { $this->rightClickOnElement($this->findElement($xpath)); } @@ -647,15 +627,13 @@ public function rightClick( */ public function attachFile( #[Language('XPath')] - $xpath, + string $xpath, #[Language('file-reference')] - $path + string $path ): void { $element = $this->findElement($xpath); $this->ensureInputType($element, $xpath, 'file', 'attach a file on'); - - // @todo - Check if this is the correct way to upload files - $element->sendKeys($path); + $this->setValue($xpath, $path); } /** @@ -663,7 +641,7 @@ public function attachFile( */ public function isVisible( #[Language('XPath')] - $xpath + string $xpath ): bool { return $this->findElement($xpath)->isDisplayed(); } @@ -673,7 +651,7 @@ public function isVisible( */ public function mouseOver( #[Language('XPath')] - $xpath + string $xpath ): void { $this->mouseOverElement($this->findElement($xpath)); } @@ -683,7 +661,7 @@ public function mouseOver( */ public function focus( #[Language('XPath')] - $xpath + string $xpath ): void { $this->trigger($xpath, 'focus'); } @@ -693,7 +671,7 @@ public function focus( */ public function blur( #[Language('XPath')] - $xpath + string $xpath ): void { $this->trigger($xpath, 'blur'); } @@ -703,9 +681,9 @@ public function blur( */ public function keyPress( #[Language('XPath')] - $xpath, + string $xpath, $char, - $modifier = null + ?string $modifier = null ): void { $options = $this->charToSynOptions($char, $modifier); $this->trigger($xpath, 'keypress', $options); @@ -716,9 +694,9 @@ public function keyPress( */ public function keyDown( #[Language('XPath')] - $xpath, + string $xpath, $char, - $modifier = null + ?string $modifier = null ): void { $options = $this->charToSynOptions($char, $modifier); $this->trigger($xpath, 'keydown', $options); @@ -729,9 +707,9 @@ public function keyDown( */ public function keyUp( #[Language('XPath')] - $xpath, + string $xpath, $char, - $modifier = null + ?string $modifier = null ): void { $options = $this->charToSynOptions($char, $modifier); $this->trigger($xpath, 'keyup', $options); @@ -742,9 +720,9 @@ public function keyUp( */ public function dragTo( #[Language('XPath')] - $sourceXpath, + string $sourceXpath, #[Language('XPath')] - $destinationXpath + string $destinationXpath ): void { $source = $this->findElement($sourceXpath); $destination = $this->findElement($destinationXpath); @@ -756,11 +734,10 @@ public function dragTo( */ public function executeScript( #[Language('JavaScript')] - $script + string $script ): void { - if (preg_match('/^function[\s(]/', $script ?? '')) { - $script = preg_replace('/;$/', '', $script ?? ''); - $script = '(' . $script . ')'; + if (preg_match('/^function[\s(]/', $script)) { + $script = '(' . rtrim($script, ';') . ')'; } $this->getWebDriver()->executeScript($script); @@ -772,9 +749,9 @@ public function executeScript( */ public function evaluateScript( #[Language('JavaScript')] - $script + string $script ) { - if (strncmp(ltrim((string)$script), 'return ', 7) !== 0) { + if (strncmp(ltrim($script), 'return ', 7) !== 0) { $script = "return $script;"; } @@ -785,9 +762,9 @@ public function evaluateScript( * {@inheritdoc} */ public function wait( - $timeout, + int $timeout, #[Language('JavaScript')] - $condition + string $condition ): bool { $start = microtime(true); $end = $start + $timeout / 1000.0; @@ -803,7 +780,7 @@ public function wait( /** * {@inheritdoc} */ - public function resizeWindow($width, $height, $name = null): void + public function resizeWindow(int $width, int $height, ?string $name = null): void { $this->withWindow( $name, @@ -820,7 +797,7 @@ public function resizeWindow($width, $height, $name = null): void */ public function submitForm( #[Language('XPath')] - $xpath + string $xpath ): void { $this->findElement($xpath)->submit(); } @@ -828,7 +805,7 @@ public function submitForm( /** * {@inheritdoc} */ - public function maximizeWindow($name = null): void + public function maximizeWindow(?string $name = null): void { $this->withWindow( $name, @@ -975,7 +952,7 @@ private function getWebDriver(): RemoteWebDriver * * @see https://github.com/SeleniumHQ/selenium/wiki/DesiredCapabilities */ - private function initCapabilities(array $desiredCapabilities = []): DesiredCapabilities + private function initCapabilities(array $desiredCapabilities): DesiredCapabilities { // Build base capabilities $browserName = $this->browserName; diff --git a/tests/WebdriverClassicConfig.php b/tests/WebdriverClassicConfig.php index 340be6b..2adcf0b 100644 --- a/tests/WebdriverClassicConfig.php +++ b/tests/WebdriverClassicConfig.php @@ -28,7 +28,7 @@ public function createDriver(): DriverInterface $browser = getenv('WEB_FIXTURES_BROWSER') ?: null; $seleniumHost = $_SERVER['DRIVER_URL']; - return new WebdriverClassicDriver($browser, null, $seleniumHost); + return new WebdriverClassicDriver($browser, [], $seleniumHost); } public function mapRemoteFilePath($file): string From 386c9aef1860422fad3db4c6163f23e4c770a460 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Thu, 15 Jun 2023 18:43:04 +0200 Subject: [PATCH 30/47] Fix null arg problem; improve init capaibilities --- src/WebdriverClassicDriver.php | 58 +++++++++++++++++++++++++++----- tests/WebdriverClassicConfig.php | 2 +- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index 073c7e0..c23b895 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -20,6 +20,7 @@ use Facebook\WebDriver\Remote\DesiredCapabilities; use Facebook\WebDriver\Remote\RemoteWebDriver; use Facebook\WebDriver\Remote\RemoteWebElement; +use Facebook\WebDriver\Remote\WebDriverBrowserType; use Facebook\WebDriver\WebDriverBy; use Facebook\WebDriver\WebDriverDimension; use Facebook\WebDriver\WebDriverElement; @@ -955,18 +956,12 @@ private function getWebDriver(): RemoteWebDriver private function initCapabilities(array $desiredCapabilities): DesiredCapabilities { // Build base capabilities - $browserName = $this->browserName; - if ($browserName && method_exists(DesiredCapabilities::class, $browserName)) { - /** @var DesiredCapabilities $caps */ - $caps = DesiredCapabilities::$browserName(); - } else { - $caps = new DesiredCapabilities(); - } + $caps = $this->getBrowserSpecificCapabilities() ?? new DesiredCapabilities(); // Set defaults $defaults = array_merge( self::DEFAULT_CAPABILITIES['default'], - self::DEFAULT_CAPABILITIES[$browserName] ?? [] + self::DEFAULT_CAPABILITIES[$this->browserName] ?? [] ); foreach ($defaults as $key => $value) { if (is_null($caps->getCapability($key))) { @@ -982,6 +977,53 @@ private function initCapabilities(array $desiredCapabilities): DesiredCapabiliti return $caps; } + private function getBrowserSpecificCapabilities(): ?DesiredCapabilities + { + switch ($this->browserName) { + case WebDriverBrowserType::FIREFOX: + return DesiredCapabilities::firefox(); + + case WebDriverBrowserType::CHROME: + case WebDriverBrowserType::GOOGLECHROME: + return DesiredCapabilities::chrome(); + + case WebDriverBrowserType::SAFARI: + return DesiredCapabilities::safari(); + + case WebDriverBrowserType::OPERA: + return DesiredCapabilities::opera(); + + case WebDriverBrowserType::MICROSOFT_EDGE: + return DesiredCapabilities::microsoftEdge(); + + case WebDriverBrowserType::IE: + case WebDriverBrowserType::IEXPLORE: + return DesiredCapabilities::internetExplorer(); + + case WebDriverBrowserType::ANDROID: + return DesiredCapabilities::android(); + + case WebDriverBrowserType::HTMLUNIT: + return DesiredCapabilities::htmlUnit(); + + case WebDriverBrowserType::IPHONE: + return DesiredCapabilities::iphone(); + + case WebDriverBrowserType::IPAD: + return DesiredCapabilities::ipad(); + + case WebDriverBrowserType::FIREFOX_PROXY: + case WebDriverBrowserType::FIREFOX_CHROME: + case WebDriverBrowserType::SAFARI_PROXY: + case WebDriverBrowserType::IEXPLORE_PROXY: + case WebDriverBrowserType::KONQUEROR: + case WebDriverBrowserType::MOCK: + case WebDriverBrowserType::IE_HTA: + default: + return null; + } + } + /** * @throws DriverException */ diff --git a/tests/WebdriverClassicConfig.php b/tests/WebdriverClassicConfig.php index 2adcf0b..a163c7f 100644 --- a/tests/WebdriverClassicConfig.php +++ b/tests/WebdriverClassicConfig.php @@ -25,7 +25,7 @@ public static function getInstance(): self */ public function createDriver(): DriverInterface { - $browser = getenv('WEB_FIXTURES_BROWSER') ?: null; + $browser = getenv('WEB_FIXTURES_BROWSER') ?: WebdriverClassicDriver::DEFAULT_BROWSER; $seleniumHost = $_SERVER['DRIVER_URL']; return new WebdriverClassicDriver($browser, [], $seleniumHost); From d1d21cef9b9df86673fbbb4f9dc28c182fff657f Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Thu, 15 Jun 2023 18:51:15 +0200 Subject: [PATCH 31/47] Remove inheritdoc --- src/WebdriverClassicDriver.php | 138 --------------------------------- 1 file changed, 138 deletions(-) diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index c23b895..3cb560f 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -79,9 +79,6 @@ public function __construct( // - /** - * {@inheritdoc} - */ public function start(): void { if ($this->isStarted()) { @@ -97,17 +94,11 @@ public function start(): void } } - /** - * {@inheritdoc} - */ public function isStarted(): bool { return $this->webDriver !== null; } - /** - * {@inheritdoc} - */ public function stop(): void { if (!$this->webDriver) { @@ -140,49 +131,31 @@ public function reset(): void $this->getWebDriver()->manage()->deleteAllCookies(); } - /** - * {@inheritdoc} - */ public function visit(string $url): void { $this->getWebDriver()->navigate()->to($url); } - /** - * {@inheritdoc} - */ public function getCurrentUrl(): string { return $this->getWebDriver()->getCurrentURL(); } - /** - * {@inheritdoc} - */ public function reload(): void { $this->getWebDriver()->navigate()->refresh(); } - /** - * {@inheritdoc} - */ public function forward(): void { $this->getWebDriver()->navigate()->forward(); } - /** - * {@inheritdoc} - */ public function back(): void { $this->getWebDriver()->navigate()->back(); } - /** - * {@inheritdoc} - */ public function switchToWindow(?string $name = null): void { if ($name === null) { @@ -196,9 +169,6 @@ public function switchToWindow(?string $name = null): void $this->getWebDriver()->switchTo()->window((string)$name); } - /** - * {@inheritdoc} - */ public function switchToIFrame(?string $name = null): void { $frameQuery = $name; @@ -213,9 +183,6 @@ public function switchToIFrame(?string $name = null): void $this->getWebDriver()->switchTo()->frame($frameQuery); } - /** - * {@inheritdoc} - */ public function setCookie(string $name, ?string $value = null): void { if (null === $value) { @@ -233,9 +200,6 @@ public function setCookie(string $name, ?string $value = null): void $this->getWebDriver()->manage()->addCookie($cookieArray); } - /** - * {@inheritdoc} - */ public function getCookie(string $name): ?string { try { @@ -250,25 +214,16 @@ public function getCookie(string $name): ?string return rawurldecode($result->getValue()); } - /** - * {@inheritdoc} - */ public function getContent(): string { return $this->getWebDriver()->getPageSource(); } - /** - * {@inheritdoc} - */ public function getScreenshot(): string { return $this->getWebDriver()->takeScreenshot(); } - /** - * {@inheritdoc} - */ public function getWindowNames(): array { $origWindow = $this->getWebDriver()->getWindowHandle(); @@ -285,9 +240,6 @@ public function getWindowNames(): array } } - /** - * {@inheritdoc} - */ public function getWindowName(): string { $name = (string)$this->evaluateScript('window.name'); @@ -299,9 +251,6 @@ public function getWindowName(): string return $name; } - /** - * {@inheritdoc} - */ protected function findElementXpaths( #[Language('XPath')] string $xpath @@ -316,9 +265,6 @@ protected function findElementXpaths( return $elements; } - /** - * {@inheritdoc} - */ public function getTagName( #[Language('XPath')] string $xpath @@ -326,9 +272,6 @@ public function getTagName( return $this->findElement($xpath)->getTagName(); } - /** - * {@inheritdoc} - */ public function getText( #[Language('XPath')] string $xpath @@ -336,9 +279,6 @@ public function getText( return str_replace(["\r", "\n"], ' ', $this->findElement($xpath)->getText()); } - /** - * {@inheritdoc} - */ public function getHtml( #[Language('XPath')] string $xpath @@ -346,9 +286,6 @@ public function getHtml( return $this->executeJsOnXpath($xpath, 'return arguments[0].innerHTML;'); } - /** - * {@inheritdoc} - */ public function getOuterHtml( #[Language('XPath')] string $xpath @@ -356,9 +293,6 @@ public function getOuterHtml( return $this->executeJsOnXpath($xpath, 'return arguments[0].outerHTML;'); } - /** - * {@inheritdoc} - */ public function getAttribute( #[Language('XPath')] string $xpath, @@ -370,9 +304,6 @@ public function getAttribute( return $this->executeJsOnXpath($xpath, $script); } - /** - * {@inheritdoc} - */ public function getValue( #[Language('XPath')] string $xpath @@ -414,9 +345,6 @@ public function getValue( } } - /** - * {@inheritdoc} - */ public function setValue( #[Language('XPath')] string $xpath, @@ -513,9 +441,6 @@ public function setValue( $this->trigger($xpath, 'blur'); } - /** - * {@inheritdoc} - */ public function check( #[Language('XPath')] string $xpath @@ -530,9 +455,6 @@ public function check( $this->clickOnElement($element); } - /** - * {@inheritdoc} - */ public function uncheck( #[Language('XPath')] string $xpath @@ -547,9 +469,6 @@ public function uncheck( $this->clickOnElement($element); } - /** - * {@inheritdoc} - */ public function isChecked( #[Language('XPath')] string $xpath @@ -557,9 +476,6 @@ public function isChecked( return $this->findElement($xpath)->isSelected(); } - /** - * {@inheritdoc} - */ public function selectOption( #[Language('XPath')] string $xpath, @@ -583,9 +499,6 @@ public function selectOption( throw new DriverException(sprintf($message, $xpath)); } - /** - * {@inheritdoc} - */ public function isSelected( #[Language('XPath')] string $xpath @@ -593,9 +506,6 @@ public function isSelected( return $this->findElement($xpath)->isSelected(); } - /** - * {@inheritdoc} - */ public function click( #[Language('XPath')] string $xpath @@ -603,9 +513,6 @@ public function click( $this->clickOnElement($this->findElement($xpath)); } - /** - * {@inheritdoc} - */ public function doubleClick( #[Language('XPath')] string $xpath @@ -613,9 +520,6 @@ public function doubleClick( $this->doubleClickOnElement($this->findElement($xpath)); } - /** - * {@inheritdoc} - */ public function rightClick( #[Language('XPath')] string $xpath @@ -623,9 +527,6 @@ public function rightClick( $this->rightClickOnElement($this->findElement($xpath)); } - /** - * {@inheritdoc} - */ public function attachFile( #[Language('XPath')] string $xpath, @@ -637,9 +538,6 @@ public function attachFile( $this->setValue($xpath, $path); } - /** - * {@inheritdoc} - */ public function isVisible( #[Language('XPath')] string $xpath @@ -647,9 +545,6 @@ public function isVisible( return $this->findElement($xpath)->isDisplayed(); } - /** - * {@inheritdoc} - */ public function mouseOver( #[Language('XPath')] string $xpath @@ -657,9 +552,6 @@ public function mouseOver( $this->mouseOverElement($this->findElement($xpath)); } - /** - * {@inheritdoc} - */ public function focus( #[Language('XPath')] string $xpath @@ -667,9 +559,6 @@ public function focus( $this->trigger($xpath, 'focus'); } - /** - * {@inheritdoc} - */ public function blur( #[Language('XPath')] string $xpath @@ -677,9 +566,6 @@ public function blur( $this->trigger($xpath, 'blur'); } - /** - * {@inheritdoc} - */ public function keyPress( #[Language('XPath')] string $xpath, @@ -690,9 +576,6 @@ public function keyPress( $this->trigger($xpath, 'keypress', $options); } - /** - * {@inheritdoc} - */ public function keyDown( #[Language('XPath')] string $xpath, @@ -703,9 +586,6 @@ public function keyDown( $this->trigger($xpath, 'keydown', $options); } - /** - * {@inheritdoc} - */ public function keyUp( #[Language('XPath')] string $xpath, @@ -716,9 +596,6 @@ public function keyUp( $this->trigger($xpath, 'keyup', $options); } - /** - * {@inheritdoc} - */ public function dragTo( #[Language('XPath')] string $sourceXpath, @@ -730,9 +607,6 @@ public function dragTo( $this->getWebDriver()->action()->dragAndDrop($source, $destination)->perform(); } - /** - * {@inheritdoc} - */ public function executeScript( #[Language('JavaScript')] string $script @@ -759,9 +633,6 @@ public function evaluateScript( return $this->getWebDriver()->executeScript($script); } - /** - * {@inheritdoc} - */ public function wait( int $timeout, #[Language('JavaScript')] @@ -778,9 +649,6 @@ public function wait( return (bool)$result; } - /** - * {@inheritdoc} - */ public function resizeWindow(int $width, int $height, ?string $name = null): void { $this->withWindow( @@ -793,9 +661,6 @@ public function resizeWindow(int $width, int $height, ?string $name = null): voi ); } - /** - * {@inheritdoc} - */ public function submitForm( #[Language('XPath')] string $xpath @@ -803,9 +668,6 @@ public function submitForm( $this->findElement($xpath)->submit(); } - /** - * {@inheritdoc} - */ public function maximizeWindow(?string $name = null): void { $this->withWindow( From 01a8a75afed392fa072ae67fff2cd441115e91dd Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Thu, 15 Jun 2023 19:09:11 +0200 Subject: [PATCH 32/47] Each retry should not take longer than 10s --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97dd5c3..26afe0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,7 @@ jobs: - name: Wait for selenium to start run: | - curl --head -X GET --silent --show-error --retry 60 --retry-connrefused --retry-delay 1 http://172.18.0.2:4444 + curl --head -X GET --silent --show-error --retry 60 --retry-connrefused --retry-delay 1 --max-time 10 http://172.18.0.2:4444 - name: Run tests env: From 18ef9130aa27ab6c922b6ba9d7b184a8fdfdf441 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Fri, 16 Jun 2023 12:08:04 +0200 Subject: [PATCH 33/47] Add a single test for edge --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26afe0f..e3c6806 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,10 @@ jobs: php: [ '7.4', '8.0', '8.1', '8.2' ] browser: [ 'firefox', 'chrome' ] selenium: [ '2.53.1', '3', '4' ] + include: + - php: '7.4' + browser: 'edge' + selenium: '4' fail-fast: false steps: From 2130f2f97ffd95b1678a563a6b4628ed4da40980 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Fri, 16 Jun 2023 16:32:42 +0200 Subject: [PATCH 34/47] Fix edge default config --- src/WebdriverClassicDriver.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index 3cb560f..b6a8374 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -41,13 +41,27 @@ class WebdriverClassicDriver extends CoreDriver 'deviceOrientation' => 'landscape', 'deviceType' => 'desktop', ], + 'chrome' => [ 'goog:chromeOptions' => [ + // disable "Chrome is being controlled.." notification bar 'excludeSwitches' => ['enable-automation'], ], ], + 'firefox' => [ ], + + 'edge' => [ + 'ms:edgeOptions' => [ + // disable "Microsoft Edge is being controlled.." notification bar + 'excludeSwitches' => ['enable-automation'], + // disable menu shown when text is selected (which interferes with double-clicking) + 'prefs' => [ + 'edge_quick_search.show_mini_menu' => false, + ], + ], + ], ]; private const W3C_WINDOW_HANDLE_PREFIX = 'w3cwh:'; From 7a7e5c22d3c9fe24dd047266a8b07619b27d098d Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Fri, 16 Jun 2023 16:34:42 +0200 Subject: [PATCH 35/47] Simplify capabilities stuff --- src/WebdriverClassicDriver.php | 38 +--------- .../Custom/ChromeDesiredCapabilitiesTest.php | 75 ------------------- .../Custom/FirefoxDesiredCapabilitiesTest.php | 75 ------------------- 3 files changed, 1 insertion(+), 187 deletions(-) delete mode 100644 tests/Custom/ChromeDesiredCapabilitiesTest.php delete mode 100644 tests/Custom/FirefoxDesiredCapabilitiesTest.php diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index b6a8374..64b1d64 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -87,7 +87,7 @@ public function __construct( string $webDriverHost = 'http://localhost:4444/wd/hub' ) { $this->browserName = $browserName; - $this->setDesiredCapabilities($this->initCapabilities($desiredCapabilities)); + $this->desiredCapabilities=$this->initCapabilities($desiredCapabilities); $this->webDriverHost = $webDriverHost; } @@ -722,30 +722,6 @@ public function getWebDriverSessionId(): ?string : null; } - /** - * Sets the desired capabilities - called on construction. - * - * @see http://code.google.com/p/selenium/wiki/DesiredCapabilities - * - * @api - * @param array|DesiredCapabilities $desiredCapabilities - * @throws DriverException - */ - public function setDesiredCapabilities($desiredCapabilities): self - { - if ($this->isStarted()) { - throw new DriverException('Unable to set desiredCapabilities, the session has already started'); - } - - if (is_array($desiredCapabilities)) { - $desiredCapabilities = new DesiredCapabilities($desiredCapabilities); - } - - $this->desiredCapabilities = $desiredCapabilities; - - return $this; - } - /** * Sets the timeouts to apply to the webdriver session * @@ -762,18 +738,6 @@ public function setTimeouts(array $timeouts): void } } - /** - * Gets the final desired capabilities (as sent to Selenium). - * - * @see http://code.google.com/p/selenium/wiki/DesiredCapabilities - * - * @api - */ - public function getDesiredCapabilities(): array - { - return $this->desiredCapabilities->toArray(); - } - /** * Globally press a key i.e. not typing into an element. * diff --git a/tests/Custom/ChromeDesiredCapabilitiesTest.php b/tests/Custom/ChromeDesiredCapabilitiesTest.php deleted file mode 100644 index 7310898..0000000 --- a/tests/Custom/ChromeDesiredCapabilitiesTest.php +++ /dev/null @@ -1,75 +0,0 @@ -getSession()->getDriver(); - if ($driver instanceof WebdriverClassicDriver && $driver->getBrowserName() !== 'chrome') { - $this->markTestSkipped('This test only applies to Chrome'); - } - } - - protected function tearDown(): void - { - if ($this->getSession()->isStarted()) { - $this->getSession()->stop(); - } - - parent::tearDown(); - } - - public function testGetDesiredCapabilities(): void - { - $caps = [ - 'browserName' => 'chrome', - 'version' => '30', - 'platform' => 'ANY', - 'browserVersion' => '30', - 'browser' => 'chrome', - 'name' => 'PhpWebDriver Mink Driver Test', - 'deviceOrientation' => 'portrait', - 'deviceType' => 'tablet', - 'goog:chromeOptions' => [ - 'prefs' => [], - ], - ]; - - $driver = new WebdriverClassicDriver('chrome', $caps); - - $this->assertNotEmpty($driver->getDesiredCapabilities(), 'desiredCapabilities empty'); - $this->assertIsArray($driver->getDesiredCapabilities()); - $this->assertEquals($caps, $driver->getDesiredCapabilities()); - } - - public function testSetDesiredCapabilities(): void - { - $caps = [ - 'browserName' => 'chrome', - 'version' => '30', - 'platform' => 'ANY', - 'browserVersion' => '30', - 'browser' => 'chrome', - 'name' => 'PhpWebDriver Mink Driver Test', - 'deviceOrientation' => 'portrait', - 'deviceType' => 'tablet', - ]; - $session = $this->getSession(); - $session->start(); - $driver = $this->getSession()->getDriver(); - assert($driver instanceof WebdriverClassicDriver); - - $this->expectException(DriverException::class); - $this->expectExceptionMessage('Unable to set desiredCapabilities, the session has already started'); - - $driver->setDesiredCapabilities($caps); - } -} diff --git a/tests/Custom/FirefoxDesiredCapabilitiesTest.php b/tests/Custom/FirefoxDesiredCapabilitiesTest.php deleted file mode 100644 index ebe2efb..0000000 --- a/tests/Custom/FirefoxDesiredCapabilitiesTest.php +++ /dev/null @@ -1,75 +0,0 @@ -getSession()->getDriver(); - if ($driver instanceof WebdriverClassicDriver && $driver->getBrowserName() !== 'firefox') { - $this->markTestSkipped('This test only applies to Firefox'); - } - } - - protected function tearDown(): void - { - if ($this->getSession()->isStarted()) { - $this->getSession()->stop(); - } - - parent::tearDown(); - } - - public function testGetDesiredCapabilities(): void - { - $caps = [ - 'browserName' => 'firefox', - 'version' => '30', - 'platform' => 'ANY', - 'browserVersion' => '30', - 'browser' => 'firefox', - 'name' => 'PhpWebDriver Mink Driver Test', - 'deviceOrientation' => 'portrait', - 'deviceType' => 'tablet', - 'moz:firefoxOptions' => [ - 'prefs' => [], - ], - ]; - - $driver = new WebdriverClassicDriver('firefox', $caps); - - $this->assertNotEmpty($driver->getDesiredCapabilities(), 'desiredCapabilities empty'); - $this->assertIsArray($driver->getDesiredCapabilities()); - $this->assertEquals($caps, $driver->getDesiredCapabilities()); - } - - public function testSetDesiredCapabilities(): void - { - $caps = [ - 'browserName' => 'firefox', - 'version' => '30', - 'platform' => 'ANY', - 'browserVersion' => '30', - 'browser' => 'firefox', - 'name' => 'PhpWebDriver Mink Driver Test', - 'deviceOrientation' => 'portrait', - 'deviceType' => 'tablet', - ]; - $session = $this->getSession(); - $session->start(); - $driver = $this->getSession()->getDriver(); - assert($driver instanceof WebdriverClassicDriver); - - $this->expectException(DriverException::class); - $this->expectExceptionMessage('Unable to set desiredCapabilities, the session has already started'); - - $driver->setDesiredCapabilities($caps); - } -} From f2357b2d3b956a2d41e13809627076915fea0e98 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Fri, 16 Jun 2023 16:55:03 +0200 Subject: [PATCH 36/47] Minor fixes --- .github/workflows/ci.yml | 2 +- src/WebdriverClassicDriver.php | 2 +- tests/WebdriverClassicConfig.php | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3c6806..94a56e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,7 +91,7 @@ jobs: - name: Archive logs artifacts uses: actions/upload-artifact@v3 - if: always() + if: ${{ failure() }} with: name: logs_php-${{ matrix.php }}_selenium-${{ matrix.selenium }}_${{ matrix.browser }} path: logs diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index 64b1d64..7ad0d55 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -87,7 +87,7 @@ public function __construct( string $webDriverHost = 'http://localhost:4444/wd/hub' ) { $this->browserName = $browserName; - $this->desiredCapabilities=$this->initCapabilities($desiredCapabilities); + $this->desiredCapabilities = $this->initCapabilities($desiredCapabilities); $this->webDriverHost = $webDriverHost; } diff --git a/tests/WebdriverClassicConfig.php b/tests/WebdriverClassicConfig.php index a163c7f..532c5b3 100644 --- a/tests/WebdriverClassicConfig.php +++ b/tests/WebdriverClassicConfig.php @@ -20,7 +20,6 @@ public static function getInstance(): self } /** - * {@inheritdoc} * @throws DriverException */ public function createDriver(): DriverInterface From c36a0dd385b60b4f2eadd63c7f10c228a643659e Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Sat, 17 Jun 2023 14:20:16 +0200 Subject: [PATCH 37/47] Remove redundant test --- tests/Custom/WindowNameTest.php | 33 --------------------------------- 1 file changed, 33 deletions(-) delete mode 100644 tests/Custom/WindowNameTest.php diff --git a/tests/Custom/WindowNameTest.php b/tests/Custom/WindowNameTest.php deleted file mode 100644 index 0293cef..0000000 --- a/tests/Custom/WindowNameTest.php +++ /dev/null @@ -1,33 +0,0 @@ -getSession()->start(); - } - - protected function tearDown(): void - { - $this->getSession()->stop(); - - parent::tearDown(); - } - - public function testWindowNames(): void - { - $windowNames = $this->getSession()->getWindowNames(); - $this->assertArrayHasKey(0, $windowNames); - - $windowName = $this->getSession()->getWindowName(); - - $this->assertIsString($windowName); - $this->assertContains($windowName, $windowNames, 'The current window name is one of the available window names.'); - } -} From 302fe6641af53e310d0d5d85024e9d77c4b52ecc Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Sat, 17 Jun 2023 15:04:43 +0200 Subject: [PATCH 38/47] Improve set/getValue --- src/WebdriverClassicDriver.php | 155 ++++++++++++++++----------------- 1 file changed, 74 insertions(+), 81 deletions(-) diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index 7ad0d55..265252b 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -323,13 +323,14 @@ public function getValue( string $xpath ) { $element = $this->findElement($xpath); - $elementName = strtolower($element->getTagName() ?? ''); - $elementType = strtolower((string)$element->getAttribute('type')); - $widgetType = $elementName === 'input' ? $elementType : $elementName; + $widgetType = strtolower($element->getTagName() ?? ''); + if ($widgetType === 'input') { + $widgetType = strtolower((string)$element->getAttribute('type')); + } try { - switch (true) { - case $widgetType === 'radio': + switch ($widgetType) { + case 'radio': $radioElement = new WebDriverRadios($element); try { return $radioElement->getFirstSelectedOption()->getAttribute('value'); @@ -337,11 +338,11 @@ public function getValue( return null; } - case $widgetType === 'checkbox': + case 'checkbox': // WebDriverCheckboxes is not suitable since it _always_ behaves as a group return $element->isSelected() ? $element->getAttribute('value') : null; - case $widgetType === 'select': + case 'select': $selectElement = new WebDriverSelect($element); $selectedOptions = array_map( static fn(WebDriverElement $option) => $option->getAttribute('value'), @@ -365,91 +366,83 @@ public function setValue( $value ): void { $element = $this->findElement($xpath); - $elementName = strtolower($element->getTagName() ?? ''); + $widgetType = strtolower($element->getTagName() ?? ''); + if ($widgetType === 'input') { + $widgetType = strtolower((string)$element->getAttribute('type')); + } - switch ($elementName) { - case 'textarea': - if (!is_string($value)) { - throw new DriverException('Textarea value must be a string'); - } - $element->clear(); - $element->sendKeys($value); - break; - - case 'select': - if (is_array($value)) { - $this->deselectAllOptions($element); - foreach ($value as $option) { - $this->selectOptionOnElement($element, $option, true); + try { + switch ($widgetType) { + case 'select': + if (is_array($value)) { + $this->deselectAllOptions($element); + foreach ($value as $option) { + $this->selectOptionOnElement($element, $option, true); + } + return; } + is_string($value) or throw new DriverException("Value for $widgetType must be a string"); + $this->selectOptionOnElement($element, $value); return; - } - $this->selectOptionOnElement($element, (string)$value); - return; - - case 'input': - $elementType = strtolower((string)$element->getAttribute('type')); - switch ($elementType) { - case 'submit': - case 'image': - case 'button': - case 'reset': - $message = 'Cannot set value on element with XPath "%s" as it is not a select, textarea or textbox'; - throw new DriverException(sprintf($message, $xpath)); - - case 'color': - if (!is_string($value)) { - throw new DriverException('Color value must be a string'); - } - // one cannot simply type into a color field, nor clear it + + case 'submit': + case 'image': + case 'button': + case 'reset': + $message = 'Cannot set value on element with XPath "%s" as it is not a select, textarea or textbox'; + throw new DriverException(sprintf($message, $xpath)); + + case 'color': + is_string($value) or throw new DriverException("Value for $widgetType must be a string"); + // one cannot simply type into a color field, nor clear it + $this->executeJsOnElement( + $element, + 'arguments[0].value = ' . $this->jsonEncode($value, 'set value', 'value') + ); + break; + + case 'date': + case 'time': + try { + $element->clear(); + $element->sendKeys($value); + } catch (WebDriverException $ex) { + // fix for Selenium 2 compatibility, since it's not able to clear these specific fields $this->executeJsOnElement( $element, 'arguments[0].value = ' . $this->jsonEncode($value, 'set value', 'value') ); - break; - - case 'date': - case 'time': - try { - $element->clear(); - $element->sendKeys($value); - } catch (WebDriverException $ex) { - // fix for Selenium 2 compatibility, since it's not able to clear these specific fields - $this->executeJsOnElement( - $element, - 'arguments[0].value = ' . $this->jsonEncode($value, 'set value', 'value') - ); - } - break; + } + break; - case 'checkbox': - if ($element->isSelected() xor $value) { - $this->clickOnElement($element); - } - return; + case 'checkbox': + is_bool($value) or throw new DriverException("Value for $widgetType must be a boolean"); + if ($element->isSelected() xor $value) { + $this->clickOnElement($element); + } + return; - case 'radio': - if (!is_string($value)) { - throw new DriverException('Value must be a string'); - } - $this->selectRadioValue($element, $value); - return; + case 'radio': + is_string($value) or throw new DriverException("Value for $widgetType must be a string"); + $this->selectRadioValue($element, $value); + return; - case 'file': - if (!is_string($value)) { - throw new DriverException('Value must be a string'); - } - $element->sendKeys($value); - break; + case 'file': + is_string($value) or throw new DriverException("Value for $widgetType must be a string"); + $element->sendKeys($value); + break; - default: - if (!is_string($value)) { - throw new DriverException('Value must be a string'); - } - $element->clear(); - $element->sendKeys($value); - break; - } + case 'text': + case 'password': + case 'textarea': + default: + is_string($value) or throw new DriverException("Value for $widgetType must be a string"); + $element->clear(); + $element->sendKeys($value); + break; + } + } catch (Throwable $e) { + throw new DriverException("Cannot retrieve $widgetType value: {$e->getMessage()}", 0, $e); } $this->trigger($xpath, 'blur'); From be08632e0aecc7e463dcadcdd5535555813c5d6c Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Sat, 17 Jun 2023 21:09:31 +0200 Subject: [PATCH 39/47] Fix syntax; improve code --- src/WebdriverClassicDriver.php | 102 ++++++++++++++++++++++--------- tests/WebdriverClassicConfig.php | 3 - 2 files changed, 74 insertions(+), 31 deletions(-) diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index 265252b..e095feb 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -16,6 +16,7 @@ use Behat\Mink\Exception\DriverException; use Facebook\WebDriver\Exception\NoSuchCookieException; use Facebook\WebDriver\Exception\NoSuchElementException; +use Facebook\WebDriver\Exception\UnsupportedOperationException; use Facebook\WebDriver\Exception\WebDriverException; use Facebook\WebDriver\Remote\DesiredCapabilities; use Facebook\WebDriver\Remote\RemoteWebDriver; @@ -78,9 +79,6 @@ class WebdriverClassicDriver extends CoreDriver private ?string $initialWindowName = null; - /** - * @throws DriverException - */ public function __construct( string $browserName = self::DEFAULT_BROWSER, array $desiredCapabilities = [], @@ -297,14 +295,14 @@ public function getHtml( #[Language('XPath')] string $xpath ): string { - return $this->executeJsOnXpath($xpath, 'return arguments[0].innerHTML;'); + return $this->getElementDomProperty($this->findElement($xpath), 'innerHTML'); } public function getOuterHtml( #[Language('XPath')] string $xpath ): string { - return $this->executeJsOnXpath($xpath, 'return arguments[0].outerHTML;'); + return $this->getElementDomProperty($this->findElement($xpath), 'outerHTML'); } public function getAttribute( @@ -312,10 +310,8 @@ public function getAttribute( string $xpath, string $name ): ?string { - $escapedName = $this->jsonEncode($name, 'get attribute', 'attribute name'); - $script = "return arguments[0].getAttribute($escapedName)"; - - return $this->executeJsOnXpath($xpath, $script); + $result = $this->findElement($xpath)->getAttribute($name); + return $result === true ? '' : $result; } public function getValue( @@ -351,9 +347,7 @@ public function getValue( return $selectElement->isMultiple() ? $selectedOptions : ($selectedOptions[0] ?? ''); default: - return $this->getWebDriver()->isW3cCompliant() - ? $element->getDomProperty('value') - : $this->executeJsOnElement($element, 'return arguments[0].value'); + return $this->getElementDomProperty($element, 'value'); } } catch (Throwable $e) { throw new DriverException("Cannot retrieve $widgetType value: {$e->getMessage()}", 0, $e); @@ -381,7 +375,7 @@ public function setValue( } return; } - is_string($value) or throw new DriverException("Value for $widgetType must be a string"); + $this->assertString($value, "Value for $widgetType must be a string"); $this->selectOptionOnElement($element, $value); return; @@ -393,12 +387,9 @@ public function setValue( throw new DriverException(sprintf($message, $xpath)); case 'color': - is_string($value) or throw new DriverException("Value for $widgetType must be a string"); + $this->assertString($value, "Value for $widgetType must be a string"); // one cannot simply type into a color field, nor clear it - $this->executeJsOnElement( - $element, - 'arguments[0].value = ' . $this->jsonEncode($value, 'set value', 'value') - ); + $this->setElementDomProperty($element, 'value', $value); break; case 'date': @@ -407,28 +398,25 @@ public function setValue( $element->clear(); $element->sendKeys($value); } catch (WebDriverException $ex) { - // fix for Selenium 2 compatibility, since it's not able to clear these specific fields - $this->executeJsOnElement( - $element, - 'arguments[0].value = ' . $this->jsonEncode($value, 'set value', 'value') - ); + // fix for Selenium 2 compatibility, since it's not able to clear or set these specific fields + $this->setElementDomProperty($element, 'value', $value); } break; case 'checkbox': - is_bool($value) or throw new DriverException("Value for $widgetType must be a boolean"); + $this->assertBool($value, "Value for $widgetType must be a boolean"); if ($element->isSelected() xor $value) { $this->clickOnElement($element); } return; case 'radio': - is_string($value) or throw new DriverException("Value for $widgetType must be a string"); + $this->assertString($value, "Value for $widgetType must be a string"); $this->selectRadioValue($element, $value); return; case 'file': - is_string($value) or throw new DriverException("Value for $widgetType must be a string"); + $this->assertString($value, "Value for $widgetType must be a string"); $element->sendKeys($value); break; @@ -436,7 +424,7 @@ public function setValue( case 'password': case 'textarea': default: - is_string($value) or throw new DriverException("Value for $widgetType must be a string"); + $this->assertString($value, "Value for $widgetType must be a string"); $element->clear(); $element->sendKeys($value); break; @@ -1056,7 +1044,7 @@ private function withWindow(?string $name, callable $callback): void private function findElement( #[Language('XPath')] string $xpath, - RemoteWebElement $parent = null + ?RemoteWebElement $parent = null ): RemoteWebElement { try { $finder = WebDriverBy::xpath($xpath); @@ -1176,5 +1164,63 @@ private function jsonEncode($value, string $action, string $field): string } } + /** + * @param mixed $value + * @throws DriverException + * @phpstan-assert string $value + * @todo When switching to PHP 8, this can be replaced with `is_string(..) or throw new DriverException(..);` + */ + private function assertString($value, string $message): void + { + if (!is_string($value)) { + throw new DriverException($message); + } + } + + /** + * @param mixed $value + * @throws DriverException + * @phpstan-assert bool $value + * @todo When switching to PHP 8, this can be replaced with `is_bool(..) or throw new DriverException(..);` + */ + private function assertBool($value, string $message): void + { + if (!is_bool($value)) { + throw new DriverException($message); + } + } + + /** + * @param mixed $value + * @throws DriverException + */ + private function setElementDomProperty(RemoteWebElement $element, string $property, $value): void + { + $this->executeJsOnElement( + $element, + "arguments[0]['$property'] = {$this->jsonEncode($value, "set $property", $property)}" + ); + } + + /** + * @return mixed + * @throws DriverException + */ + private function getElementDomProperty(RemoteWebElement $element, string $property) + { + try { + return $this->getWebDriver()->isW3cCompliant() + ? $element->getDomProperty($property) + : $this->executeJsOnElement($element, "return arguments[0]['$property']"); + } catch (UnsupportedOperationException $e) { + $message = sprintf( + 'Could not get value of property "%s": %s', + $property, + $e->getMessage() + ); + throw new DriverException($message, 0, $e); + } + } + // } diff --git a/tests/WebdriverClassicConfig.php b/tests/WebdriverClassicConfig.php index 532c5b3..c59ebfc 100644 --- a/tests/WebdriverClassicConfig.php +++ b/tests/WebdriverClassicConfig.php @@ -19,9 +19,6 @@ public static function getInstance(): self return new self(); } - /** - * @throws DriverException - */ public function createDriver(): DriverInterface { $browser = getenv('WEB_FIXTURES_BROWSER') ?: WebdriverClassicDriver::DEFAULT_BROWSER; From b53f9a785b82885ec048f7cd16ddeb0b9bd4b77f Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Sat, 17 Jun 2023 21:23:34 +0200 Subject: [PATCH 40/47] Fix and document regression --- src/WebdriverClassicDriver.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index e095feb..2e92599 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -310,8 +310,11 @@ public function getAttribute( string $xpath, string $name ): ?string { - $result = $this->findElement($xpath)->getAttribute($name); - return $result === true ? '' : $result; + // W3C spec deviates from expected behavior, (e.g. returns empty string instead of null for missing property), + // so we cannot use webdriver api for this. See also: https://w3c.github.io/webdriver/#dfn-get-element-attribute + $escapedName = $this->jsonEncode($name, 'get attribute', 'attribute name'); + $script = "return arguments[0].getAttribute($escapedName)"; + return $this->executeJsOnXpath($xpath, $script); } public function getValue( From d385eed7f7ec955f3706d765003a5907beb85509 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Sun, 18 Jun 2023 19:43:56 +0200 Subject: [PATCH 41/47] Remove unofficial apis --- src/WebdriverClassicDriver.php | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index 2e92599..1577707 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -722,40 +722,6 @@ public function setTimeouts(array $timeouts): void } } - /** - * Globally press a key i.e. not typing into an element. - * - * @throws DriverException - * @api - */ - public function globalKeyPress(string $char, ?string $modifier = null): void - { - $keyboard = $this->getWebDriver()->getKeyboard(); - if ($modifier) { - $keyboard->pressKey($modifier); - } - $keyboard->pressKey($char); - if ($modifier) { - $keyboard->releaseKey($modifier); - } - } - - /** - * Drag and drop an element by x,y pixels. - * - * @throws DriverException - * @api - */ - public function dragBy( - #[Language('XPath')] - string $sourceXpath, - int $xOffset, - int $yOffset - ): void { - $source = $this->findElement($sourceXpath); - $this->getWebDriver()->action()->dragAndDropBy($source, $xOffset, $yOffset)->perform(); - } - // // From c5edb42406daf8094ce1fe47d390204e9a51f927 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Wed, 12 Jul 2023 00:37:15 +0200 Subject: [PATCH 42/47] A few CR fixes --- src/WebdriverClassicDriver.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index 1577707..8586d18 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -6,8 +6,6 @@ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. - * - * @noinspection PhpLanguageLevelInspection */ namespace Mink\WebdriverClassDriver; @@ -119,9 +117,10 @@ public function stop(): void try { $this->getWebDriver()->quit(); - $this->webDriver = null; } catch (Throwable $e) { throw new DriverException('Could not close connection', 0, $e); + } finally { + $this->webDriver = null; } } From 6619d75c902be7bd2c47f5c9a0705f18a32379b4 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Tue, 26 Sep 2023 21:00:56 +0200 Subject: [PATCH 43/47] Ensure value for date/time field is a string --- src/WebdriverClassicDriver.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index 8586d18..91e9963 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -396,6 +396,7 @@ public function setValue( case 'date': case 'time': + $this->assertString($value, "Value for $widgetType must be a string"); try { $element->clear(); $element->sendKeys($value); From 9730f540d3e5bcd304692ac9e59f5cc7118fad61 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Wed, 27 Sep 2023 00:01:51 +0200 Subject: [PATCH 44/47] MSEdge-specific fix --- src/WebdriverClassicDriver.php | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index 91e9963..c445f7f 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -20,9 +20,11 @@ use Facebook\WebDriver\Remote\RemoteWebDriver; use Facebook\WebDriver\Remote\RemoteWebElement; use Facebook\WebDriver\Remote\WebDriverBrowserType; +use Facebook\WebDriver\Remote\WebDriverCapabilityType; use Facebook\WebDriver\WebDriverBy; use Facebook\WebDriver\WebDriverDimension; use Facebook\WebDriver\WebDriverElement; +use Facebook\WebDriver\WebDriverPlatform; use Facebook\WebDriver\WebDriverRadios; use Facebook\WebDriver\WebDriverSelect; use JetBrains\PhpStorm\Language; @@ -31,7 +33,7 @@ class WebdriverClassicDriver extends CoreDriver { - public const DEFAULT_BROWSER = 'chrome'; + public const DEFAULT_BROWSER = WebDriverBrowserType::CHROME; public const DEFAULT_CAPABILITIES = [ 'default' => [ @@ -41,17 +43,14 @@ class WebdriverClassicDriver extends CoreDriver 'deviceType' => 'desktop', ], - 'chrome' => [ + WebDriverBrowserType::CHROME => [ 'goog:chromeOptions' => [ // disable "Chrome is being controlled.." notification bar 'excludeSwitches' => ['enable-automation'], ], ], - 'firefox' => [ - ], - - 'edge' => [ + WebDriverBrowserType::MICROSOFT_EDGE => [ 'ms:edgeOptions' => [ // disable "Microsoft Edge is being controlled.." notification bar 'excludeSwitches' => ['enable-automation'], @@ -63,6 +62,12 @@ class WebdriverClassicDriver extends CoreDriver ], ]; + private const BROWSER_NAME_ALIAS_MAP = [ + 'edge' => WebDriverBrowserType::MICROSOFT_EDGE, + 'chrome' => WebDriverBrowserType::CHROME, + 'firefox' => WebDriverBrowserType::FIREFOX, + ]; + private const W3C_WINDOW_HANDLE_PREFIX = 'w3cwh:'; private ?RemoteWebDriver $webDriver = null; @@ -77,12 +82,15 @@ class WebdriverClassicDriver extends CoreDriver private ?string $initialWindowName = null; + /** + * @param string $browserName One of 'edge', 'firefox', 'chrome' or any one of {@see WebDriverBrowserType} constants. + */ public function __construct( string $browserName = self::DEFAULT_BROWSER, array $desiredCapabilities = [], string $webDriverHost = 'http://localhost:4444/wd/hub' ) { - $this->browserName = $browserName; + $this->browserName = self::BROWSER_NAME_ALIAS_MAP[$browserName]; $this->desiredCapabilities = $this->initCapabilities($desiredCapabilities); $this->webDriverHost = $webDriverHost; } @@ -754,7 +762,7 @@ private function initCapabilities(array $desiredCapabilities): DesiredCapabiliti self::DEFAULT_CAPABILITIES[$this->browserName] ?? [] ); foreach ($defaults as $key => $value) { - if (is_null($caps->getCapability($key))) { + if ($caps->getCapability($key) === null) { $caps->setCapability($key, $value); } } @@ -784,7 +792,8 @@ private function getBrowserSpecificCapabilities(): ?DesiredCapabilities return DesiredCapabilities::opera(); case WebDriverBrowserType::MICROSOFT_EDGE: - return DesiredCapabilities::microsoftEdge(); + return DesiredCapabilities::microsoftEdge() + ->setCapability(WebDriverCapabilityType::PLATFORM, WebDriverPlatform::ANY); case WebDriverBrowserType::IE: case WebDriverBrowserType::IEXPLORE: From 88552502e3b2a25e42f66c3a0415064b90b35d91 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Thu, 28 Sep 2023 12:03:21 +0200 Subject: [PATCH 45/47] CR fixes --- src/WebdriverClassicDriver.php | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index c445f7f..465ca83 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -28,8 +28,6 @@ use Facebook\WebDriver\WebDriverRadios; use Facebook\WebDriver\WebDriverSelect; use JetBrains\PhpStorm\Language; -use JsonException; -use Throwable; class WebdriverClassicDriver extends CoreDriver { @@ -90,7 +88,7 @@ public function __construct( array $desiredCapabilities = [], string $webDriverHost = 'http://localhost:4444/wd/hub' ) { - $this->browserName = self::BROWSER_NAME_ALIAS_MAP[$browserName]; + $this->browserName = self::BROWSER_NAME_ALIAS_MAP[$browserName] ?? $browserName; $this->desiredCapabilities = $this->initCapabilities($desiredCapabilities); $this->webDriverHost = $webDriverHost; } @@ -107,7 +105,7 @@ public function start(): void $this->webDriver = RemoteWebDriver::create($this->webDriverHost, $this->desiredCapabilities); $this->applyTimeouts(); $this->initialWindowName = $this->getWindowName(); - } catch (Throwable $e) { + } catch (\Throwable $e) { throw new DriverException("Could not start driver: {$e->getMessage()}", 0, $e); } } @@ -125,7 +123,7 @@ public function stop(): void try { $this->getWebDriver()->quit(); - } catch (Throwable $e) { + } catch (\Throwable $e) { throw new DriverException('Could not close connection', 0, $e); } finally { $this->webDriver = null; @@ -359,7 +357,7 @@ public function getValue( default: return $this->getElementDomProperty($element, 'value'); } - } catch (Throwable $e) { + } catch (\Throwable $e) { throw new DriverException("Cannot retrieve $widgetType value: {$e->getMessage()}", 0, $e); } } @@ -440,7 +438,7 @@ public function setValue( $element->sendKeys($value); break; } - } catch (Throwable $e) { + } catch (\Throwable $e) { throw new DriverException("Cannot retrieve $widgetType value: {$e->getMessage()}", 0, $e); } @@ -930,7 +928,7 @@ private function applyTimeouts(): void throw new DriverException("Invalid timeout type: $type"); } } - } catch (Throwable $e) { + } catch (\Throwable $e) { throw new DriverException("Error setting timeout: {$e->getMessage()}", 0, $e); } } @@ -1029,7 +1027,7 @@ private function findElement( return $parent ? $parent->findElement($finder) : $this->getWebDriver()->findElement($finder); - } catch (Throwable $e) { + } catch (\Throwable $e) { throw new DriverException("Failed to find element: {$e->getMessage()}", 0, $e); } } @@ -1041,7 +1039,7 @@ private function selectRadioValue(RemoteWebElement $element, string $value): voi { try { (new WebDriverRadios($element))->selectByValue($value); - } catch (Throwable $e) { + } catch (\Throwable $e) { $message = sprintf( 'Cannot select radio button of group "%s" with value "%s": %s', $element->getAttribute('name'), @@ -1067,7 +1065,7 @@ private function selectOptionOnElement(RemoteWebElement $element, string $value, } catch (NoSuchElementException $e) { $select->selectByVisibleText($value); } - } catch (Throwable $e) { + } catch (\Throwable $e) { $message = sprintf( 'Cannot select option "%s" of "%s": %s', $value, @@ -1089,7 +1087,7 @@ private function deselectAllOptions(RemoteWebElement $element): void { try { (new WebDriverSelect($element))->deselectAll(); - } catch (Throwable $e) { + } catch (\Throwable $e) { $message = sprintf( 'Cannot deselect all options of "%s": %s', $element->getAttribute('name'), @@ -1137,7 +1135,7 @@ private function jsonEncode($value, string $action, string $field): string { try { return json_encode($value, JSON_THROW_ON_ERROR); - } catch (JsonException $e) { + } catch (\JsonException $e) { throw new DriverException("Cannot $action, $field not serializable: {$e->getMessage()}", 0, $e); } } From 9a8191872e5061ae6c3d05d13e1f177586821df0 Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Thu, 28 Sep 2023 13:57:06 +0200 Subject: [PATCH 46/47] Various workflow improvements (#1) --- .github/workflows/ci.yml | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94a56e0..3dc50e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,11 +52,6 @@ jobs: - name: Checkout uses: actions/checkout@v3 - - name: Start Selenium - run: | - mkdir -p ./logs - SELENIUM_IMAGE=selenium/standalone-${{ matrix.browser }}:${{ matrix.selenium }} docker compose up --no-color &> ./logs/selenium.log & - - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -68,14 +63,18 @@ jobs: run: | composer update --no-interaction --prefer-dist --ansi --no-progress + - name: Start Selenium + run: | + SELENIUM_IMAGE=selenium/standalone-${{ matrix.browser }}:${{ matrix.selenium }} docker compose up --wait + - name: Wait for selenium to start run: | - curl --head -X GET --silent --show-error --retry 60 --retry-connrefused --retry-delay 1 --max-time 10 http://172.18.0.2:4444 + curl --retry 5 --retry-all-errors --retry-delay 1 --max-time 10 --head -X GET http://localhost:4444/wd/hub/status - name: Run tests env: SELENIUM_VERSION: ${{ matrix.selenium }} - DRIVER_URL: http://172.18.0.2:4444/wd/hub + DRIVER_URL: http://localhost:4444/wd/hub WEB_FIXTURES_HOST: http://host.docker.internal:8002 WEB_FIXTURES_BROWSER: ${{ matrix.browser }} DRIVER_MACHINE_BASE_PATH: /fixtures/ @@ -89,6 +88,12 @@ jobs: with: files: coverage.xml + - name: Extract docker logs + if: ${{ failure() }} + run: | + mkdir -p ./logs + docker compose logs --no-color &> ./logs/selenium.log + - name: Archive logs artifacts uses: actions/upload-artifact@v3 if: ${{ failure() }} From 5f5268020ee76fb0f1749041d316c3f233b7ebed Mon Sep 17 00:00:00 2001 From: Christian Sciberras Date: Thu, 28 Sep 2023 14:06:45 +0200 Subject: [PATCH 47/47] CR fix --- src/WebdriverClassicDriver.php | 54 +++++++++++++--------------------- 1 file changed, 21 insertions(+), 33 deletions(-) diff --git a/src/WebdriverClassicDriver.php b/src/WebdriverClassicDriver.php index 465ca83..2d4ba13 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -383,7 +383,9 @@ public function setValue( } return; } - $this->assertString($value, "Value for $widgetType must be a string"); + if (!is_string($value)) { + throw new DriverException("Value for $widgetType must be a string"); + } $this->selectOptionOnElement($element, $value); return; @@ -395,14 +397,18 @@ public function setValue( throw new DriverException(sprintf($message, $xpath)); case 'color': - $this->assertString($value, "Value for $widgetType must be a string"); + if (!is_string($value)) { + throw new DriverException("Value for $widgetType must be a string"); + } // one cannot simply type into a color field, nor clear it $this->setElementDomProperty($element, 'value', $value); break; case 'date': case 'time': - $this->assertString($value, "Value for $widgetType must be a string"); + if (!is_string($value)) { + throw new DriverException("Value for $widgetType must be a string"); + } try { $element->clear(); $element->sendKeys($value); @@ -413,19 +419,25 @@ public function setValue( break; case 'checkbox': - $this->assertBool($value, "Value for $widgetType must be a boolean"); + if (!is_bool($value)) { + throw new DriverException("Value for $widgetType must be a boolean"); + } if ($element->isSelected() xor $value) { $this->clickOnElement($element); } return; case 'radio': - $this->assertString($value, "Value for $widgetType must be a string"); + if (!is_string($value)) { + throw new DriverException("Value for $widgetType must be a string"); + } $this->selectRadioValue($element, $value); return; case 'file': - $this->assertString($value, "Value for $widgetType must be a string"); + if (!is_string($value)) { + throw new DriverException("Value for $widgetType must be a string"); + } $element->sendKeys($value); break; @@ -433,7 +445,9 @@ public function setValue( case 'password': case 'textarea': default: - $this->assertString($value, "Value for $widgetType must be a string"); + if (!is_string($value)) { + throw new DriverException("Value for $widgetType must be a string"); + } $element->clear(); $element->sendKeys($value); break; @@ -1140,32 +1154,6 @@ private function jsonEncode($value, string $action, string $field): string } } - /** - * @param mixed $value - * @throws DriverException - * @phpstan-assert string $value - * @todo When switching to PHP 8, this can be replaced with `is_string(..) or throw new DriverException(..);` - */ - private function assertString($value, string $message): void - { - if (!is_string($value)) { - throw new DriverException($message); - } - } - - /** - * @param mixed $value - * @throws DriverException - * @phpstan-assert bool $value - * @todo When switching to PHP 8, this can be replaced with `is_bool(..) or throw new DriverException(..);` - */ - private function assertBool($value, string $message): void - { - if (!is_bool($value)) { - throw new DriverException($message); - } - } - /** * @param mixed $value * @throws DriverException