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/.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..3dc50e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,12 @@ jobs: strategy: matrix: 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: @@ -57,19 +63,40 @@ 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 --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://localhost: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 + vendor/bin/phpunit -v --coverage-clover=coverage.xml --colors=always --testdox - name: Upload coverage uses: codecov/codecov-action@v3 + # See https://github.com/nektos/act#skipping-steps + if: ${{ !env.ACT }} with: files: coverage.xml - - name: Archive logs artifacts + - 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() }} with: - name: logs_php-${{ matrix.php }} - path: | - logs + name: logs_php-${{ matrix.php }}_selenium-${{ matrix.selenium }}_${{ matrix.browser }} + path: logs diff --git a/README.md b/README.md index 7f9ec89..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://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) [![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..e608342 100644 --- a/composer.json +++ b/composer.json @@ -14,14 +14,17 @@ ], "require": { "php": ">=7.4", - "behat/mink": "^1.9@dev" + "ext-json": "*", + "behat/mink": "^1.11@dev", + "php-webdriver/webdriver": "^1.14" }, "require-dev": { "mink/driver-testsuite": "dev-master", "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": { @@ -30,7 +33,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..2121f84 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..2d4ba13 100644 --- a/src/WebdriverClassicDriver.php +++ b/src/WebdriverClassicDriver.php @@ -1,6 +1,6 @@ * @@ -11,11 +11,1180 @@ namespace Mink\WebdriverClassDriver; use Behat\Mink\Driver\CoreDriver; +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; +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; class WebdriverClassicDriver extends CoreDriver { + public const DEFAULT_BROWSER = WebDriverBrowserType::CHROME; + + public const DEFAULT_CAPABILITIES = [ + 'default' => [ + 'platform' => 'ANY', + 'name' => 'Behat Test', + 'deviceOrientation' => 'landscape', + 'deviceType' => 'desktop', + ], + + WebDriverBrowserType::CHROME => [ + 'goog:chromeOptions' => [ + // disable "Chrome is being controlled.." notification bar + 'excludeSwitches' => ['enable-automation'], + ], + ], + + WebDriverBrowserType::MICROSOFT_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 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; + + private string $browserName; + + private DesiredCapabilities $desiredCapabilities; + + private array $timeouts = []; + + private string $webDriverHost; + + 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 = self::BROWSER_NAME_ALIAS_MAP[$browserName] ?? $browserName; + $this->desiredCapabilities = $this->initCapabilities($desiredCapabilities); + $this->webDriverHost = $webDriverHost; + } + + // + + 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(); + } catch (\Throwable $e) { + throw new DriverException("Could not start driver: {$e->getMessage()}", 0, $e); + } + } + public function isStarted(): bool { - return false; + return $this->webDriver !== null; + } + + public function stop(): void + { + if (!$this->webDriver) { + throw new DriverException('Driver has not been started'); + } + + try { + $this->getWebDriver()->quit(); + } catch (\Throwable $e) { + throw new DriverException('Could not close connection', 0, $e); + } finally { + $this->webDriver = null; + } + } + + /** + * {@inheritdoc} + * @throws DriverException + */ + 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(); + } + + public function visit(string $url): void + { + $this->getWebDriver()->navigate()->to($url); + } + + public function getCurrentUrl(): string + { + return $this->getWebDriver()->getCurrentURL(); + } + + public function reload(): void + { + $this->getWebDriver()->navigate()->refresh(); + } + + public function forward(): void + { + $this->getWebDriver()->navigate()->forward(); + } + + public function back(): void + { + $this->getWebDriver()->navigate()->back(); + } + + public function switchToWindow(?string $name = null): void + { + if ($name === null) { + $name = $this->initialWindowName; + } + + if (is_string($name)) { + $name = $this->getWindowHandleFromName($name); + } + + $this->getWebDriver()->switchTo()->window((string)$name); + } + + public function switchToIFrame(?string $name = null): void + { + $frameQuery = $name; + if ($name && $this->getWebDriver()->isW3cCompliant()) { + try { + $frameQuery = $this->getWebDriver()->findElement(WebDriverBy::id($name)); + } catch (NoSuchElementException $e) { + $frameQuery = $this->getWebDriver()->findElement(WebDriverBy::name($name)); + } + } + + $this->getWebDriver()->switchTo()->frame($frameQuery); + } + + public function setCookie(string $name, ?string $value = null): void + { + if (null === $value) { + $this->getWebDriver()->manage()->deleteCookieNamed($name); + + return; + } + + $cookieArray = [ + 'name' => $name, + 'value' => rawurlencode($value), + 'secure' => false, + ]; + + $this->getWebDriver()->manage()->addCookie($cookieArray); + } + + public function getCookie(string $name): ?string + { + try { + $result = $this->getWebDriver()->manage()->getCookieNamed($name); + } catch (NoSuchCookieException $e) { + $result = null; + } + if ($result === null) { + return null; + } + + return rawurldecode($result->getValue()); + } + + public function getContent(): string + { + return $this->getWebDriver()->getPageSource(); + } + + public function getScreenshot(): string + { + return $this->getWebDriver()->takeScreenshot(); + } + + public function getWindowNames(): array + { + $origWindow = $this->getWebDriver()->getWindowHandle(); + + try { + $result = []; + foreach ($this->getWebDriver()->getWindowHandles() as $tempWindow) { + $this->getWebDriver()->switchTo()->window($tempWindow); + $result[] = $this->getWindowName(); + } + return $result; + } finally { + $this->getWebDriver()->switchTo()->window($origWindow); + } + } + + public function getWindowName(): string + { + $name = (string)$this->evaluateScript('window.name'); + + if ($name === '') { + $name = self::W3C_WINDOW_HANDLE_PREFIX . $this->getWebDriver()->getWindowHandle(); + } + + return $name; + } + + protected function findElementXpaths( + #[Language('XPath')] + string $xpath + ): array { + $nodes = $this->getWebDriver()->findElements(WebDriverBy::xpath($xpath)); + + $elements = []; + foreach ($nodes as $i => $node) { + $elements[] = sprintf('(%s)[%d]', $xpath, $i + 1); + } + + return $elements; + } + + public function getTagName( + #[Language('XPath')] + string $xpath + ): string { + return $this->findElement($xpath)->getTagName(); + } + + public function getText( + #[Language('XPath')] + string $xpath + ): string { + return str_replace(["\r", "\n"], ' ', $this->findElement($xpath)->getText()); + } + + public function getHtml( + #[Language('XPath')] + string $xpath + ): string { + return $this->getElementDomProperty($this->findElement($xpath), 'innerHTML'); + } + + public function getOuterHtml( + #[Language('XPath')] + string $xpath + ): string { + return $this->getElementDomProperty($this->findElement($xpath), 'outerHTML'); + } + + public function getAttribute( + #[Language('XPath')] + string $xpath, + string $name + ): ?string { + // 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( + #[Language('XPath')] + string $xpath + ) { + $element = $this->findElement($xpath); + $widgetType = strtolower($element->getTagName() ?? ''); + if ($widgetType === 'input') { + $widgetType = strtolower((string)$element->getAttribute('type')); + } + + try { + switch ($widgetType) { + case 'radio': + $radioElement = new WebDriverRadios($element); + try { + return $radioElement->getFirstSelectedOption()->getAttribute('value'); + } catch (NoSuchElementException $e) { + return null; + } + + case 'checkbox': + // WebDriverCheckboxes is not suitable since it _always_ behaves as a group + return $element->isSelected() ? $element->getAttribute('value') : null; + + case '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->getElementDomProperty($element, 'value'); + } + } catch (\Throwable $e) { + throw new DriverException("Cannot retrieve $widgetType value: {$e->getMessage()}", 0, $e); + } + } + + public function setValue( + #[Language('XPath')] + string $xpath, + $value + ): void { + $element = $this->findElement($xpath); + $widgetType = strtolower($element->getTagName() ?? ''); + if ($widgetType === 'input') { + $widgetType = strtolower((string)$element->getAttribute('type')); + } + + try { + switch ($widgetType) { + case 'select': + if (is_array($value)) { + $this->deselectAllOptions($element); + foreach ($value as $option) { + $this->selectOptionOnElement($element, $option, true); + } + return; + } + if (!is_string($value)) { + throw new DriverException("Value for $widgetType must be a string"); + } + $this->selectOptionOnElement($element, $value); + return; + + 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("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': + if (!is_string($value)) { + throw new DriverException("Value for $widgetType must be a string"); + } + try { + $element->clear(); + $element->sendKeys($value); + } catch (WebDriverException $ex) { + // 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': + 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': + if (!is_string($value)) { + 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 for $widgetType must be a string"); + } + $element->sendKeys($value); + break; + + case 'text': + case 'password': + case 'textarea': + default: + if (!is_string($value)) { + 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'); + } + + public function check( + #[Language('XPath')] + string $xpath + ): void { + $element = $this->findElement($xpath); + $this->ensureInputType($element, $xpath, 'checkbox', 'check'); + + if ($element->isSelected()) { + return; + } + + $this->clickOnElement($element); + } + + public function uncheck( + #[Language('XPath')] + string $xpath + ): void { + $element = $this->findElement($xpath); + $this->ensureInputType($element, $xpath, 'checkbox', 'uncheck'); + + if (!$element->isSelected()) { + return; + } + + $this->clickOnElement($element); + } + + public function isChecked( + #[Language('XPath')] + string $xpath + ): bool { + return $this->findElement($xpath)->isSelected(); + } + + public function selectOption( + #[Language('XPath')] + string $xpath, + string $value, + bool $multiple = false + ): void { + $element = $this->findElement($xpath); + $tagName = strtolower($element->getTagName() ?? ''); + + if ($tagName === 'input' && strtolower((string)$element->getAttribute('type')) === 'radio') { + $this->selectRadioValue($element, $value); + return; + } + + if ($tagName === 'select') { + $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)); + } + + public function isSelected( + #[Language('XPath')] + string $xpath + ): bool { + return $this->findElement($xpath)->isSelected(); + } + + public function click( + #[Language('XPath')] + string $xpath + ): void { + $this->clickOnElement($this->findElement($xpath)); + } + + public function doubleClick( + #[Language('XPath')] + string $xpath + ): void { + $this->doubleClickOnElement($this->findElement($xpath)); + } + + public function rightClick( + #[Language('XPath')] + string $xpath + ): void { + $this->rightClickOnElement($this->findElement($xpath)); + } + + public function attachFile( + #[Language('XPath')] + string $xpath, + #[Language('file-reference')] + string $path + ): void { + $element = $this->findElement($xpath); + $this->ensureInputType($element, $xpath, 'file', 'attach a file on'); + $this->setValue($xpath, $path); + } + + public function isVisible( + #[Language('XPath')] + string $xpath + ): bool { + return $this->findElement($xpath)->isDisplayed(); + } + + public function mouseOver( + #[Language('XPath')] + string $xpath + ): void { + $this->mouseOverElement($this->findElement($xpath)); + } + + public function focus( + #[Language('XPath')] + string $xpath + ): void { + $this->trigger($xpath, 'focus'); + } + + public function blur( + #[Language('XPath')] + string $xpath + ): void { + $this->trigger($xpath, 'blur'); + } + + public function keyPress( + #[Language('XPath')] + string $xpath, + $char, + ?string $modifier = null + ): void { + $options = $this->charToSynOptions($char, $modifier); + $this->trigger($xpath, 'keypress', $options); + } + + public function keyDown( + #[Language('XPath')] + string $xpath, + $char, + ?string $modifier = null + ): void { + $options = $this->charToSynOptions($char, $modifier); + $this->trigger($xpath, 'keydown', $options); + } + + public function keyUp( + #[Language('XPath')] + string $xpath, + $char, + ?string $modifier = null + ): void { + $options = $this->charToSynOptions($char, $modifier); + $this->trigger($xpath, 'keyup', $options); + } + + public function dragTo( + #[Language('XPath')] + string $sourceXpath, + #[Language('XPath')] + string $destinationXpath + ): void { + $source = $this->findElement($sourceXpath); + $destination = $this->findElement($destinationXpath); + $this->getWebDriver()->action()->dragAndDrop($source, $destination)->perform(); + } + + public function executeScript( + #[Language('JavaScript')] + string $script + ): void { + if (preg_match('/^function[\s(]/', $script)) { + $script = '(' . rtrim($script, ';') . ')'; + } + + $this->getWebDriver()->executeScript($script); + } + + /** + * {@inheritdoc} + * @return mixed + */ + public function evaluateScript( + #[Language('JavaScript')] + string $script + ) { + if (strncmp(ltrim($script), 'return ', 7) !== 0) { + $script = "return $script;"; + } + + return $this->getWebDriver()->executeScript($script); + } + + public function wait( + int $timeout, + #[Language('JavaScript')] + string $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; + } + + public function resizeWindow(int $width, int $height, ?string $name = null): void + { + $this->withWindow( + $name, + fn() => $this + ->getWebDriver() + ->manage() + ->window() + ->setSize(new WebDriverDimension($width, $height)) + ); + } + + public function submitForm( + #[Language('XPath')] + string $xpath + ): void { + $this->findElement($xpath)->submit(); + } + + public function maximizeWindow(?string $name = null): void + { + $this->withWindow( + $name, + fn() => $this + ->getWebDriver() + ->manage() + ->window() + ->maximize() + ); + } + + // + + // + + /** + * Returns the browser name. + * + * @api + */ + public function getBrowserName(): string + { + return $this->browserName; + } + + /** + * Returns Session ID of WebDriver or `null`, when session not started yet. + * + * @return string|null + * @throws DriverException + * @api + */ + public function getWebDriverSessionId(): ?string + { + return $this->isStarted() + ? $this->getWebDriver()->getSessionID() + : null; + } + + /** + * 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(); + } + } + + // + + // + + /** + * @throws DriverException + */ + private function getWebDriver(): RemoteWebDriver + { + if ($this->webDriver) { + return $this->webDriver; + } + + throw new DriverException('Driver has not been started'); + } + + /** + * Detect and assign appropriate browser capabilities + * + * @see https://github.com/SeleniumHQ/selenium/wiki/DesiredCapabilities + */ + private function initCapabilities(array $desiredCapabilities): DesiredCapabilities + { + // Build base capabilities + $caps = $this->getBrowserSpecificCapabilities() ?? new DesiredCapabilities(); + + // Set defaults + $defaults = array_merge( + self::DEFAULT_CAPABILITIES['default'], + self::DEFAULT_CAPABILITIES[$this->browserName] ?? [] + ); + foreach ($defaults as $key => $value) { + if ($caps->getCapability($key) === null) { + $caps->setCapability($key, $value); + } + } + + // Merge in other requested types + foreach ($desiredCapabilities as $key => $value) { + $caps->setCapability($key, $value); + } + + 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() + ->setCapability(WebDriverCapabilityType::PLATFORM, WebDriverPlatform::ANY); + + 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 + */ + private function withSyn(): self + { + $hasSyn = $this->evaluateScript( + 'return window.syn !== undefined && window.syn.trigger !== undefined' + ); + if ($hasSyn) { + return $this; + } + + $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; + } + + /** + * @param int|string $char + * @throws DriverException + */ + 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 $this->jsonEncode($options, 'build Syn payload', 'options'); + } + + /** + * 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 + * + * @return mixed + * @throws DriverException + * @example $this->executeJsOnXpath($xpath, 'return argument[0].childNodes.length'); + */ + private function executeJsOnXpath( + #[Language('XPath')] + string $xpath, + #[Language('JavaScript')] + string $script + ) { + return $this->executeJsOnElement($this->findElement($xpath), $script); + } + + /** + * 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 + * @return mixed + * @throws DriverException + * @example $this->executeJsOnXpath($xpath, 'return argument[0].childNodes.length'); + */ + private function executeJsOnElement( + RemoteWebElement $element, + #[Language('JavaScript')] + string $script + ) { + return $this->getWebDriver()->executeScript($script, [$element]); + } + + /** + * @throws DriverException + */ + private function applyTimeouts(): void + { + try { + $timeouts = $this->getWebDriver()->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->getWebDriver()->getWindowHandle(); + + try { + foreach ($this->getWebDriver()->getWindowHandles() as $handle) { + $this->getWebDriver()->switchTo()->window($handle); + if ($this->evaluateScript('window.name') === $name) { + return $handle; + } + } + + throw new DriverException("Could not find handle of window named \"$name\""); + } finally { + $this->getWebDriver()->switchTo()->window($origWindowHandle); + } + } + + private function clickOnElement(RemoteWebElement $element): void + { + $element->getLocationOnScreenOnceScrolledIntoView(); + $element->click(); + } + + /** + * @throws DriverException + */ + private function doubleClickOnElement(RemoteWebElement $element): void + { + $element->getLocationOnScreenOnceScrolledIntoView(); + $this->getWebDriver()->getMouse()->doubleClick($element->getCoordinates()); + } + + /** + * @throws DriverException + */ + private function rightClickOnElement(RemoteWebElement $element): void + { + $element->getLocationOnScreenOnceScrolledIntoView(); + $this->getWebDriver()->getMouse()->contextClick($element->getCoordinates()); + } + + /** + * @throws DriverException + */ + private function mouseOverElement(RemoteWebElement $element): void + { + $element->getLocationOnScreenOnceScrolledIntoView(); + $this->getWebDriver()->getMouse()->mouseMove($element->getCoordinates()); + } + + /** + * @throws DriverException + */ + private function withWindow(?string $name, callable $callback): void + { + $origName = $this->getWindowName(); + + try { + if ($origName !== $name) { + $this->switchToWindow($name); + } + + $callback(); + } finally { + if ($origName !== $name) { + $this->switchToWindow($origName); + } + } + } + + /** + * @throws DriverException + */ + private function findElement( + #[Language('XPath')] + string $xpath, + ?RemoteWebElement $parent = null + ): RemoteWebElement { + try { + $finder = WebDriverBy::xpath($xpath); + return $parent + ? $parent->findElement($finder) + : $this->getWebDriver()->findElement($finder); + } catch (\Throwable $e) { + throw new DriverException("Failed to find element: {$e->getMessage()}", 0, $e); + } + } + + /** + * @throws DriverException + */ + private function selectRadioValue(RemoteWebElement $element, string $value): void + { + try { + (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); + } + } + + /** + * @throws DriverException + */ + private function selectOptionOnElement(RemoteWebElement $element, string $value, bool $multiple = false): void + { + try { + $select = new WebDriverSelect($element); + if (!$multiple && $select->isMultiple()) { + $select->deselectAll(); + } + try { + $select->selectByValue($value); + } catch (NoSuchElementException $e) { + $select->selectByVisibleText($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); + } + } + + /** + * Deselects all options of a multiple select + * + * Note: this implementation does not trigger a change event after deselecting the elements. + * + * @throws DriverException + */ + private function deselectAllOptions(RemoteWebElement $element): void + { + 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); + } + } + + /** + * @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 DriverException + */ + 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)"); + } + + /** + * @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); + } + } + + /** + * @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/Custom/TimeoutTest.php b/tests/Custom/TimeoutTest.php new file mode 100644 index 0000000..1f0ecb3 --- /dev/null +++ b/tests/Custom/TimeoutTest.php @@ -0,0 +1,67 @@ +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()) { + $session->stop(); + } + + // Reset the array of timeouts to avoid impacting other tests + $driver->setTimeouts([]); + + parent::resetSessions(); + } + + public function testInvalidTimeoutSettingThrowsException(): void + { + $this->getSession()->start(); + $driver = $this->getSession()->getDriver(); + assert($driver instanceof WebdriverClassicDriver); + + $this->expectException(DriverException::class); + + $driver->setTimeouts(['invalid' => 0]); + } + + public function testShortTimeoutDoesNotWaitForElementToAppear(): void + { + $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); + } + + public function testLongTimeoutWaitsForElementToAppear(): void + { + $driver = $this->getSession()->getDriver(); + assert($driver instanceof WebdriverClassicDriver); + $driver->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..9314b02 --- /dev/null +++ b/tests/Custom/WebDriverTest.php @@ -0,0 +1,33 @@ +getSession()->start(); + } + + protected function tearDown(): void + { + $this->getSession()->stop(); + + parent::tearDown(); + } + + public function testGetWebDriverSessionId(): void + { + $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'); + } +} diff --git a/tests/WebdriverClassicConfig.php b/tests/WebdriverClassicConfig.php index 2bf8c52..c59ebfc 100644 --- a/tests/WebdriverClassicConfig.php +++ b/tests/WebdriverClassicConfig.php @@ -3,7 +3,13 @@ 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; class WebdriverClassicConfig extends AbstractConfig @@ -15,22 +21,62 @@ public static function getInstance(): self public function createDriver(): DriverInterface { - return new WebdriverClassicDriver(); + $browser = getenv('WEB_FIXTURES_BROWSER') ?: WebdriverClassicDriver::DEFAULT_BROWSER; + $seleniumHost = $_SERVER['DRIVER_URL']; + + return new WebdriverClassicDriver($browser, [], $seleniumHost); } - public function skipMessage($testCase, $test): ?string + public function mapRemoteFilePath($file): string { - /** @phpstan-ignore-next-line */ - if (true) { - return 'TODO: implement the initial driver'; + if (!isset($_SERVER['TEST_MACHINE_BASE_PATH'])) { + $_SERVER['TEST_MACHINE_BASE_PATH'] = realpath( + dirname(__DIR__) . '/vendor/mink/driver-testsuite/web-fixtures' + ) . DIRECTORY_SEPARATOR; } - /** @phpstan-ignore-next-line */ - return parent::skipMessage($testCase, $test); + return parent::mapRemoteFilePath($file); + } + + public function skipMessage($testCase, $test): ?string + { + switch (true) { + case $testCase === WindowTest::class && $test === 'testWindowMaximize' && $this->isXvfb(): + return 'Maximizing the window does not work when running the browser in Xvfb.'; + + case $testCase === BasicAuthTest::class: + return 'Basic auth is not supported.'; + + case $testCase === HeaderTest::class: + return 'Headers are not supported.'; + + case $testCase === StatusCodeTest::class: + return 'Checking status code is not supported.'; + + case $testCase === EventsTest::class && $test === 'testKeyboardEvents' && $this->isOldChrome(): + return 'Old Chrome does not allow triggering events.'; + + default: + return parent::skipMessage($testCase, $test); + } } + /** + * {@inheritdoc} + */ 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', '<'); + } } 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() +);