diff --git a/.babelrc b/.babelrc
deleted file mode 100644
index 6206925..0000000
--- a/.babelrc
+++ /dev/null
@@ -1 +0,0 @@
-{ "presets": ["latest", "react"] }
diff --git a/.circleci/config.yml b/.circleci/config.yml
index fe331d4..6684ddf 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -6,7 +6,7 @@ version: 2
jobs:
build:
docker:
- - image: circleci/node:11.5-browsers-legacy
+ - image: circleci/node:12-browsers
working_directory: ~/repo
diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 0000000..f367fda
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,37 @@
+module.exports = {
+ "env": {
+ "browser": true,
+ "es6": true,
+ "node": true,
+ },
+ "extends": [
+ "eslint:recommended",
+ "plugin:@typescript-eslint/eslint-recommended"
+ ],
+ "parser": "@typescript-eslint/parser",
+ "parserOptions": {
+ "ecmaFeatures": {
+ "jsx": true
+ },
+ "ecmaVersion": 2018,
+ "sourceType": "module"
+ },
+ "plugins": [
+ "@typescript-eslint",
+ "mocha",
+ ],
+ ignorePatterns: [
+ "dist/",
+ "docs/",
+ "docs-dist/",
+ ],
+ overrides: [
+ {
+ files: ['*.ts', '*.tsx'],
+ rules: {
+ "no-unused-vars": "off",
+ "@typescript-eslint/no-unused-vars": "error",
+ },
+ }
+ ]
+};
diff --git a/.gitignore b/.gitignore
index 03bba7c..402c5c6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,7 @@
*.orig
/node_modules/
.DS_Store
+/dist/
+docs-dist/
+.exrc
+*Mount.js*
diff --git a/angular.js b/angular.js
deleted file mode 100644
index b7c06ee..0000000
--- a/angular.js
+++ /dev/null
@@ -1,19 +0,0 @@
-var debug = require('debug')('browser-monkey:angular')
-var Mount = require('./lib/mount')
-var createMonkey = require('./create')
-var createTestDiv = require('./lib/createTestDiv')
-var angular = require('angular')
-
-module.exports = function (app) {
- return new Mount(app, {
- stopApp: function () {},
- startApp: function () {
- debug('Mounting angular app ' + app.moduleName)
- var div = createTestDiv()
- div.setAttribute(app.directiveName, '')
- angular.bootstrap(div, [app.moduleName])
-
- return createMonkey(document.body)
- }
- }).start()
-}
diff --git a/babel.config.js b/babel.config.js
new file mode 100644
index 0000000..281b5cb
--- /dev/null
+++ b/babel.config.js
@@ -0,0 +1,14 @@
+module.exports = {
+ "presets": [
+ "@babel/preset-react",
+ [
+ "@babel/preset-env",
+ {
+ "targets": {
+ // "ie": "10"
+ "node": true
+ }
+ }
+ ]
+ ]
+}
diff --git a/create.js b/create.js
deleted file mode 100644
index 7fdf139..0000000
--- a/create.js
+++ /dev/null
@@ -1,13 +0,0 @@
-var Selector = require('./lib/selector')
-var finders = require('./lib/finders')
-var actions = require('./lib/actions')
-var assertions = require('./lib/assertions')
-var promise = require('./lib/promise')
-
-module.exports = function (rootSelector) {
- return new Selector(rootSelector)
- .component(promise)
- .component(finders)
- .component(actions)
- .component(assertions)
-}
diff --git a/docs/.nojekyll b/docs/.nojekyll
new file mode 100644
index 0000000..e69de29
diff --git a/docs/CNAME b/docs/CNAME
new file mode 100644
index 0000000..bc22eb7
--- /dev/null
+++ b/docs/CNAME
@@ -0,0 +1 @@
+browsermonkey.org
\ No newline at end of file
diff --git a/docs/_sidebar.md b/docs/_sidebar.md
new file mode 100644
index 0000000..b990080
--- /dev/null
+++ b/docs/_sidebar.md
@@ -0,0 +1,7 @@
+- [Introduction](introduction)
+- [Quick start](quickstart)
+- [API Reference](api)
+- **Links**
+- [Demo Sandbox](https://codesandbox.io/s/bm3-example-demo-sandbox-gzr7j?module=/test/app.spec.js)
+- [Github](https://github.com/featurist/browser-monkey)
+- [NPM](https://www.npmjs.com/package/browser-monkey)
diff --git a/docs/api.md b/docs/api.md
new file mode 100644
index 0000000..9beebdd
--- /dev/null
+++ b/docs/api.md
@@ -0,0 +1,664 @@
+# API Reference
+
+## Overview
+
+The API is made up of three concepts: queries, actions and assertions.
+
+* queries are chains of methods, such as `find` and `containing`, that progressively narrow the scope of elements to be searched for. Queries return new queries.
+* actions such as `click` and `enterText` "execute" the query chain, waiting for the elements to be found before simulating a corresponding UI event. These return promises that resolve when the event has been dispatched.
+* assertions such as `shouldExist` and `shouldContain` also "execute" the query chain and ensure that the elements exist or contain text, classes or other properties. These return promises that resolve if queries are satisfied, or rejected otherwise (after retrying query for some time).
+
+### Queries
+
+Queries can be of two types: those whose arguments filter elements based on their own properties (e.g. `find` or `is`), vs those whose arguments filter elements based on _contents_ of those elements (e.g. `containing`).
+
+The former accepts a selector, which is either a css string or a [custom finder](#createFinder). The latter accepts a "model" (TODO: find a better name) and that can be one of the following:
+
+* string: the exact text content
+* RegExp: a matcher for text content
+* [custom finder](#createFinder): to assert that certain elements are contained
+* an object where keys are selectors (css string or a finder) and values are one of the above or a nested object
+* an array with any of the above
+
+## Mount
+
+Browser-monkey can create a test DOM container to mount your app into. This is convinient, but not required - you can put your DOM wherever you want.
+
+There are a couple of shortcuts for doing this for particular frameworks. Otherwise, generic mount is equally straightforward.
+
+### React
+
+```js
+import {Query} from 'browser-monkey'
+import ReactMount from 'browser-monkey/ReactMount'
+const mount = new ReactMount(React.createElement(YourReactApp, {}, null))
+```
+
+### Hyperdom
+
+```js
+import {Query} from 'browser-monkey'
+import HyperdomMount from 'browser-monkey/HyperdomMount'
+const mount = new HyperdomMount(new YourHyperdomApp())
+```
+
+### Iframe
+
+Instead of mounting client side app directly, you can also give browser-monkey a url to load in an iframe. This is a more realistic test environment - it covers js bundling and `index.html` - and so it may be worth having tests like this as well. Iframe mount can also be used for other types of browser automations, e.g. web crawler.
+
+```js
+import {IFrameMount, Query} from 'browser-monkey'
+const mount = new IFrameMount('http://example.com/some/page')
+```
+
+### Manual
+
+Browser-monkey mount gives you a reference to the test DOM container. It's just a DOM element, insert your html there.
+
+```js
+import {Mount, Query} from 'browser-monkey'
+const mount = new Mount()
+mount.containerElement().innerHTML = '
bananas
'
+```
+
+### Query mount
+
+Once mount is created, it is passed to the Query API. The result is browser-monkey API, scoped to the contents of mount's container DOM.
+
+```js
+const page = new Query(mount.containerElement())
+```
+
+### Unmount
+
+Remove test container (e.g. between tests):
+
+```js
+mount.unmount()
+```
+
+### No Mount
+
+Simply pass DOM element to query constructor:
+
+```js
+const page = new Query(document.querySelector('#my-test-container'))
+```
+
+## Query
+
+Queries are chains of methods, such as `find(css)` and `containing(text)`, that progressively narrow the scope of elements to be searched for. Queries return new queries.
+
+All query chains are immutable, so you can reuse portions of a chain to build new chains:
+
+```js
+const page = new Query(mount.containerElement())
+
+const details = page.find('.details') // finds .details
+const name = details.find('.name') // finds .details .name
+const email = details.find('.email') // finds .details .email
+```
+
+A query can be "resolved" in a number of ways:
+
+```js
+// Simply resolving it as promise returns found elements (or rejects after timeout if none found)
+const elements = await details
+
+// Use a specific assertion
+const elements = await details.shouldHaveElements(2)
+
+// Call `result()` to grab whatever elements match _without_ waiting
+const elements = details.result()
+```
+
+You can call `.scope()` explicitely to (re)set the starting point for the query (an element from which all elements are searched for):
+
+```js
+const scopeUnderElement = page.scope(element)
+```
+
+### setOptions
+
+Set query options. They are inherited by inner queries.
+
+```js
+query.setOptions({visibleOnly: false})
+
+query.find('div').getOptions().visibleOnly // => false
+```
+
+* `visibleOnly` if true, then only visible elements will be found, if false, then all elements are considered. Default is true.
+* `timeout` an integer specifying the milliseconds to wait for an element to appear. This can be overriden by specifying the timeout when calling an action.
+* `interval` a number of milliseconds to wait between querying DOM when waiting for element to appear.
+
+### getOptions
+
+Returns query options.
+
+### find
+
+Scope query by selector
+
+```js
+const innerQuery = query.find(css)
+```
+
+Returns a new query that matches `css`. A custom finder (see [`createFinder()`](#createFinder)) can also be used instead of CSS selector. Example:
+
+```js
+import { Button } from 'browser-monkey'
+
+await page.find(Button('Submit')).click()
+```
+
+### createFinder
+
+Defines a custom finder that can be used instead of css as an argument to `find`/`set`. Example:
+
+```js
+import { createFinder } from 'browser-monkey'
+
+const Flash = createFinder(q => q.find('.messages .flash'))
+await page.find(Flash).containing('Success!').shouldExist()
+```
+
+It's possible to create finders that accept parameters:
+
+```js
+const Flash = createFinder('Flash', (q, flashType) => q.find(`.flash-${flashType}`))
+await page.shouldContain({
+ [Flash('success')]: 'Success!',
+ [Flash('alert')]: /Fail/,
+})
+```
+
+Custom finders can be used in [`set()`](#set) too:
+
+```js
+page.set({
+ [CustomParent]: {
+ [CustomChild]: "new value"
+ }
+})
+```
+
+### is
+
+Narrows the scope to match selector. This is useful for composability. Consider the following example:
+
+```js
+const alert = page.find('.alert')
+
+// and then later
+await alert.is('.success').shouldExist()
+
+// and further down
+await alert.is('.danger').shouldExist()
+```
+
+In the end, `find('.a').is('.b')` is the same as `find('.a.b')`. However the latter is atomic. Whereas the former can be composed programmatically bit by bit.
+
+### containing
+
+Narrows a scope based on its content. For example:
+
+```js
+const scope = page.find('.alert').containing('Success!')
+```
+
+Will only yield elements with class `alert` whose text content is 'Success!'. A RegExp can be used instead of a string for elements that _contain_ 'Success!'.
+
+It's also possible to examine the content of individual bits inside the scope:
+
+```js
+const scope = page.find('.result').containing({
+ '.title': 'Title',
+ '.body': /Body/
+})
+```
+
+Text content is not the only filtering option - element attributes can be inspected too:
+
+```js
+import {matchers} from 'browser-monkey'
+
+const scope = page.find('.result').containing({
+ '.body': matchers.elementAttributes({style: {color: 'red'}})
+})
+```
+
+### filter
+
+Narrow query scope based on a filtering function. The function receives a DOM element as its argument and returns either truthy or falsey. If truthy, then the element will be considered as part of the scope, if falsey then it won't.
+
+```js
+const [sally] = await page
+ .find('.contact')
+ .filter(e => e.querySelector('.name').innerText === 'Sally')
+```
+
+### findButton
+
+Find button by one of the following criteria:
+
+- `input[type=button|submit]`, `button` or `a` element text
+- `label` text, enclosing an `input[type=radio|checkbox]`
+- `label` text whose `for` attribute points to an `input[type=checkbox]`
+- checkbox's `aria-label`
+- `label` text whose `id` is referenced by a checkbox's `aria-labelledby`
+- custom finder, defined by `addButtonDefinition()`
+
+If you find button in order to click than you probably want `clickButton()` instead.
+
+### addButtonDefinition
+
+Define custom button finder. If you have non-standard buttons - e.g. `
` - then you can use `addButtonDefinition()` to have browser-monkey look it up when calling `findButton()`/`clickButton()` methods:
+
+```js
+const query = page.addButtonDefinition(
+ (query, name) => query.find('div.button').containing(name)
+)
+
+await query.clickButton('Login')
+```
+
+You can name custom finders so that they can be later removed with [`removeButtonDefinition()`](#removeButtonDefinition).
+
+```js
+const query = page.addButtonDefinition(
+ 'div-button',
+ (query, name) => query.find('div.button').containing(name)
+)
+```
+
+### removeButtonDefinition
+
+Remove button definition. You can remove both built-in button definitions and custom ones defined with [`addButtonDefinition()`](#addButtonDefinition).
+
+```js
+// built-in
+page.removeButtonDefinition('aria-labelledby')
+// custom
+page.removeButtonDefinition('div-button')
+```
+
+Built-in definitions:
+
+- `button`: elements that match `button, input[type=button], input[type=submit], input[type=reset], a`
+- `label`: labels that contain a clickable input
+- `label-for`: labels with `for` attribute
+- `aria-label`: elements with attribute `aria-label`
+- `aria-labelledby`: elements whole `id` is referenced by another element's `aria-labelledby`
+
+### addFieldDefinition
+
+When you use `find("Field('some-input')")`, browser-monkey is matching against a few built-in field definitions. It should cover most cases, but sometimes your inputs are special and then you might want to teach browser-monkey to recognise them.
+
+For example, let's say that in your universe this is an input:
+
+```html
+
+```
+
+By default, browser-monkey won't recognise it as such. But with `addFieldDefinition` it will:
+
+```js
+await page.set({ "Field('Search')": 'bananas' })
+// => error!
+
+page.addFieldDefinition('data-label', (query, label) => (
+ query.find(`[data-label=${label}]`)
+))
+
+await page.set({ "Field('Search')": 'bananas' })
+// => ok!
+```
+
+### removeFieldDefinition
+
+Remove field definition. E.g:
+
+```js
+browser.removeFieldDefinition('data-label')
+```
+
+### result
+
+Synchronously resolves query:
+
+```js
+page.find('.thing') // => new Query object
+page.find('.thing').result() // => array of matching elements
+```
+
+### map
+
+Use Javascript to transform a query (rather than css).
+
+Sometimes, it's easier to define a query in Javascript. For example, getting a "target" element of a label's "for" attribute isn't even possible in css. So we could use something like this:
+
+```js
+page.find('label[for]').map(e => (
+ document.getElementById(e.getAttribute('for'))
+)).filter(Boolean)
+```
+
+## Assertions
+
+### shouldExist
+
+Wait for an element to exist.
+
+```js
+await browser.find('.selector').shouldExist([options]);
+```
+
+* `options.timeout` - length of time to wait for the element (1000ms)
+* `options.interval` - time between testing the dom (10ms)
+* `options.allowMultiple` - allow multiple elements to be found, default just one
+
+Returns a promise that resolves when the element exists, or is rejected if the timeout expires.
+
+### shouldFind
+As an alternative to `browser.find('.selector').shouldExist()` you can also do:
+
+```js
+await browser.shouldFind('.selector')
+````
+
+### shouldNotExist
+Waits for the element not to exist.
+
+```js
+await scope.shouldNotExist([options]);
+```
+
+* `options.timeout` - length of time to wait for the element (1000ms)
+* `options.interval` - time between testing the dom (10ms)
+
+Returns a promise that resolves when the element no longer exists, or is rejected if the timeout expires.
+
+### shouldHave
+Assert that a scope has certain properties.
+
+```js
+await scope.shouldHave([options]);
+
+//e.g.:
+await browser.find('#topMonkey').shouldHave({ text: 'Olive Baboon' });
+```
+
+would match:
+
+```html
+OliveBaboon
+```
+
+or if checking multiple elements:
+```
+await browser.find('#top5 .monkey-species').shouldHave({ text: [
+ 'Olive Baboon',
+ 'Patas Monkey',
+ 'Proboscis Monkey',
+ 'Pygmy Marmoset',
+ 'Red-Handed Tamarin']
+});
+```
+
+would match:
+
+```html
+
+ Olive Baboon
+ Patas Monkey
+ Proboscis Monkey
+ Pygmy Marmoset
+ Red-Handed Tamarin
+
+```
+
+You can also match child components:
+
+```js
+const component = browser.component({
+ airport: function(){
+ return this.find('.airport').component({
+ date: function(){ return this.find('.date'); }
+ });
+ }
+});
+
+component.shouldHave({
+ airport: {
+ text: 'LHR',
+ date: { exactText: 'Aug 2055' }
+ }
+});
+```
+
+would match:
+
+```html
+
+
+ LHR
+ Aug 2055
+
+```
+
+```js
+browser.find('img').shouldHave({
+ attributes: [
+ {src: '/monkey1.jpg', alt: 'first monkey'},
+ {src: '/monkey2.jpg', alt: 'second monkey'},
+ ]
+})
+```
+
+would match:
+
+```html
+
+
+```
+
+* `options.text` - a string, expects the resolved scope to contain the text. If an array of strings, expects the elements to have the same number of elements as there are strings in the array, and expects each string to be found in each respective element's text.
+* `options.exactText` - a string, expects the resolved scope to have the exact text. If an array of strings, expects the elements to have the same number of elements as there are strings in the array, and expects each string to equal each respective element's text.
+* `options.css` - a CSS string. Expects the resolved element to be matched by the CSS selector. Note that it won't match if the element contains other elements that match the CSS selector. So if we have `{css: '.class'}` then we expect the resolved element to have a class `class`.
+* `options.value` - a string, expects the resolved element to be an input and have the value. An array expects the same number of inputs, each with the respective value.
+* `options.exactValue` - a string, expects the resolved scope to have the exact value. If an array of strings, expects the elements to have the same number of elements as there are strings in the array, and expects each string to equal each respective element's value.
+* `options.checked` - a boolean, expects the resolved element to be an checkbox input and to be checked or not. An array expects the same number of checkboxes, each with the respective checked value.
+* `options.html` - a string, expects the resolved element to have the html. An array expects the same number of elements, each with the respective html.
+* `options.length` - a number, expects there to be this number of elements
+* `options.elements` - a function, which is passed the resolved elements, return truthy for a match, falsey for a failure.
+* `options.attributes` - an object or an array of objects representing the attributes that should appear on one or more elements, `shouldHave({ attributes: { href: '/home' } })` would match ` `
+* `options.message` - the error message
+* `options.timeout` - length of time to wait for the element (1000ms)
+* `options.interval` - time between testing the dom (10ms)
+
+### shouldHaveElement
+Assert that there is one element, and that it passes the expectations of a function.
+
+```js
+await scope.shouldHaveElement(fn, [options]);
+```
+
+* `fn` a function that tests the element. The function is repeatedly called until it doesn't throw an exception, or until the timeout.
+* `options.timeout` the timeout given for the element to pass the expectations, default 1000ms.
+* `options.interval` - time between testing the dom (10ms)
+
+### shouldHaveElements
+Assert that the elements found in the scope pass an expectation.
+
+```js
+await scope.shouldHaveElements(fn, [options]);
+```
+
+* `fn` a function that tests the elements. The function is repeatedly called until it doesn't throw an exception, or until the timeout.
+* `options.timeout` the timeout given for the element to pass the expectations, default 1000ms.
+* `options.interval` - time between testing the dom (10ms)
+
+## Actions
+### click
+Returns a promise that resolves once the element has been found and the click has been triggered
+
+```js
+await scope.click();
+```
+
+### typeIn
+Returns a promise that resolves once the element has been found and the text has been entered.
+
+```js
+await scope.typeIn(text);
+```
+
+* `text` the text to type into the input.
+
+### submit
+Returns a promise that resolves once the element has been found and the submit event has been triggered
+
+```js
+await scope.submit();
+```
+
+### select
+Returns a promise that resolves once the element has been found and the matching item selected from the select box
+
+```js
+scope.select({text: 'Text of option'})
+```
+
+or
+
+```js
+scope.select('Text of option')
+```
+
+Example:
+
+```html
+
+ First
+ Second
+
+```
+
+```js
+const scope = browser.component({
+ mySelect: function(){
+ return this.find('.my-select');
+ }
+})
+
+await scope.mySelect().select({text: 'Second'});
+```
+
+```js
+scope.select([options]);
+```
+
+* `options.text` - a string, text to match against the options text, this will also match partial text
+* `options` could also just be the text of the string to match
+
+### fill
+It can be tedious to fill out forms using `typeIn`, `select`, etc.
+Fill lets you easily specify fields and actions to run:
+
+```
+const address = browser.component({
+ street: function(){return this.find('.street');},
+ city: function(){return this.find('.city');},
+ country: function(){return this.find('.country');},
+});
+
+address.fill([
+ {name: 'street', action: 'typeIn', options: {text: 'Monkey St'}},
+ {name: 'city', action: 'typeIn', options: {text: 'Browserville'}},
+ {name: 'country', action: 'select', options: {text: 'Monkey Island'}},
+]);
+```
+
+This is exectuted as if you wrote this:
+
+```
+await address.street().typeIn({text: 'Monkey St'})
+await address.city().typeIn({text: 'Browserville'});
+await address.country().select({text: 'Monkey Island'});
+```
+
+* name - the name of any element on the component
+* action - an action to perform, eg. `select`, `typeIn`
+* options - a hash of options that is passed to the action
+
+or if this syntax is still too long for you try the abridged version:
+
+```
+address.fill([
+ {typeIn: 'street', text: 'Monkey St'},
+ {typeIn: 'city', text: 'Browserville'},
+ {select: 'country', text: 'Monkey Island'},
+]);
+```
+
+### elements
+Returns a promise resolving to the list of elements matched by the scope.
+
+```js
+await scope.elements([options]);
+```
+
+* `elements` - the HTML DOM elements matched by the scope.
+* `options.timeout` - length of time to wait for the element (1000ms)
+* `options.interval` - time between testing the dom (10ms)
+
+### element
+Returns a promise resolving to the single element matched by the scope, it will be rejected if there are multiple.
+
+```js
+await scope.element([options]);
+```
+
+* `element` - the HTML DOM element matched by the scope.
+* `options.timeout` - length of time to wait for the element (1000ms)
+* `options.interval` - time between testing the dom (10ms)
+
+### link
+`browser.link('gorilla')` matches:
+ - `gorilla `
+ - `link `
+ - `link `
+ - ` `
+
+### button
+A button is considered any of the following types - `input[type=submit]`, `input[type=button]`, `input[type=reset]`
+
+`browser.button('tamarin')` matches:
+ - `tamarin `
+ - `button `
+ - `button `
+ - `button `
+ - ` `
+
+### linkOrButton
+would match either a link or button according to their respective rules
+
+### click
+Normally the click action is performed on a scope but you can also provide it with a string `browser.click('monkey')` and it will search for a link or button that matches and perform the click on it.
+
+## Events
+### on
+You can receive an event whenever an interaction is made on the DOM, such as a click or text entry. The event will have the element that is interacted with, the event type and other properties depending on the event type.
+
+```js
+const scopeWithEvents = scope.on(function (event) {
+ // handle event
+});
+```
+
+* `event.type` is one of `'click'`, `'typing'`, `'typing html'`, `'select option'`.
+* `event.element` is the element that received the interaction, i.e. the button or input.
+* `event.optionElement` is the option element selected, in the case of type `'select option'`.
+* `event.text` is the text entered, in the case of type `'typing'`.
+* `event.html` is the html entered, in the case of type `'typing html'`.
diff --git a/docs/codesandbox/basic-example/index.html b/docs/codesandbox/basic-example/index.html
deleted file mode 100755
index 7927ca9..0000000
--- a/docs/codesandbox/basic-example/index.html
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
- Beer App
-
-
-
-
-
-
-
diff --git a/docs/codesandbox/basic-example/src/app.js b/docs/codesandbox/basic-example/src/app.js
deleted file mode 100755
index 9822e99..0000000
--- a/docs/codesandbox/basic-example/src/app.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import { html as h } from "hyperdom";
-
-export default class App {
- async getBeerInfo() {
- delete this.beer;
- const response = await fetch("https://api.punkapi.com/v2/beers/192");
- this.beer = await response.json();
- }
-
- render() {
- return h("main", [
- h("h1", "Hello Lubbers"),
- h("button", { onclick: () => this.getBeerInfo() }, "Beer"),
- this.beer
- ? h("div", [
- h("div", this.beer[0].name),
- h("img", { src: this.beer[0].image_url, width: 100 })
- ])
- : undefined
- ]);
- }
-}
diff --git a/docs/codesandbox/get-started-example/index.html b/docs/codesandbox/get-started-example/index.html
new file mode 100644
index 0000000..e886000
--- /dev/null
+++ b/docs/codesandbox/get-started-example/index.html
@@ -0,0 +1,29 @@
+
+
+
+ Parcel Sandbox
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/codesandbox/basic-example/package.json b/docs/codesandbox/get-started-example/package.json
old mode 100755
new mode 100644
similarity index 62%
rename from docs/codesandbox/basic-example/package.json
rename to docs/codesandbox/get-started-example/package.json
index 420f325..1dab8c7
--- a/docs/codesandbox/basic-example/package.json
+++ b/docs/codesandbox/get-started-example/package.json
@@ -1,5 +1,5 @@
{
- "name": "browser-monkey-example",
+ "name": "bm3-example",
"version": "1.0.0",
"description": "Browser Monkey basic usage example",
"main": "index.html",
@@ -8,10 +8,14 @@
"build": "parcel build index.html"
},
"dependencies": {
- "browser-monkey": "latest",
+ "browser-monkey": "3.0.0-beta.13",
"hyperdom": "latest"
},
"devDependencies": {
+ "@babel/core": "latest",
+ "@babel/preset-env": "latest",
+ "babel-preset-hyperdom": "^2.0.0",
"parcel-bundler": "latest"
- }
+ },
+ "keywords": []
}
diff --git a/docs/codesandbox/get-started-example/src/app.js b/docs/codesandbox/get-started-example/src/app.js
new file mode 100644
index 0000000..17f0e14
--- /dev/null
+++ b/docs/codesandbox/get-started-example/src/app.js
@@ -0,0 +1,38 @@
+import { html as h } from "hyperdom"
+
+export default class App {
+ async getBeerInfo() {
+ delete this.beer
+ const response = await fetch(
+ `https://api.punkapi.com/v2/beers?beer_name=${this.query || "sunk"}`
+ )
+ this.beer = await response.json()
+ }
+
+ onsubmit(e) {
+ e.preventDefault()
+ return this.getBeerInfo()
+ }
+
+ render() {
+ return h("main", [
+ h("h1", "Hello, Lubbers!"),
+ h("form", { onsubmit: e => this.onsubmit(e) },
+ [
+ h("label", [
+ "Name a beer",
+ h("br"),
+ h("input", { binding: [this, "query"] })
+ ]),
+ h("button", {type: 'submit'}, "Get It")
+ ]
+ ),
+ this.beer
+ ? h("div", [
+ h("div", this.beer[0].name),
+ h("img", { src: this.beer[0].image_url, width: 100 })
+ ])
+ : undefined
+ ])
+ }
+}
diff --git a/docs/codesandbox/basic-example/src/index.js b/docs/codesandbox/get-started-example/src/index.js
old mode 100755
new mode 100644
similarity index 53%
rename from docs/codesandbox/basic-example/src/index.js
rename to docs/codesandbox/get-started-example/src/index.js
index fa62d96..f731b3e
--- a/docs/codesandbox/basic-example/src/index.js
+++ b/docs/codesandbox/get-started-example/src/index.js
@@ -1,4 +1,4 @@
import { append } from "hyperdom";
import App from "./app";
-//append(document.querySelector("#app"), new App());
+append(document.getElementById("app"), new App());
diff --git a/docs/codesandbox/get-started-example/test/app.spec.js b/docs/codesandbox/get-started-example/test/app.spec.js
new file mode 100644
index 0000000..81f6849
--- /dev/null
+++ b/docs/codesandbox/get-started-example/test/app.spec.js
@@ -0,0 +1,46 @@
+// Uncomment when running project locally
+// import 'regenerator-runtime/runtime'
+import { Mount, Query, Field, Button } from "browser-monkey"
+import hyperdom from "hyperdom"
+import App from "../src/app"
+
+describe("Browser-monkey", function () {
+ let mount, page
+
+ beforeEach(function () {
+ if (mount) mount.unmount()
+
+ // Create test DOM container
+ mount = new Mount({ className: 'test-mount' })
+
+ // Mount your SPA into the DOM as you would normally.
+ // E.g. for react: `ReactDOM.render(React.createElement(SomeApp), mount.containerElement()))`
+ hyperdom.append(mount.containerElement(), new App())
+
+ // Set browser-monkey query scope to the test container
+ page = new Query().mount(mount)
+ })
+
+ it("finds text", async function () {
+ await page.find("h1").shouldContain("Hello, Lubbers!")
+ })
+
+ it("fills an input", async function () {
+ await page.set({
+ [Field("Name a beer")]: "punk ipa"
+ })
+ })
+
+ it("clicks a button", async function () {
+ await page.find(Button("Get It")).click()
+ })
+
+ it("finds text, eventually rendered by an ajax call", async function () {
+ // Another way to submit search form
+ await page.enterText('input', ['punk ipa', '{Enter}'])
+ await page.shouldContain(/Punk IPA 2010 - Current/)
+ })
+})
+
+// eslint-disable-next-line
+mocha.run()
diff --git a/docs/coverpage.md b/docs/coverpage.md
new file mode 100644
index 0000000..2b58b56
--- /dev/null
+++ b/docs/coverpage.md
@@ -0,0 +1,11 @@
+# browser monkey
+
+> Fast and reliable browser testing
+
+- Faster and more reliable than Selenium or Cypress.
+- Well thought out API.
+- Works with your favourite framework.
+- Easy debugging.
+- Runs in your favourite test runner.
+
+[Get Started](introduction)
diff --git a/docs/index.html b/docs/index.html
new file mode 100644
index 0000000..25d8d51
--- /dev/null
+++ b/docs/index.html
@@ -0,0 +1,52 @@
+
+
+
+
+ browser-monkey - reliable dom testing
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/introduction.md b/docs/introduction.md
new file mode 100644
index 0000000..81fcdbc
--- /dev/null
+++ b/docs/introduction.md
@@ -0,0 +1,20 @@
+# Browser Monkey
+
+## What it is
+
+Browser Monkey is a DOM manipulation and assertion library. It helps you write framework agnostic browser tests that are reliable in the face of asynchronous behaviours like animations, AJAX and delayed rendering. It also helps you to write tests that exhibit the semantic meaning of the page, as opposed to a jumble of CSS selectors.
+
+## Features
+
+ - Automatically waits for elements to appear on the page.
+ - Simulates text entry and clicks.
+ - Rich DSLs for your page structure.
+ - Promise based.
+ - Framework agnostic.
+ - Typescript types.
+
+## Example
+
+[view code](/docs/codesandbox/get-started-example/test/app.spec.js#L3-L43)
+
+[codesandbox](/docs/codesandbox/get-started-example?module=/test/app.spec.js)
diff --git a/docs/quickstart.md b/docs/quickstart.md
new file mode 100644
index 0000000..859593c
--- /dev/null
+++ b/docs/quickstart.md
@@ -0,0 +1,79 @@
+# Quick start
+
+Let's craete a tiny React project and test it with Browser Monkey.
+
+```bash
+yarn add react react-dom
+yarn add browser-monkey --dev
+```
+
+Now create a test file: `test/appSpec.js`.
+For simplicity we will create our react application in the test file.
+
+```js
+const {Query} = require('browser-monkey')
+const {default: ReactMount} = require('browser-monkey/ReactMount')
+const React = require('react')
+
+class App extends React.Component {
+ render () {
+ return React.createElement('div', {className: 'greeting'}, 'Hello World')
+ }
+}
+
+describe('greeting', () => {
+ it('renders a greeting', async () => {
+ const mount = new ReactMount(React.createElement(App, {}, null))
+ const page = new Query().mount(mount)
+
+ await page.find('.greeting').containing('Hello World').shouldExist()
+ })
+})
+```
+
+You will need a browser environment to run your tests in. For general feature development we recommend using [electron](#electron) - it does not require any javascript bundling and so your tests will run faster. For testing across different browser environments you can use something like [karma](#karma).
+
+## Electron
+
+```bash
+yarn add electron electron-mocha --dev
+```
+
+Now you can run the test using `electron-mocha`, the `--renderer` flag tells electron to run the test in it's built in browser, the `--interactive` flag makes the browser visible so that you can debug or inspect the test
+
+```bash
+yarn electron-mocha test/**/*Spec.js --renderer --interactive
+```
+
+[Clone this example](https://github.com/featurist/browser-monkey3-electron-mocha/)
+
+## Karma
+
+```bash
+yarn add karma karma-mocha karma-chrome-launcher karma-webpack webpack --dev
+```
+
+Create a karma config file `karma.conf.js`
+
+```js
+module.exports = function(config) {
+ config.set({
+ frameworks: ['mocha'],
+ files: [
+ 'test/**/*Spec.js',
+ ],
+ preprocessors: {
+ 'test/**/*Spec.js': ['webpack']
+ },
+ webpack: {},
+ reporters: ['progress'],
+ port: 9876,
+ colors: true,
+ browsers: ['Chrome']
+ })
+}
+```
+
+Now you can run the testing using `yarn karma start`
+
+[Clone this example](https://github.com/featurist/browser-monkey3-karma/)
diff --git a/docs/update-readme-example-links.js b/docs/update-readme-example-links.js
deleted file mode 100644
index 5a9ea0b..0000000
--- a/docs/update-readme-example-links.js
+++ /dev/null
@@ -1,67 +0,0 @@
-const fs = require('fs')
-const {escape} = require('querystring')
-const {getParameters} = require('codesandbox/lib/api/define')
-
-function generateExampleSandboxLink (exampleTemplateName, exampleCode) {
- const templatePath = `${process.cwd()}/docs/codesandbox/${exampleTemplateName}`
- const parameters = getParameters({
- files: {
- 'package.json': {
- content: fs.readFileSync(`${templatePath}/package.json`, {encoding: 'utf-8'})
- },
- 'src/app.spec.js': {
- content: exampleCode
- },
- 'src/app.js': {
- content: fs.readFileSync(`${templatePath}/src/app.js`, {encoding: 'utf-8'})
- },
- 'src/index.js': {
- content: fs.readFileSync(`${templatePath}/src/index.js`, {encoding: 'utf-8'})
- },
- 'index.html': {
- content: fs.readFileSync(`${templatePath}/index.html`, {encoding: 'utf-8'})
- }
- }
- })
- const url = `https://codesandbox.io/api/v1/sandboxes/define?parameters=${parameters}&query=${escape('module=/src/app.spec.js')}`
- return `Run this example `
-}
-
-const input = fs.readFileSync(`${process.cwd()}/readme.md`, {encoding: 'utf-8'})
-
-const output = input.split('\n').reduce((result, line) => {
- if (!line.match('https://codesandbox.io/api/v1/sandboxes/define')) {
- result.lines.push(line)
- }
-
- if (result.currentExampleLines) {
- if (line.match(/``` *$/)) {
- const linkToExampleSandbox = generateExampleSandboxLink(
- result.currentExampleTemplateName,
- result.currentExampleLines.join('\n')
- )
- result.lines.push(linkToExampleSandbox)
-
- delete result.currentExampleLines
- delete result.currentExampleTemplateName
- } else {
- result.currentExampleLines.push(line)
- }
- }
-
- const [, exampleTemplateName] = line.match(/```js +codesandbox: +([\w-]+)/) || []
-
- if (exampleTemplateName) {
- result.currentExampleTemplateName = exampleTemplateName
- result.currentExampleLines = []
- }
-
- return result
-}, {lines: []})
-
-fs.promises.truncate(`${process.cwd()}/readme.md`, 0).then(() => {
- return fs.writeFileSync(`${process.cwd()}/readme.md`, output.lines.join('\n'))
-}).catch(e => {
- console.error(e)
- process.exit(1)
-})
diff --git a/electron/foreignIframe.js b/electron/foreignIframe.js
new file mode 100644
index 0000000..b1bbfb8
--- /dev/null
+++ b/electron/foreignIframe.js
@@ -0,0 +1,23 @@
+const { session, app } = require('electron')
+
+const omitHeaders = new Set([
+ 'x-frame-options',
+ 'content-security-policy'
+])
+
+app.on('ready', function () {
+ app.commandLine.appendSwitch('disable-pushstate-throttle')
+
+ session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
+ Object.keys(details.responseHeaders).forEach(header => {
+ if (omitHeaders.has(header.toLowerCase())) {
+ delete details.responseHeaders[header]
+ }
+ })
+ callback({
+ cancel: false,
+ responseHeaders: details.responseHeaders,
+ statusLine: details.statusLine
+ })
+ })
+})
diff --git a/export-mounts.js b/export-mounts.js
new file mode 100755
index 0000000..095738e
--- /dev/null
+++ b/export-mounts.js
@@ -0,0 +1,18 @@
+#!/usr/bin/env node
+const fs = require('fs')
+
+function moveFileAndUpdateImports(path) {
+ fs.copyFileSync(`./dist/lib/${path}`, `./${path}`)
+ fs.copyFileSync(`./dist/lib/${path}.map`, `./${path}.map`)
+
+ const jsFile = fs.readFileSync(`./${path}`, {encoding: 'utf-8'})
+ const newJsFile = jsFile.replace(/require\("\.\//, 'require("./dist/lib/')
+ fs.writeFileSync(`./${path}`, newJsFile)
+
+ const mapFile = fs.readFileSync(`./${path}.map`, {encoding: 'utf-8'})
+ const newMapFile = mapFile.replace('../..', '.')
+ fs.writeFileSync(`./${path}.map`, newMapFile)
+}
+
+moveFileAndUpdateImports('HyperdomMount.js')
+moveFileAndUpdateImports('ReactMount.js')
diff --git a/hyperdom.js b/hyperdom.js
deleted file mode 100644
index 5b5251a..0000000
--- a/hyperdom.js
+++ /dev/null
@@ -1,41 +0,0 @@
-var Mount = require('./lib/mount')
-var hyperdom = require('hyperdom')
-var createMonkey = require('./create')
-var window = require('global')
-var createTestDiv = require('./lib/createTestDiv')
-var extend = require('lowscore/extend')
-
-module.exports = function (app, options) {
- return new Mount(app, {
- stopApp: function () {
- },
- startApp: function () {
- if (options && options.router) {
- options.router.reset()
- }
- var app = this.app
-
- if (Mount.runningInNode) {
- try {
- var vquery = require('vdom-query')
- } catch (e) {
- throw new Error('you must `npm install vdom-query --save-dev` to run tests in node')
- }
- var vdom = hyperdom.html('body')
-
- var monkey = createMonkey(vdom)
- monkey.set({$: vquery, visibleOnly: false, document: {}})
-
- hyperdom.appendVDom(vdom, app, extend({ requestRender: setTimeout, window: window }, options))
- return monkey
- } else {
- var testDiv = createTestDiv()
- if (options && (options.hash || options.url) && options.router) {
- options.router.push(options.url || options.hash)
- }
- hyperdom.append(testDiv, app, extend({ requestRender: setTimeout }, options))
- return createMonkey(testDiv)
- }
- }
- }).start()
-}
diff --git a/iframe.js b/iframe.js
deleted file mode 100644
index 5cec069..0000000
--- a/iframe.js
+++ /dev/null
@@ -1,45 +0,0 @@
-var debug = require('debug')('browser-monkey:angular')
-var Mount = require('./lib/mount')
-var createMonkey = require('./create')
-var hobostyle = require('hobostyle')
-var createTestDiv = require('./lib/createTestDiv')
-var addressBarInterval
-
-module.exports = function (url) {
- return new Mount(url, {
- stopApp: function () {},
- startApp: function () {
- debug('Mounting iframe: ' + url)
- var div = createTestDiv()
- var addressBar = document.createElement('div')
- addressBar.innerText = url
- addressBar.className = 'address-bar'
- div.appendChild(addressBar)
-
- var iframe = document.createElement('iframe')
- iframe.src = url
- iframe.onload = function () {
- addressBar.innerText = iframe.contentWindow.location.href
- }
- iframe.height = window.innerHeight - addressBar.clientHeight - 10
- div.appendChild(iframe)
-
- if (addressBarInterval) {
- clearInterval(addressBarInterval)
- }
- addressBarInterval = setInterval(function () {
- if (iframe.contentWindow) {
- addressBar.innerText = iframe.contentWindow.location.href
- } else {
- clearInterval(addressBarInterval)
- }
- }, 300)
-
- hobostyle.style('html,body { margin: 0; height: 100%; }')
- hobostyle.style('iframe { border: none; width: 100%; }')
- hobostyle.style('.address-bar { padding: 5px; font-family: arial; font-size: 20px; border-bottom: 1px solid gray; }')
-
- return createMonkey(iframe)
- }
- }).start()
-}
diff --git a/index.js b/index.js
deleted file mode 100644
index 9838084..0000000
--- a/index.js
+++ /dev/null
@@ -1 +0,0 @@
-module.exports = require('./create')()
diff --git a/index.ts b/index.ts
new file mode 100644
index 0000000..ddd440e
--- /dev/null
+++ b/index.ts
@@ -0,0 +1,10 @@
+export {Query, createFinder, Button, Field} from './lib/Query'
+import IFrameMount from './lib/IFrameMount'
+import Mount from './lib/Mount'
+import * as matchers from './lib/matchers'
+
+export {
+ IFrameMount,
+ Mount,
+ matchers,
+}
diff --git a/karma.conf.js b/karma.conf.js
index 7ef82ad..4dfd124 100644
--- a/karma.conf.js
+++ b/karma.conf.js
@@ -1,60 +1,11 @@
-module.exports = function (config) {
- config.set({
- concurrency: 5,
- basePath: '',
- frameworks: ['browserify', 'mocha'],
- files: [
- 'test/global.js',
- 'test/**/*Spec.js',
- 'test/page1.html',
- 'test/page2.html',
- 'test/iframe-mount-test.html'
- ],
- exclude: [
- '**/*.sw?'
- ],
- preprocessors: {
- 'test/global.js': ['browserify', 'env'],
- 'test/**/*Spec.js': ['browserify']
- },
-
- envPreprocessor: [
- 'BM_TIMEOUT'
- ],
-
- browserify: {
- debug: true,
- extensions: ['.jsx'],
- transform: ['babelify', [require('./utils/removeStrictFromSend'), {global: true}]]
- },
-
- client: {
- mocha: {
- timeout: 0
- }
- },
- reporters: process.env.BROWSERS ? ['dots'] : ['mocha'],
- port: 9876,
- colors: true,
- logLevel: config.LOG_INFO,
- autoWatch: true,
- browsers: process.env.BROWSERS === 'all' ? Object.keys(browsers) : [
- config.singleRun ? 'ChromeHeadless' : 'Chrome'
- ],
+const webpackConfig = require("./webpack.config");
- browserStack: {
- username: process.env.BROWSERSTACK_USER,
- accessKey: process.env.BROWSERSTACK_PASSWORD
- },
- singleRun: false,
- customLaunchers: browsers,
- browserNoActivityTimeout: 120000,
- browserDisconnectTimeout: 120000,
- browserDisconnectTolerance: 3
- })
-}
+const files = [
+ {pattern: "test/*.html", included: false},
+ "test/**/*Spec.ts",
+]
-var browsers = {
+const browserstackBrowsers = {
'browserstack-windows-firefox': {
base: 'BrowserStack',
browser: 'Firefox',
@@ -90,13 +41,6 @@ var browsers = {
os_version: 'Mojave',
resolution: '1280x1024'
},
- 'browserstack-ie11': {
- base: 'BrowserStack',
- browser: 'IE',
- os: 'Windows',
- os_version: '10',
- resolution: '1280x1024'
- },
'browserstack-edge': {
base: 'BrowserStack',
browser: 'Edge',
@@ -105,3 +49,39 @@ var browsers = {
resolution: '1280x1024'
}
}
+
+module.exports = function(config) {
+ const browser = process.env.BROWSER || 'Chrome'
+
+ const browsers = process.env.BROWSERS === 'all' ? Object.keys(browserstackBrowsers) : [
+ config.singleRun ? browser + 'Headless' : browser
+ ]
+
+ config.set({
+ basePath: "",
+ frameworks: ["mocha"],
+ files,
+ exclude: [],
+ preprocessors: {
+ "test/**/*.{ts,js,jsx,tsx}": ["webpack", "sourcemap"]
+ },
+ webpack: webpackConfig,
+ reporters: ["progress"],
+ port: 9876,
+ colors: true,
+ logLevel: config.LOG_INFO,
+ autoWatch: true,
+ singleRun: false,
+ concurrency: Infinity,
+ browsers,
+ browserStack: {
+ username: process.env.BROWSERSTACK_USER,
+ accessKey: process.env.BROWSERSTACK_PASSWORD
+ },
+
+ customLaunchers: browserstackBrowsers,
+ browserNoActivityTimeout: 120000,
+ browserDisconnectTimeout: 120000,
+ browserDisconnectTolerance: 3,
+ });
+};
diff --git a/lib/BrowserMonkeyAssertionError.ts b/lib/BrowserMonkeyAssertionError.ts
new file mode 100644
index 0000000..0ac994a
--- /dev/null
+++ b/lib/BrowserMonkeyAssertionError.ts
@@ -0,0 +1,52 @@
+import { ExecutedTransform } from './ExecutedTransform'
+import { ExecutedTransformPath } from './ExecutedTransformPath'
+
+class BrowserMonkeyAssertionError extends Error {
+ private description: string
+ public showDiff: boolean
+ public expected: any
+ public actual: any
+ public executedTransforms: ExecutedTransformPath
+ public duration: number
+ public retries: number
+
+ public constructor (message, {
+ expected = undefined,
+ actual = undefined,
+ executedTransforms = new ExecutedTransformPath(undefined)
+ } = {}) {
+ super(message)
+ this.description = message
+ this.showDiff = true
+ this.expected = expected
+ this.actual = actual
+ this.executedTransforms = executedTransforms
+
+ Object.setPrototypeOf(this, BrowserMonkeyAssertionError.prototype)
+ this.message = this.renderError()
+ }
+
+ public rewriteMessage (): void {
+ this.message = this.renderError()
+ }
+
+ public addExecutedTransform (executedTransform: ExecutedTransform): void {
+ this.executedTransforms.addTransform(executedTransform)
+ this.rewriteMessage()
+ }
+
+ public prependExecutedTransforms (executedTransforms: ExecutedTransformPath): void {
+ this.executedTransforms.prepend(executedTransforms)
+ this.rewriteMessage()
+ }
+
+ public renderError (): string {
+ const stats = this.duration !== undefined && this.retries !== undefined
+ ? ` [waited ${this.duration}ms, retried ${this.retries} times]`
+ : ''
+
+ return `${this.description}${stats}${this.executedTransforms.transforms.length ? ` (found: ${this.executedTransforms.renderError()})`: ''}`
+ }
+}
+
+export default BrowserMonkeyAssertionError
diff --git a/lib/Button.ts b/lib/Button.ts
new file mode 100644
index 0000000..5337b75
--- /dev/null
+++ b/lib/Button.ts
@@ -0,0 +1,11 @@
+import { createMultiFinder } from './MultiFinder'
+import * as fieldDefinitions from './fieldDefinitions'
+import inputSelectors from './inputSelectors'
+
+export const Button = createMultiFinder([
+ fieldDefinitions.button,
+ fieldDefinitions.label(() => inputSelectors.canBeClicked),
+ fieldDefinitions.labelFor,
+ fieldDefinitions.ariaLabel,
+ fieldDefinitions.ariaLabelledBy,
+])
diff --git a/lib/Css.ts b/lib/Css.ts
new file mode 100644
index 0000000..025b5a3
--- /dev/null
+++ b/lib/Css.ts
@@ -0,0 +1,4 @@
+import { createFinder } from './Finder'
+import { Query } from './Query'
+
+export const Css = createFinder((q: Query, css: string) => q.findCss(css))
diff --git a/lib/Dom.ts b/lib/Dom.ts
new file mode 100644
index 0000000..260418a
--- /dev/null
+++ b/lib/Dom.ts
@@ -0,0 +1,290 @@
+import {MouseEvent, KeyboardEvent} from './polyfills'
+import normaliseText from './normaliseText'
+import keycode from 'keycode'
+
+const eventCreatorsByType = {
+ mousedown: function () {
+ return createMouseEvent('mousedown')
+ },
+ mouseup: function () {
+ return createMouseEvent('mouseup')
+ },
+ change: function () {
+ return createEvent('change')
+ },
+ input: function () {
+ return createEvent('input')
+ },
+ keydown: function (key) {
+ return createKeyboardEvent('keydown', key)
+ },
+ keyup: function (key) {
+ return createKeyboardEvent('keyup', key)
+ },
+ keypress: function (key) {
+ return createKeyboardEvent('keypress', key)
+ },
+ submit: function () {
+ return createEvent('submit', { bubbles: true, cancelable: true })
+ }
+}
+
+function getFormSubmits(form) {
+ return form.querySelectorAll('input[type=submit],button:not([type=reset])')
+}
+
+function multipleInputsAllowImplicitSubmissionAndNoSubmitElements(form) {
+ const submits = getFormSubmits(form)
+
+ // https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#implicit-submission
+ // some types of inputs can submit the form when hitting {enter}
+ // but only if they are the sole input that allows implicit submission
+ // and there are no buttons or input[submits] in the form
+ const implicitSubmissionInputs = Array.from(form.querySelectorAll('input')).filter(isInputAllowingImplicitFormSubmission)
+
+ return (implicitSubmissionInputs.length > 1) && (submits.length === 0)
+}
+
+function simulateSubmitHandler(form, event) {
+ // bail if we have multiple inputs allowing implicit submission and no submit elements
+ if (multipleInputsAllowImplicitSubmissionAndNoSubmitElements(form)) {
+ return
+ }
+
+ const defaultButton = getFormSubmits(form)[0]
+
+ // bail if the default button is in a 'disabled' state
+ if (defaultButton && defaultButton.disabled) {
+ return
+ }
+
+ // issue the click event to the 'default button' of the form
+ // we need this to be synchronous so not going through our
+ // own click command
+ // as of now, at least in Chrome, causing the click event
+ // on the button will indeed trigger the form submit event
+ // so we dont need to fire it manually anymore!
+ if (defaultButton) {
+ defaultButton.click()
+ } else {
+ // if we werent able to click the default button
+ // then synchronously fire the submit event
+ // currently this is sync but if we use a waterfall
+ // promise in the submit command it will break again
+ // consider changing type to a Promise and juggle logging
+ const submitForm = (e) => {
+ if (e == event) {
+ form.removeEventListener('keypress', submitForm)
+ form.dispatchEvent(createEvent('submit', { bubbles: true, cancelable: true }))
+ }
+ }
+ return form.addEventListener('keypress', submitForm)
+ }
+}
+
+function isInputAllowingImplicitFormSubmission(el) {
+ // https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#implicit-submission
+ return [
+ 'text',
+ 'search',
+ 'url',
+ 'tel',
+ 'email',
+ 'password',
+ 'date',
+ 'month',
+ 'week',
+ 'time',
+ 'datetime-local',
+ 'number',
+ ].includes(el.type)
+}
+
+export default class Dom {
+ private jsdom: boolean
+
+ public constructor({jsdom = window.navigator.userAgent.includes('jsdom')} = {}) {
+ this.jsdom = jsdom
+ }
+
+ public enterText (element: HTMLInputElement, text: string | string[], {incremental = true} = {}): void {
+ element.focus()
+
+ const enterText = (text: string) => {
+ if (matchKeyCode(text)) {
+ this.triggerEvent(element, 'keydown', text)
+ this.triggerEvent(element, 'keyup', text)
+
+ if (element.form && isInputAllowingImplicitFormSubmission(element) && text == '{Enter}') {
+ const event = createKeyboardEvent('keypress', text)
+ simulateSubmitHandler(element.form, event)
+ element.dispatchEvent(event)
+ } else {
+ this.triggerEvent(element, 'keypress', text)
+ }
+ } else if (incremental) {
+ if (element.value !== '') {
+ this.typeKey(element, '', undefined)
+ }
+
+ text.split('').forEach((key, index) => {
+ const value = text.slice(0, index + 1)
+ this.typeKey(element, value, key)
+ })
+ } else {
+ this.setInputValue(element, text)
+ }
+ }
+
+ if (typeof text === 'string') {
+ enterText(text)
+ } else {
+ text.forEach(t => enterText(t))
+ }
+
+ this.triggerEvent(element, 'change')
+ }
+
+ public elementInnerText (element: HTMLElement): string {
+ const text = this.jsdom
+ ? element.textContent
+ : element.innerText
+
+ return normaliseText(text)
+ }
+
+ public click (element: HTMLElement): void {
+ this.triggerEvent(element, 'mousedown')
+ this.triggerEvent(element, 'mouseup')
+ element.focus()
+ element.click()
+ }
+
+ public querySelectorAll (element: HTMLElement, selector: string, {visibleOnly = true} = {}): HTMLElement[] {
+ const children = Array.prototype.slice.call(element.querySelectorAll(selector))
+
+ return visibleOnly && !this.jsdom
+ ? children.filter(c => this.elementVisible(c))
+ : children
+ }
+
+ public elementVisible (element: HTMLElement): boolean {
+ if (element.tagName === 'OPTION' && element.parentNode) {
+ return this.elementVisible(element.parentNode as HTMLElement)
+ } else {
+ return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length)
+ }
+ }
+
+ public elementMatches (element: HTMLElement, selector: string): boolean {
+ return element.matches(selector)
+ }
+
+ public selectOption (selectElement: HTMLSelectElement, optionElement: HTMLOptionElement): void {
+ this.click(selectElement)
+ selectElement.selectedIndex = optionElement.index
+ this.triggerEvent(selectElement, 'input')
+ this.triggerEvent(selectElement, 'change')
+ }
+
+ private triggerEvent (element: HTMLElement, eventType, value?): void {
+ const creator = eventCreatorsByType[eventType]
+
+ if (!creator) {
+ throw new Error('event type ' + JSON.stringify(eventType) + ' not recognised')
+ }
+
+ const event = creator(value)
+
+ element.dispatchEvent(event)
+ }
+
+ public setInputValue (element: HTMLInputElement, value: string): void {
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), 'value').set
+ if (nativeInputValueSetter) {
+ nativeInputValueSetter.call(element, value)
+ } else {
+ element.value = value
+ }
+ this.triggerEvent(element, 'input')
+ }
+
+ public typeKey (element: HTMLInputElement, value: string, key: string): void {
+ this.triggerEvent(element, 'keydown', key)
+ this.triggerEvent(element, 'keypress', key)
+ this.setInputValue(element, value)
+ this.triggerEvent(element, 'keyup', key)
+ }
+
+ public checked (element: HTMLInputElement): 'indeterminate'|boolean {
+ return element.indeterminate
+ ? 'indeterminate'
+ : element.checked
+ }
+
+ public selector (element: HTMLElement): string {
+ const id = element.id
+ const classes = element.className.split(/ +/g).filter(Boolean).sort()
+ const tag = element.tagName.toLowerCase()
+
+ return (id || '') +
+ tag +
+ (classes.length ? '.' + classes.join('.') : '')
+ }
+
+ public submit (element: HTMLInputElement): void {
+ element.focus()
+ this.triggerEvent(element, 'keydown')
+ this.triggerEvent(element, 'keypress')
+ const submitButton = getFormSubmits(element.form)[0]
+ if (submitButton) {
+ (submitButton as HTMLElement).click()
+ } else {
+ this.triggerEvent(element.form, 'submit')
+ }
+ this.triggerEvent(element, 'keyup')
+ }
+}
+
+function createMouseEvent (type): MouseEvent {
+ // @ts-ignore
+ return new window.MouseEvent(type, { bubbles: true, cancelable: true })
+}
+
+function createEvent (type, params = {bubbles: true, cancelable: false}): Event {
+ return new window.Event(type, params)
+}
+
+function matchKeyCode (text) {
+ return /^{(.*)}$/.exec(text)
+}
+
+function createKeyboardEvent (type, key): KeyboardEvent {
+ // @ts-ignore
+ const event = new window.KeyboardEvent(type, { bubbles: true, cancelable: true })
+
+ const match = matchKeyCode(key)
+
+ if (match) {
+ const code = match[1]
+ Object.defineProperty(event, 'code', {
+ get: () => code,
+ })
+ const which = keycode(code)
+ Object.defineProperty(event, 'keyCode', {
+ get: () => which,
+ })
+ Object.defineProperty(event, 'which', {
+ get: () => which,
+ })
+ Object.defineProperty(event, 'key', {
+ get: () => code,
+ })
+ } else {
+ Object.defineProperty(event, 'key', {
+ get: () => key,
+ })
+ }
+
+ return event
+}
diff --git a/lib/ExecutedAndTransform.ts b/lib/ExecutedAndTransform.ts
new file mode 100644
index 0000000..ab8cb24
--- /dev/null
+++ b/lib/ExecutedAndTransform.ts
@@ -0,0 +1,14 @@
+import { ExecutedTransform } from './ExecutedTransform'
+
+export class ExecutedAndTransform extends ExecutedTransform {
+ public items: ExecutedTransform[]
+
+ public constructor (value: any, items: ExecutedTransform[]) {
+ super(value)
+ this.items = items
+ }
+
+ public renderError (): string {
+ return `and(${this.items.map(i => i.renderError()).join(', ')}) [${this.value.length}]`
+ }
+}
diff --git a/lib/ExecutedConcatTransform.ts b/lib/ExecutedConcatTransform.ts
new file mode 100644
index 0000000..901c0ce
--- /dev/null
+++ b/lib/ExecutedConcatTransform.ts
@@ -0,0 +1,14 @@
+import { ExecutedTransform } from './ExecutedTransform'
+
+export class ExecutedConcatTransform extends ExecutedTransform {
+ public items: ExecutedTransform[]
+
+ public constructor (value: any, items: ExecutedTransform[]) {
+ super(value)
+ this.items = items
+ }
+
+ public renderError (): string {
+ return `concat(${this.items.map(i => i.renderError()).join(', ')}) [${this.value.length}]`
+ }
+}
diff --git a/lib/ExecutedContainingTransform.ts b/lib/ExecutedContainingTransform.ts
new file mode 100644
index 0000000..05fa5a1
--- /dev/null
+++ b/lib/ExecutedContainingTransform.ts
@@ -0,0 +1,17 @@
+import { ExecutedTransform } from './ExecutedTransform'
+import inspect from 'object-inspect'
+
+export class ExecutedContainingTransform extends ExecutedTransform {
+ public model: any
+ public failingActuals: any[]
+
+ public constructor (value: any, model: any, failingActuals: any[]) {
+ super(value)
+ this.model = model
+ this.failingActuals = failingActuals
+ }
+
+ public renderError (): string {
+ return `containing(${this.failingActuals ? inspect({expected: this.model, actual: this.failingActuals}) : inspect(this.model)}) [${this.value.length}]`
+ }
+}
diff --git a/lib/ExecutedDetectTransform.ts b/lib/ExecutedDetectTransform.ts
new file mode 100644
index 0000000..76dab4e
--- /dev/null
+++ b/lib/ExecutedDetectTransform.ts
@@ -0,0 +1,14 @@
+import { ExecutedTransform } from './ExecutedTransform'
+
+export class ExecutedDetectTransform extends ExecutedTransform {
+ public items: {key: string, transform: ExecutedTransform}[]
+
+ public constructor (value: {key: string, value: any}, items: {key: string, transform: ExecutedTransform}[]) {
+ super(value)
+ this.items = items
+ }
+
+ public renderError (): string {
+ return `detect(${this.items.map(i => `${i.key}: ${i.transform.renderError()}`).join(', ')}) [${this.value ? this.value.length : 0}]`
+ }
+}
diff --git a/lib/ExecutedFirstOfTransform.ts b/lib/ExecutedFirstOfTransform.ts
new file mode 100644
index 0000000..c037de4
--- /dev/null
+++ b/lib/ExecutedFirstOfTransform.ts
@@ -0,0 +1,16 @@
+import { ExecutedTransform } from './ExecutedTransform'
+
+export class ExecutedFirstOfTransform extends ExecutedTransform {
+ public items: ExecutedTransform[]
+ public index: number
+
+ public constructor (value: any, index: number, items: ExecutedTransform[]) {
+ super(value)
+ this.index = index
+ this.items = items
+ }
+
+ public renderError (): string {
+ return `firstOf(${this.items.map(i => i.renderError()).join(', ')}) [${this.value.length}]`
+ }
+}
diff --git a/lib/ExecutedSimpleTransform.ts b/lib/ExecutedSimpleTransform.ts
new file mode 100644
index 0000000..c49e00a
--- /dev/null
+++ b/lib/ExecutedSimpleTransform.ts
@@ -0,0 +1,16 @@
+import { ExecutedTransform } from './ExecutedTransform'
+
+export class ExecutedSimpleTransform extends ExecutedTransform {
+ public description: string
+
+ public constructor (value: any, description = '') {
+ super(value)
+ this.description = description
+ }
+
+ public renderError (): string {
+ if (this.description) {
+ return `${this.description} [${this.value.length}]`
+ }
+ }
+}
diff --git a/lib/ExecutedTransform.ts b/lib/ExecutedTransform.ts
new file mode 100644
index 0000000..b5bb521
--- /dev/null
+++ b/lib/ExecutedTransform.ts
@@ -0,0 +1,15 @@
+export class ExecutedTransform {
+ public value: any
+
+ public constructor (value: any) {
+ this.value = value
+ }
+
+ public renderError (): string {
+ throw new Error()
+ }
+
+ public print (): void {
+ throw new Error()
+ }
+}
diff --git a/lib/ExecutedTransformError.ts b/lib/ExecutedTransformError.ts
new file mode 100644
index 0000000..ba545bb
--- /dev/null
+++ b/lib/ExecutedTransformError.ts
@@ -0,0 +1,15 @@
+import { ExecutedTransform } from './ExecutedTransform'
+import BrowserMonkeyAssertionError from './BrowserMonkeyAssertionError'
+
+export class ExecutedTransformError extends ExecutedTransform {
+ public exception: BrowserMonkeyAssertionError
+
+ public constructor (exception: BrowserMonkeyAssertionError) {
+ super([])
+ this.exception = exception
+ }
+
+ public renderError (): string {
+ return this.exception.renderError()
+ }
+}
diff --git a/lib/ExecutedTransformPath.ts b/lib/ExecutedTransformPath.ts
new file mode 100644
index 0000000..6d6000d
--- /dev/null
+++ b/lib/ExecutedTransformPath.ts
@@ -0,0 +1,44 @@
+import { ExecutedTransform } from './ExecutedTransform'
+
+export class ExecutedTransformPath extends ExecutedTransform {
+ public transforms: ExecutedTransform[]
+
+ public constructor (value: any) {
+ super(value)
+ this.transforms = []
+ }
+
+ public addTransform (transform: ExecutedTransform): void {
+ this.value = transform.value
+ this.transforms.push(transform)
+ }
+
+ public addTransforms (transforms: ExecutedTransform[]): void {
+ this.value = transforms[transforms.length - 1].value
+ this.transforms.push(...transforms)
+ }
+
+ public prepend (transforms: ExecutedTransformPath): void {
+ this.value = transforms.value
+ this.transforms.unshift(...transforms.transforms)
+ }
+
+ public clear (): void {
+ this.transforms = []
+ }
+
+ public renderError (): string {
+ const errors = this.transforms.map(t => t.renderError()).filter(Boolean).join(', ')
+ if (this.transforms.length > 1) {
+ return `path(${errors})`
+ } else {
+ return errors
+ }
+ }
+
+ public clone (): ExecutedTransformPath {
+ const e = new ExecutedTransformPath(this.value)
+ e.transforms = this.transforms.slice()
+ return e
+ }
+}
diff --git a/lib/Field.ts b/lib/Field.ts
new file mode 100644
index 0000000..96b8d4d
--- /dev/null
+++ b/lib/Field.ts
@@ -0,0 +1,11 @@
+import { Query } from './Query'
+import { createMultiFinder } from './MultiFinder'
+import * as fieldDefinitions from './fieldDefinitions'
+
+export const Field = createMultiFinder([
+ fieldDefinitions.label((query: Query) => query.inputSelector()),
+ fieldDefinitions.labelFor,
+ fieldDefinitions.ariaLabel,
+ fieldDefinitions.ariaLabelledBy,
+ fieldDefinitions.placeholder,
+])
diff --git a/lib/Finder.ts b/lib/Finder.ts
new file mode 100644
index 0000000..27a36d6
--- /dev/null
+++ b/lib/Finder.ts
@@ -0,0 +1,80 @@
+import { Query } from './Query'
+
+const finders = {}
+
+export const finderIdProperty = Symbol()
+export const finderFunctionProperty = Symbol()
+
+let lastFinderId = 0
+
+export type FinderFunction = (query: Query, ...any) => Query
+
+export interface Finder {
+ (...args: any[]): string
+ [finderIdProperty]: string
+ [finderFunctionProperty]: FinderFunction
+}
+
+function replacer(key, value) {
+ if (value instanceof RegExp) {
+ return {
+ prototype: "RegExp",
+ source: value.source,
+ flags: value.flags
+ }
+ } else {
+ return value
+ }
+}
+
+export function createFinder (css: string): any;
+export function createFinder (finderFunction: FinderFunction): any;
+
+export function createFinder (cssOrFinder: any): any {
+ const id = lastFinderId++
+
+ const finderFunction = typeof cssOrFinder == 'string'
+ ? q => q.findCss(cssOrFinder)
+ : cssOrFinder
+
+ const finder = function (...args) {
+ return JSON.stringify({
+ id,
+ args
+ }, replacer)
+ }
+
+ finder.toString = function () { return JSON.stringify({id, args: []}) }
+ finder[finderIdProperty] = id
+ finder[finderFunctionProperty] = finderFunction
+
+ finders[id] = finder
+
+ return finder
+}
+
+function reviver(key, value) {
+ if (value instanceof Object && value.prototype) {
+ switch (value.prototype) {
+ case 'RegExp':
+ return new RegExp(value.source, value.flags)
+ }
+ }
+
+ return value
+}
+
+export function parseFinder(finderString) {
+ if (finderString[0] === '{') {
+ const {id, args} = JSON.parse(finderString, reviver)
+ const finder = finders[id]
+ return {
+ finder,
+ args
+ }
+ }
+}
+
+export function callFinder(finder: Finder, query: Query, ...args: any[]) {
+ return finder[finderFunctionProperty](query, ...args)
+}
diff --git a/lib/HyperdomMount.ts b/lib/HyperdomMount.ts
new file mode 100644
index 0000000..19cf114
--- /dev/null
+++ b/lib/HyperdomMount.ts
@@ -0,0 +1,18 @@
+import Mount from './Mount'
+import hyperdom from 'hyperdom'
+import extend from 'lowscore/extend'
+
+export default class HyperdomMount extends Mount {
+ constructor (app: any, options?) {
+ super()
+ if (options && options.router) {
+ options.router.reset()
+ }
+
+ const testDiv = this.containerElement()
+ if (options && (options.hash || options.url) && options.router) {
+ options.router.push(options.url || options.hash)
+ }
+ hyperdom.append(testDiv, app, extend({ requestRender: setTimeout }, options))
+ }
+}
diff --git a/lib/IFrameMount.ts b/lib/IFrameMount.ts
new file mode 100644
index 0000000..9e22228
--- /dev/null
+++ b/lib/IFrameMount.ts
@@ -0,0 +1,104 @@
+import _debug from 'debug'
+import Mount from './Mount'
+import hobostyle from 'hobostyle'
+import { iframeResizer } from 'iframe-resizer'
+import {Query} from '../lib/Query'
+
+const styles = `
+.browser-monkey-browser {
+ padding: 13px;
+ position: absolute;
+ display: flex;
+ flex-direction: column;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+}
+
+.browser-monkey-address-bar-text {
+ border-radius: 10px;
+ background-color: white;
+}
+
+.browser-monkey-address-bar {
+ padding: 5px;
+ background-color: grey;
+}
+
+.browser-monkey-iframe {
+ width: 100%;
+ height: 100%;
+}
+`
+
+const debug = _debug('browser-monkey:iframe')
+
+export default class IFrameMount extends Mount {
+ url: string
+ resize: boolean
+ _iframe: HTMLIFrameElement
+
+ constructor (url: string, {resize = false} = {}) {
+ super()
+
+ this.url = url
+ this.resize = resize
+
+ debug('Mounting iframe: ' + this.url)
+
+ const div = this.containerElement()
+ div.className = 'browser-monkey-browser'
+
+ div.innerHTML = `
+
+
+ `
+
+ const forwardButton = div.querySelector('.browser-monkey-forward-button') as HTMLElement
+ const backButton = div.querySelector('.browser-monkey-back-button') as HTMLElement
+ const addressBarText = div.querySelector('.browser-monkey-address-bar-text') as HTMLElement
+ addressBarText.innerText = this.url
+ const iframe = div.querySelector('.browser-monkey-iframe') as HTMLIFrameElement
+
+ forwardButton.addEventListener('click', () => {
+ iframe.contentWindow.history.forward()
+ })
+
+ backButton.addEventListener('click', () => {
+ iframe.contentWindow.history.back()
+ })
+
+ iframe.src = this.url
+ iframe.addEventListener('load', () => {
+ addressBarText.innerText = iframe.contentWindow.location.href
+
+ if (this.resize) {
+ const script = iframe.contentDocument.createElement('script')
+ script.src = 'file://' + __dirname + '/node_modules/iframe-resizer/js/iframeResizer.contentWindow.min.js'
+ iframe.contentDocument.head.appendChild(script)
+ }
+ })
+
+ if (this.resize) {
+ iframeResizer({ log: true, checkOrigin: false }, iframe)
+ }
+
+ hobostyle.style(styles.toString())
+
+ this._iframe = iframe
+ }
+
+ iframe () {
+ return this._iframe
+ }
+
+ mount (query: Query): Query {
+ query.setInput(this._iframe)
+ return query.iframe()
+ }
+}
diff --git a/lib/Mount.ts b/lib/Mount.ts
new file mode 100644
index 0000000..1857e4f
--- /dev/null
+++ b/lib/Mount.ts
@@ -0,0 +1,37 @@
+import {Query} from './Query'
+
+export default class Mount {
+ private _mountDiv: HTMLElement
+ private className: string
+ private parentNode: HTMLElement
+
+ constructor ({className = undefined, parentNode = window.document.body} = {}) {
+ this.className = className
+ this.parentNode = parentNode
+ this.containerElement()
+ }
+
+ public containerElement (): HTMLElement {
+ if (!this._mountDiv) {
+ this._mountDiv = window.document.createElement('div')
+ if (this.className) {
+ this._mountDiv.className = this.className
+ }
+ this.parentNode.appendChild(this._mountDiv)
+ }
+
+ return this._mountDiv
+ }
+
+ public mount (query: Query): Query {
+ query.setInput(this._mountDiv)
+ return query
+ }
+
+ public unmount (): void {
+ if (this._mountDiv?.parentNode) {
+ this._mountDiv.parentNode.removeChild(this._mountDiv)
+ }
+ }
+}
+
diff --git a/lib/MultiFinder.ts b/lib/MultiFinder.ts
new file mode 100644
index 0000000..95250f0
--- /dev/null
+++ b/lib/MultiFinder.ts
@@ -0,0 +1,56 @@
+import { Finder, createFinder, FinderFunction } from './Finder'
+import { Query } from './Query'
+
+type NamedFinder = {
+ name: string,
+ finder: FinderFunction
+}
+
+export interface MultiFinder extends Finder {
+ addFinder(name: string | FinderFunction, finder?: FinderFunction)
+ removeFinder(name: string)
+ resetFinders()
+ clone(name?: string): MultiFinder
+ finders: NamedFinder[]
+}
+
+export function createMultiFinder(initialFinders?: NamedFinder[]): MultiFinder {
+ const finders = initialFinders.slice()
+
+ const finder = createFinder((query: Query, ...args: any[]) => {
+ return query.concat(finders.map(({finder}) => {
+ return (q: Query): Query => {
+ return finder(q, ...args)
+ }
+ }))
+ }) as MultiFinder
+
+ finder.addFinder = function (name: string | FinderFunction, finder?: FinderFunction) {
+ if (!finder) {
+ finder = name as FinderFunction
+ name = undefined
+ }
+
+ finders.push({
+ name: name as string,
+ finder
+ })
+ }
+
+ finder.removeFinder = function (name: string) {
+ const index = finders.findIndex(def => def.name === name)
+ if (index >= 0) {
+ finders.splice(index, 1)
+ } else {
+ throw new Error(`finder ${JSON.stringify(name)} doesn't exist`)
+ }
+ }
+
+ finder.clone = function (): MultiFinder {
+ return createMultiFinder(finders)
+ }
+
+ finder.finders = finders
+
+ return finder
+}
diff --git a/lib/Query.ts b/lib/Query.ts
new file mode 100644
index 0000000..4bfb5eb
--- /dev/null
+++ b/lib/Query.ts
@@ -0,0 +1,1045 @@
+import { ExecutedTransform } from './ExecutedTransform'
+import { ExecutedTransformPath } from './ExecutedTransformPath'
+import { ExecutedSimpleTransform } from './ExecutedSimpleTransform'
+import { ExecutedConcatTransform } from './ExecutedConcatTransform'
+import { ExecutedContainingTransform } from './ExecutedContainingTransform'
+import { ExecutedFirstOfTransform } from './ExecutedFirstOfTransform'
+import { ExecutedDetectTransform } from './ExecutedDetectTransform'
+import { ExecutedTransformError } from './ExecutedTransformError'
+import Dom from './Dom'
+import BrowserMonkeyAssertionError from './BrowserMonkeyAssertionError'
+import toExecutedTransform from './toExecutedTransform'
+import pluralize from 'pluralize'
+import extend from 'lowscore/extend'
+import retry from './retry'
+import inspect from 'object-inspect'
+import uniq from 'lowscore/uniq'
+const debug = require('debug')('browser-monkey')
+import inputSelectors from './inputSelectors'
+import object from 'lowscore/object'
+import range from 'lowscore/range'
+import flatten from 'lowscore/flatten'
+import {match} from './match'
+import {
+ Finder,
+ callFinder,
+ createFinder,
+ parseFinder
+} from './Finder'
+import { Button } from './Button'
+import { Field } from './Field'
+import { Css } from './Css'
+
+export { MultiFinder } from './MultiFinder'
+
+export { Button, Field, createFinder, Css }
+
+type Transform = (elements: Array, executedTransforms: ExecutedTransform[]) => any
+type Action = (elements: Array, executedTransforms: ExecutedTransform[]) => void
+interface InputDefinition {
+ selector?: string,
+ values?: (query: Query) => Query
+ setter?: (query: Query, value: any) => Query
+ valueAsserters?: (query: Query, expected: any) => Query
+}
+
+type Match = {
+ isMatch: boolean,
+ actual: any,
+ expected: any,
+}
+
+type LiteralModel = string | RegExp | boolean
+type FunctionModel = (query: Query) => void
+export type Model = LiteralModel | FunctionModel | { [key: string]: Model } | Model[]
+
+interface Definitions {
+ inputs: InputDefinition[]
+}
+
+const missing = {}
+
+export class Query implements Promise {
+ private _transforms: Transform[]
+ private _options: Options
+ private _input: HTMLElement[]
+ private _actionExecuted = false
+ private _action: Action
+ private _hasExpectation = false
+ private _dom: Dom
+
+ public constructor (input: HTMLElement = document.body) {
+ this._transforms = []
+ this._options = {
+ visibleOnly: true,
+ timeout: 1000,
+ interval: 10,
+ definitions: {
+ inputs: [
+ {
+ selector: 'input[type=radio]',
+ setter: (query: Query, value) => {
+ return query
+ .shouldHaveElements(1)
+ .transform(([radio]) => {
+ if (value !== true) {
+ throw new Error('a radio button cannot be unset, or set to any value other than true')
+ }
+ return () => {
+ if (!query._dom.checked(radio as HTMLInputElement)) {
+ debug('radio', radio, value)
+ query._dom.click(radio)
+ }
+ }
+ })
+ },
+ values: (query: Query) => {
+ return query
+ .is('input[type=radio]')
+ .map((radio: HTMLInputElement) => {
+ return query._dom.checked(radio)
+ })
+ }
+ },
+ {
+ selector: 'input[type=checkbox]',
+ setter: (query: Query, value) => {
+ return query
+ .shouldHaveElements(1)
+ .transform(([checkbox]) => {
+ if (typeof value !== 'boolean') {
+ throw new Error('expected boolean as argument to set checkbox')
+ }
+ return () => {
+ if (query._dom.checked(checkbox as HTMLInputElement) !== value) {
+ debug('checkbox', checkbox, value)
+ query._dom.click(checkbox)
+ }
+ }
+ })
+ },
+ values: (query: Query) => {
+ return query
+ .is('input[type=checkbox]')
+ .map((checkbox: HTMLInputElement) => {
+ return query._dom.checked(checkbox)
+ })
+ }
+ },
+ {
+ selector: 'select',
+ setter: (query: Query, value) => {
+ return query
+ .shouldHaveElements(1, 'expected to be select element')
+ .findCss('option')
+ .filter((o: HTMLInputElement) => {
+ return match(o.value, value).isMatch || match(query._dom.elementInnerText(o), value).isMatch
+ }, `option with text or value ${JSON.stringify(value)}`)
+ .shouldHaveElements(1, `expected one option element with text or value ${JSON.stringify(value)}`)
+ .transform(([option]) => {
+ return () => {
+ const selectElement = option.parentNode
+ debug('select', selectElement)
+ query._dom.selectOption(selectElement as HTMLSelectElement, option as HTMLOptionElement)
+ }
+ })
+ },
+ valueAsserters: (query: Query, expected) => {
+ return query
+ .is('select')
+ .map((select: HTMLSelectElement) => {
+ return () => {
+ const value = select.value
+
+ const matchValue = match(expected, value)
+
+ if (matchValue.isMatch) {
+ return matchValue
+ }
+
+ const selectedOption = select.options[select.selectedIndex]
+ if (selectedOption) {
+ const actual = query._dom.elementInnerText(selectedOption)
+ const matchText = match(expected, actual)
+
+ if (matchText.isMatch) {
+ return matchText
+ } else {
+ return {
+ isMatch: false,
+ actual,
+ expected,
+ }
+ }
+ }
+
+ return {
+ isMatch: false,
+ actual: value,
+ expected,
+ }
+ }
+ })
+ },
+ values: (query: Query) => {
+ return query
+ .is('select')
+ .map((select: HTMLSelectElement) => {
+ const selectedOption = select.options[select.selectedIndex]
+ return selectedOption && query._dom.elementInnerText(selectedOption)
+ })
+ }
+ },
+ {
+ selector: inputSelectors.canSetText,
+ setter: (query: Query, value) => {
+ return query
+ .shouldHaveElements(1)
+ .transform(([element]) => {
+ if (typeof value !== 'string') {
+ throw new Error('expected string as argument to set input')
+ }
+ return () => {
+ debug('set', element, value)
+ query._dom.enterText(element as HTMLInputElement, value, {incremental: false})
+ }
+ })
+ },
+ values: (query: Query) => {
+ return query.is(inputSelectors.canGetText).map((input: HTMLInputElement) => {
+ return input.value
+ })
+ }
+ },
+ {
+ values: (query: Query) => {
+ return query.map((element) => {
+ return query._dom.elementInnerText(element)
+ })
+ }
+ },
+ ]
+ }
+ }
+
+ this._dom = new Dom()
+
+ this._input = [input]
+ }
+
+ public get [Symbol.toStringTag](): string {
+ return 'Query';
+ }
+
+ private transform (transform: Transform): Query {
+ return this.clone(clone => clone._transforms.push(transform))
+ }
+
+ public expect (expectation: (elements: E[]) => void): Query {
+ const expectQuery = this.transform(function (value) {
+ expectation.call(this, value)
+ return value
+ })
+
+ expectQuery._hasExpectation = true
+
+ return expectQuery
+ }
+
+ private action (action: Action): Query {
+ if (this._action) {
+ throw new Error('can only have one action')
+ }
+
+ return this.clone(clone => {
+ clone._action = action
+ })
+ }
+
+ // TODO: try removing any
+ public result (): any {
+ return this.execute().value
+ }
+
+ public resolve (input: any): Query {
+ const resolved = this.clone()
+ resolved._input = input
+ resolved._transforms = []
+ return resolved
+ }
+
+ public map (map: (e: E) => any, description?: string): Query {
+ return this.transform((elements) => {
+ return new ExecutedSimpleTransform(elements.map(map), description)
+ })
+ }
+
+ public filter (filter: (e: E) => boolean, description?: string): Query {
+ return this.transform((elements) => {
+ return new ExecutedSimpleTransform(elements.filter(filter), description)
+ })
+ }
+
+ public concat (queryCreators: ((q: Query) => Query)[]): Query {
+ return this.transform((elements) => {
+ const resolved = this.resolve(elements)
+
+ const transforms = queryCreators.map(queryCreator => runQueryCreator(queryCreator, resolved).execute())
+
+ const value = uniq(Array.prototype.concat.apply([], transforms.map(t => t.value)))
+ return new ExecutedConcatTransform(value, transforms)
+ })
+ }
+
+ private error (message: string, {expected = undefined, actual = undefined} = {}): void {
+ throw new BrowserMonkeyAssertionError(message, { expected, actual })
+ }
+
+ private execute (): ExecutedTransformPath {
+ const transformPath = new ExecutedTransformPath(this._input)
+
+ try {
+ this._transforms.forEach(transform => {
+ const executedTransform = toExecutedTransform(transform.call(this, transformPath.value, transformPath.transforms))
+ transformPath.addTransform(executedTransform)
+ })
+
+ if (this._action && !this._actionExecuted) {
+ this._action(transformPath.value, transformPath.transforms)
+ this._actionExecuted = true
+ }
+
+ return transformPath
+ } catch (e) {
+ if (e instanceof BrowserMonkeyAssertionError) {
+ e.prependExecutedTransforms(transformPath)
+ }
+
+ throw e
+ }
+ }
+
+ public firstOf (queryCreators: ((q: Query) => Query)[]): Query {
+ const transformed = this.transform((elements) => {
+ const resolved = this.resolve(elements)
+
+ const values = queryCreators.map(query => {
+ try {
+ const q = runQueryCreator(query, resolved)
+
+ return {
+ value: q.ensureExpectation().execute()
+ }
+ } catch (e) {
+ if (e instanceof BrowserMonkeyAssertionError) {
+ return {
+ error: new ExecutedTransformError(e)
+ }
+ } else {
+ throw e
+ }
+ }
+ })
+
+ const firstSuccessIndex = values.findIndex(v => {
+ return !v.error
+ })
+
+ const firstSuccess = values[firstSuccessIndex]
+
+ const transform = new ExecutedFirstOfTransform(firstSuccess ? firstSuccess.value.value : [], firstSuccessIndex, values.map(v => v.error || v.value))
+
+ if (firstSuccess) {
+ return transform
+ } else {
+ const error = new BrowserMonkeyAssertionError('all queries failed in firstOf')
+ error.addExecutedTransform(transform)
+ throw error
+ }
+ })
+
+ transformed._hasExpectation = true
+
+ return transformed
+ }
+
+ public detect (queryCreators: {[key: string]: (q: Query) => Query}): Query {
+ const transformed = this.transform((elements) => {
+ const resolved = this.resolve(elements)
+
+ const entries = Object.keys(queryCreators).map(key => {
+ const queryCreator = queryCreators[key]
+
+ try {
+ const q = runQueryCreator(queryCreator, resolved)
+
+ return {
+ key,
+ value: q.ensureExpectation().execute()
+ }
+ } catch (e) {
+ if (e instanceof BrowserMonkeyAssertionError) {
+ return {
+ key,
+ error: new ExecutedTransformError(e)
+ }
+ } else {
+ throw e
+ }
+ }
+ })
+
+ const firstSuccessIndex = entries.findIndex(v => {
+ return !v.error
+ })
+
+ const firstSuccess = entries[firstSuccessIndex]
+
+ const transform = new ExecutedDetectTransform(
+ firstSuccess
+ ? {
+ key: firstSuccess.key,
+ value: firstSuccess.value.value,
+ }
+ : undefined,
+ entries.map(v => ({key: v.key, transform: v.error || v.value}))
+ )
+
+ if (firstSuccess) {
+ return transform
+ } else {
+ const error = new BrowserMonkeyAssertionError('all queries failed in detect')
+ error.addExecutedTransform(transform)
+ throw error
+ }
+ })
+
+ transformed._hasExpectation = true
+
+ return transformed
+ }
+
+ public setOptions (options: Options): this {
+ extend(this._options, options)
+ return this
+ }
+
+ public getOptions (): Options {
+ return this._options
+ }
+
+ public then (resolve?: (r) => any, reject?: (e) => any): Promise {
+ const retry = retryFromOptions(this._options)
+
+ let retries = 0
+ const startTime = new Date()
+
+ const promise = Promise.resolve(retry(() => {
+ retries++
+ return this.ensureExpectation().execute().value
+ })).catch(error => {
+ if (error instanceof BrowserMonkeyAssertionError) {
+ error.retries = retries
+ error.duration = Number(new Date()) - Number(startTime)
+ error.rewriteMessage()
+
+ if (debug.enabled) {
+ debug('assertion error', error.message)
+ error.executedTransforms.transforms.forEach(transform => {
+ transform.print()
+ })
+ }
+ }
+
+ throw error
+ })
+
+ return promise.then.call(promise, resolve, reject)
+ }
+
+ public catch (fn): Promise {
+ return this.then(undefined, fn)
+ }
+
+ public finally (fn): Promise {
+ return this.then(
+ async r => {
+ await fn()
+ return r
+ },
+ async e => {
+ await fn()
+ throw e
+ })
+ }
+
+ private clone (modifier?: (clone: Query) => void): Query {
+ const clone = new (this.constructor as any)()
+ clone.copyQueryFields(this)
+ if (modifier) {
+ modifier(clone)
+ }
+ return clone
+ }
+
+ public setInput (elementOrArray: HTMLElement | HTMLElement[]) {
+ const input: HTMLElement[] = elementOrArray instanceof Array
+ ? elementOrArray
+ : [elementOrArray]
+
+ this._input = input
+ }
+
+ public input (): HTMLElement[] {
+ return this._input
+ }
+
+ private copyQueryFields (from: Query): void {
+ this._transforms = from._transforms.slice()
+ this._input = from._input
+
+ this._hasExpectation = from._hasExpectation
+
+ this._options = extend({}, from._options)
+ }
+
+ public shouldHaveElements (count: number, message?: string): Query {
+ return this.expect(elements => {
+ if (elements.length !== count) {
+ this.error(message || `expected ${count} ${pluralize('elements', count)}, found ` + elements.length)
+ }
+ })
+ }
+
+ public shouldExist (message?: string): Query {
+ return this.expect(elements => {
+ if (elements.length < 1) {
+ this.error(message || 'expected one or more elements, found ' + elements.length)
+ }
+ })
+ }
+
+ public shouldNotExist (message?: string): Query {
+ return this.expect(elements => {
+ if (elements.length !== 0) {
+ this.error(message || 'expected no elements, found ' + elements.length)
+ }
+ })
+ }
+
+ public elementResult (): HTMLElement {
+ return this.shouldHaveElements(1).result()[0]
+ }
+
+ public elementsResult (): HTMLElement {
+ return this.shouldExist().result()
+ }
+
+ public click (selector?: string): Query {
+ return this.optionalSelector(selector).shouldHaveElements(1).action(([element]) => {
+ debug('click', element)
+ this._dom.click(element)
+ })
+ }
+
+ public submit (selector?: string): Query {
+ return this.optionalSelector(selector)
+ .shouldHaveElements(1)
+ .expect(([element]) => {
+ if (!(element as HTMLInputElement).form) {
+ throw new BrowserMonkeyAssertionError('expected element to be inside a form for submit')
+ }
+ })
+ .action(([element]) => {
+ debug('submit', element)
+ this._dom.submit(element as HTMLInputElement)
+ })
+ }
+
+ public enterText (selector: string, text?: string | string[]): Query {
+ if (text === undefined) {
+ text = selector
+ selector = undefined
+ }
+
+ return this.optionalSelector(selector)
+ .shouldHaveElements(1)
+ .is(inputSelectors.canSetText)
+ .action(([element]) => {
+ debug('enterText', element, text)
+ this._dom.enterText(element as HTMLInputElement, text)
+ })
+ }
+
+ public mount (mount: {mount: (query: Query) => Query}): Query {
+ return mount.mount(this)
+ }
+
+ public iframe (selector?: string): Query {
+ return this.optionalSelector(selector).transform(elements => {
+ return new ExecutedSimpleTransform(elements.map(element => {
+ if (isIframe(element)) {
+ if (element.contentDocument && element.contentDocument.readyState === 'complete') {
+ return element.contentDocument.body
+ } else {
+ throw new BrowserMonkeyAssertionError('iframe not loaded')
+ }
+ } else {
+ throw new BrowserMonkeyAssertionError('not iframe')
+ }
+ }), 'iframe.contentDocument')
+ })
+ }
+
+ public enabled (): Query {
+ return this.filter(element => {
+ const tagName = element.tagName
+ return !((tagName === 'BUTTON' || tagName === 'INPUT') && (element as HTMLInputElement).disabled)
+ }, 'enabled')
+ }
+
+ public set (model: Model): Query {
+ return this.action(elements => {
+ const setters = []
+
+ const actions = {
+ arrayLengthError: (query: Query, actualLength, expectedLength): void => {
+ query.error('expected ' + expectedLength + ' ' + pluralize('elements', expectedLength) + ', found ' + actualLength)
+ },
+
+ value: (query: Query, model): ActualExpected => {
+ const setter = query.shouldHaveElements(1).setter(model).result()
+ setters.push(() => setter())
+ return {
+ actual: undefined,
+ expected: undefined,
+ }
+ },
+
+ expectOne: (query: Query): ActualExpected => {
+ query.shouldHaveElements(1).result()
+ return {
+ actual: {},
+ expected: {},
+ }
+ },
+
+ function: (query: Query, model): ActualExpected => {
+ setters.push(() => this.runModelFunction(model, query))
+ return {
+ actual: undefined,
+ expected: undefined,
+ }
+ }
+ }
+
+ const clone = this.resolve(elements)
+ clone.mapModel(model, actions)
+
+ setters.forEach(set => {
+ set()
+ })
+ })
+ }
+
+ public async shouldAppearAfter (action: () => void): Promise {
+ await this.shouldNotExist()
+ await action()
+ await this.shouldExist()
+ }
+
+ public async shouldDisappearAfter (action: () => void): Promise {
+ await this.shouldExist()
+ await action()
+ await this.shouldNotExist()
+ }
+
+ public shouldContain (model: Model): Query {
+ return this.expect(elements => {
+ let isError = false
+
+ const actions = {
+ arrayLengthError: (): void => {
+ isError = true
+ },
+
+ expectOne: (query: Query): ActualExpected => {
+ try {
+ query.shouldHaveElements(1).result()
+ return {actual: {}, expected: {}}
+ } catch (e) {
+ if (e instanceof BrowserMonkeyAssertionError) {
+ isError = true
+ return {
+ actual: 'Error: ' + e.message,
+ expected: {},
+ }
+ } else {
+ throw e
+ }
+ }
+ },
+
+ value: (query: Query, model): ActualExpected => {
+ const match = query.matchValue(model)
+
+ if (!match.isMatch) {
+ isError = true
+ }
+
+ return match
+ },
+
+ function: (query: Query, model): ActualExpected => {
+ try {
+ this.runModelFunction(model, query)
+ return {
+ actual: model,
+ expected: model,
+ }
+ } catch (e) {
+ if (e instanceof BrowserMonkeyAssertionError) {
+ isError = true
+ return {
+ actual: e.actual,
+ expected: e.expected,
+ }
+ } else {
+ throw e
+ }
+ }
+ }
+ }
+
+ const clone = this.resolve(elements)
+ const result = clone.mapModel(model, actions)
+
+ if (isError) {
+ this.error('could not match', {expected: result.expected, actual: result.actual})
+ }
+ })
+ }
+
+ public index (index: number): Query {
+ return this.transform(elements => {
+ if (elements.length <= index) {
+ this.error(`index(${index}) where there are only ${elements.length} elements`)
+ }
+ return new ExecutedSimpleTransform([elements[index]], 'index ' + index)
+ })
+ }
+
+ public inputSelector (): string {
+ return this._options.definitions.inputs.map(i => i.selector).filter(Boolean).join(',')
+ }
+
+ private setter (value): Query {
+ return this.firstOf(this._options.definitions.inputs.filter(def => def.setter).map(def => {
+ return query => def.setter(query.is(def.selector), value)
+ }))
+ }
+
+ private valueAsserters (expected: any): Query {
+ return this.transform(elements => {
+ const definitions = this._options.definitions.inputs.filter(def => def.values || def.valueAsserters)
+
+ const asserters = elements.map(element => {
+ const queryOfOneElement = this.resolve([element])
+
+ const firstAsserterForElement = definitions.map(def => {
+ const definitionAsserters = def.valueAsserters
+ ? def.valueAsserters(queryOfOneElement, expected)
+ : def.values(queryOfOneElement).transform(actuals => {
+ return actuals.map(actual => () => {
+ return match(actual, expected)
+ })
+ })
+
+ return definitionAsserters.result()[0]
+ })
+
+ return firstAsserterForElement.find(Boolean)
+ }).filter(Boolean)
+
+ return asserters
+ })
+ }
+
+ public addInputDefinition (inputDefinition: InputDefinition): this {
+ this._options.definitions.inputs.unshift(inputDefinition)
+ return this
+ }
+
+ public containing (model: Model): Query {
+ return this.transform(elements => {
+ const actions = {
+ arrayLengthError: (query: Query, actualLength, expectedLength): void => {
+ query.error('expected ' + expectedLength + ' ' + pluralize('elements', expectedLength) + ', found ' + actualLength)
+ },
+
+ value: (query: Query, model): ActualExpected => {
+ const match = query.matchValue(model)
+
+ if (!match.isMatch) {
+ isError = true
+ }
+
+ return match
+ },
+
+ function: (query: Query, fn): ActualExpected => {
+ this.runModelFunction(fn, query)
+ return {actual: fn, expected: fn}
+ }
+ }
+
+ let isError
+ const failingActuals = []
+ const matchingElements = []
+
+ elements.forEach(element => {
+ try {
+ const clone = this.resolve([element])
+
+ isError = false
+
+ const match = clone.mapModel(model, actions)
+
+ if (isError) {
+ failingActuals.push(match.actual)
+ } else {
+ matchingElements.push(element)
+ }
+ } catch (e) {
+ if (e instanceof BrowserMonkeyAssertionError) {
+ failingActuals.push({
+ isMatch: false,
+ actual: e.actual,
+ expected: e.expected,
+ })
+ } else {
+ throw e
+ }
+ }
+ })
+
+ return new ExecutedContainingTransform(matchingElements, model, matchingElements.length ? undefined : failingActuals)
+ })
+ }
+
+ public values (): Query {
+ return this.transform(elements => {
+ const definitions = this._options.definitions.inputs.filter(def => def.values)
+
+ const values = elements.map(element => {
+ const queryOfOneElement = this.resolve([element])
+
+ const value = definitions.map(def => {
+ const values = def.values(queryOfOneElement).transform(actuals => {
+ return actuals
+ }).result()
+
+ return values
+ }).filter(vs => vs.length).map(vs => vs[0])[0]
+
+ return value
+ })
+
+ return values
+ })
+ }
+
+ public findCss (selector: string): Query {
+ const findElements = this.transform(elements => {
+ return new ExecutedSimpleTransform(flatten(elements.map(element => {
+ return this._dom.querySelectorAll(element, selector, this._options)
+ })), 'find(' + inspect(selector) + ')')
+ })
+
+ return findElements
+ }
+
+ public find (selector: string | Finder): Query {
+ const selectorString = selector.toString()
+
+ const finderInvocation = parseFinder(selectorString)
+
+ if (finderInvocation) {
+ const {finder, args = []} = finderInvocation
+ return callFinder(finder, this, ...args)
+ } else {
+ return this.findCss(selectorString)
+ }
+ }
+
+ public is (selector: string): Query {
+ return this.filter(element => {
+ return this._dom.elementMatches(element, selector)
+ }, 'is: ' + selector)
+ }
+
+ private runModelFunction(fn: (query: Query) => any, query: Query): ExecutedTransform | undefined {
+ const result = fn(query)
+ if (result instanceof Query) {
+ return result.execute()
+ } else if (result && typeof result.then === 'function') {
+ throw new Error('model functions must not be asynchronous')
+ }
+ }
+
+ private optionalSelector (selector?: string): Query {
+ return selector ? this.find(selector) : this
+ }
+
+ private matchValue(model: any): Match {
+ const valueAsserters = this.valueAsserters(model).result()
+ const results = valueAsserters.map(valueAsserter => valueAsserter())
+ const success = results.find(r => r.isMatch)
+
+ if (success) {
+ return success
+ } else {
+ const actual = results.length === 1
+ ? results[0].actual
+ : results.length === 0
+ ? undefined
+ : results.map(r => r.actual)
+
+ return {
+ isMatch: false,
+ actual,
+ expected: model,
+ }
+ }
+ }
+
+ private mapModel (model: Model, actions: Actions): any {
+ const map = (query: Query, model: any): any => {
+ if (model === missing) {
+ return {
+ actual: query.result().map(e => this._dom.elementInnerText(e)).join(),
+ }
+ } else if (model instanceof Array) {
+ const items = spliceModelArrayFromActual(model, query, actions).map((item, index) => {
+ return map(query.index(index), item)
+ })
+
+ return {
+ actual: items.map(i => i.actual),
+ expected: arrayAssign(model, items.map(i => i.expected)),
+ }
+ } else if (model.constructor === Object) {
+ const keys = Object.keys(model)
+
+ if (keys.length) {
+ const properties = keys.map(selector => {
+ const value = model[selector]
+
+ const {actual, expected} = map(query.find(selector), value)
+
+ return [
+ selector,
+ actual,
+ expected,
+ ]
+ })
+
+ return {
+ actual: object(properties.map(([key, actual]) => [key, actual])),
+ expected: object(properties.map(([key, , expected]) => [key, expected])),
+ }
+ } else {
+ return actions.expectOne(query)
+ }
+ } else if (typeof model === 'function') {
+ return actions.function(query, model)
+ } else {
+ return actions.value(query, model)
+ }
+ }
+
+ return map(this, model)
+ }
+
+ private ensureExpectation(): Query {
+ if (!this._hasExpectation && !this._action) {
+ return this.shouldExist()
+ }
+ return this
+ }
+}
+
+function isIframe (element: HTMLElement): element is HTMLIFrameElement {
+ return (element as HTMLIFrameElement).contentWindow !== undefined
+}
+
+type Retry = (fn: () => T, options?: {timeout?: number, interval?: number}) => Promise
+
+function retryFromOptions (options: {retry?: Retry, timeout?: number, interval?: number}): Retry {
+ if (options && options.retry) {
+ return options.retry
+ } else if (options && (options.timeout || options.interval)) {
+ return function (fn) {
+ return retry(fn, options)
+ }
+ } else {
+ return retry
+ }
+}
+
+function runQueryCreator (queryCreator: (q: Query) => Query, query: Query): Query {
+ const q = queryCreator(query)
+
+ if (!(q instanceof Query)) {
+ throw new Error(`function ${queryCreator} expected to return Query but was: ` + q)
+ }
+
+ return q
+}
+
+interface ActualExpected {actual: any, expected: any}
+
+interface Actions {
+ arrayLengthError (q: Query, actual: number, expected: number): void
+ function (q: Query, model: any): ActualExpected
+ value (q: Query, model: any): ActualExpected
+ expectOne? (q: Query): ActualExpected
+}
+
+// TODO: try getting rid of any
+function spliceModelArrayFromActual (model, query: Query, actions: Actions): any[] {
+ const length = query.result().length
+
+ if (length > model.length) {
+ actions.arrayLengthError(query, length, model.length)
+ return range(0, length).map((item, index) => {
+ const modelItem = model[index]
+
+ if (modelItem !== undefined) {
+ return modelItem
+ } else {
+ return missing
+ }
+ })
+ } else if (length < model.length) {
+ actions.arrayLengthError(query, length, model.length)
+ return model.slice(0, length)
+ } else {
+ return model
+ }
+}
+
+// TODO: try getting rid of any
+function arrayAssign (a: any[], b: any[]): any[] {
+ return a.map((itemA, index) => {
+ return index < b.length ? b[index] : itemA
+ })
+}
+
+interface Options {
+ visibleOnly?: boolean
+ timeout?: number
+ interval?: number
+ definitions?: Definitions
+ retry?: Retry
+}
diff --git a/lib/ReactMount.ts b/lib/ReactMount.ts
new file mode 100644
index 0000000..3aa5f41
--- /dev/null
+++ b/lib/ReactMount.ts
@@ -0,0 +1,11 @@
+import Mount from './Mount'
+import ReactDOM from 'react-dom'
+
+export default class ReactMount extends Mount {
+ public constructor (vdom: any) {
+ super()
+ const div = this.containerElement()
+
+ ReactDOM.render(vdom, div)
+ }
+}
diff --git a/lib/actions.js b/lib/actions.js
deleted file mode 100644
index d6d7bcb..0000000
--- a/lib/actions.js
+++ /dev/null
@@ -1,194 +0,0 @@
-var debug = require('debug')('browser-monkey')
-var sendkeys = require('./sendkeys')
-var errorHandler = require('./errorHandler')
-function notSillyBlankIEObject (element) {
- return Object.keys(element).length > 0
-}
-module.exports = {
- focus: function (element, options) {
- var focus = typeof options === 'object' && options.hasOwnProperty('focus') ? options.focus : true
-
- if (focus) {
- var $ = this.get('$')
- var document = this.get('document')
- if (element && element.length > 0) {
- element = element[0]
- }
-
- var activeElement = document.activeElement
- if (activeElement && !$(activeElement).is(':focus') && notSillyBlankIEObject(activeElement)) {
- $(activeElement).trigger('blur')
- }
- if (['[object Document]', '[object HTMLDocument]'].indexOf(document.toString()) === -1) {
- document.activeElement = element
- }
- $(element).focus()
- }
- },
-
- click: function (options) {
- var self = this
-
- if (typeof options === 'string') {
- self = this.linkOrButton(options)
- }
-
- return self.enabled().element(options).then(function (element) {
- debug('click', element)
- self.handleEvent({type: 'click', element: element})
- self.focus(element, options)
- element.trigger('mousedown')
- element.trigger('mouseup')
- element.trigger('click')
- }).catch(errorHandler(new Error()))
- },
-
- select: function (options) {
- if (typeof options === 'string') {
- var o = arguments[1] || {}
- o.text = options
- return this.select(o)
- }
- var $ = this.get('$')
- var self = this
-
- return this.is('select').find('option', options).elements(options).then(function (optionElements) {
- var optionElement = $(optionElements[0])
- var selectElement = optionElement.parent()
- self.focus(selectElement, options)
- optionElement.prop('selected', true)
- optionElement.attr('selected', 'selected')
- selectElement[0].selectedIndex = optionElement[0].index
-
- debug('select', selectElement)
- self.handleEvent({
- type: 'select option',
- value: selectElement.val(),
- element: selectElement,
- optionElement: optionElement
- })
- selectElement.trigger('change')
- }).catch(errorHandler(new Error()))
- },
-
- typeIn: function (text, options) {
- if (typeof text === 'object') {
- text = text.text
- }
- var self = this
-
- return this.element(options).then(function (element) {
- debug('typeIn', element, text)
- assertCanTypeIntoElement(element)
- self.focus(element, options)
- self.handleEvent({type: 'typing', text: text, element: element})
- return sendkeys(element, text)
- }).catch(errorHandler(new Error()))
- },
-
- submit: function (options) {
- var self = this
-
- return this.element(options).then(function (element) {
- debug('submit', element)
- self.focus(element, options)
- self.handleEvent({type: 'submit', element: element})
- return element.trigger('submit')
- }).catch(errorHandler(new Error()))
- },
-
- typeInHtml: function (html, options) {
- var self = this
-
- return this.element(options).then(function (element) {
- self.focus(element, options)
- debug('typeInHtml', element, html)
- self.handleEvent({type: 'typing html', html: html, element: element})
- return sendkeys.html(element, html)
- }).catch(errorHandler(new Error()))
- },
-
- fill: function (field) {
- var isArray = Object.prototype.toString.call(field) === '[object Array]'
- var component = this
- var Promise = this.promise()
- return new Promise(function (resolve, reject) {
- if (isArray) {
- fillField(component, field)
- .then(resolve)
- .catch(reject)
- } else {
- if (!field.name) {
- try {
- field = inferField(component, field)
- } catch (e) {
- reject(e)
- return
- }
- }
-
- if (typeof component[field.name] === 'function') {
- resolve(component[field.name]()[field.action](field.options))
- } else {
- reject(new Error("No field '" + field.name + "' exists on this component"))
- }
- }
- })
- }
-}
-
-function fillField (component, fields) {
- var field = fields.shift()
- if (field) {
- return component.fill(field).then(function () {
- return fillField(component, fields)
- })
- } else {
- return Promise.resolve()
- }
-}
-
-function inferField (component, field) {
- var ignoreActions = {constructor: true, _options: true}
- for (var action in component) {
- if (field[action] && !ignoreActions[action]) {
- var newField = {
- name: field[action],
- action: action,
- options: field
- }
- delete field[action]
-
- if (field.options) {
- newField.options = field.options
- }
-
- if (typeof component[newField.name] !== 'function') {
- throw new Error("Field '" + newField.name + "' does not exist")
- }
-
- return newField
- }
- };
- if (!field.name) {
- throw new Error('No action found for field: ' + JSON.stringify(field))
- }
-}
-
-function canTypeIntoElement (element) {
- return element.is('input:not([type]), ' +
- 'input[type=text], ' +
- 'input[type=email], ' +
- 'input[type=password], ' +
- 'input[type=search], ' +
- 'input[type=tel], ' +
- 'input[type=url], ' +
- 'input[type=number],' +
- 'textarea')
-}
-
-function assertCanTypeIntoElement (element) {
- if (!canTypeIntoElement(element)) {
- throw new Error('Cannot type into ' + element.prop('tagName'))
- }
-}
diff --git a/lib/assertions.js b/lib/assertions.js
deleted file mode 100644
index 1203f54..0000000
--- a/lib/assertions.js
+++ /dev/null
@@ -1,88 +0,0 @@
-var Options = require('./options')
-var expectOneElement = require('./expectOneElement')
-var errorHandler = require('./errorHandler')
-
-module.exports = {
- is: function (css) {
- return this.addFinder(this.createElementTester({css: css}))
- },
-
- exists: function (options) {
- return this.shouldExist(options)
- },
-
- shouldExist: function (options) {
- return this.resolve(options)
- .catch(errorHandler(new Error()))
- },
-
- shouldFind: function (selector, findOptions, existOptions) {
- return this.find(selector, findOptions)
- .shouldExist(existOptions)
- },
-
- shouldNotExist: function (options) {
- return this.notResolve(options)
- .catch(errorHandler(new Error()))
- },
-
- has: function (options) {
- return this.shouldHave(options)
- },
-
- shouldHave: function (options) {
- var self = this
-
- var resolveOptions = Options.remove(options, ['timeout', 'interval'])
- resolveOptions.allowMultiple = true
-
- var additionalAssertions = Object.keys(options).filter(function (finderMethodName) {
- return options[finderMethodName] && options[finderMethodName].constructor === Object && typeof self[finderMethodName] === 'function'
- })
-
- var additionalOptions = Options.remove(options, additionalAssertions)
-
- var assertions = additionalAssertions.map(function (finderMethodName) {
- if (typeof self[finderMethodName] === 'function') {
- return self[finderMethodName]().shouldHave(additionalOptions[finderMethodName])
- }
- })
-
- assertions.push(this.addFinder(this.createElementTester(options)).shouldExist(resolveOptions))
- return Promise.all(assertions)
- .catch(errorHandler(new Error()))
- },
-
- shouldHaveElement: function (fn, options) {
- var $ = this.get('$')
- var self = this
-
- return this.addFinder({
- find: function (elements) {
- expectOneElement(self, elements)
- elements.toArray().forEach(function (element) {
- fn($(element))
- })
- return elements
- }
- }).shouldExist(options)
- },
-
- shouldHaveElements: function (fn, options) {
- options = Options.default(options, {allowMultiple: true, trace: false})
-
- return this.addFinder({
- find: function (elements) {
- fn(elements.toArray())
- return elements
- }
- }).shouldExist(options)
- },
-
- shouldNotHave: function (options) {
- var resolveOptions = Options.remove(options, ['timeout', 'interval'])
- resolveOptions.allowMultiple = true
-
- return this.addFinder(this.createElementTester(options)).shouldNotExist(resolveOptions)
- }
-}
diff --git a/lib/createTestDiv.js b/lib/createTestDiv.js
index 8b0ea21..6fdd1ce 100644
--- a/lib/createTestDiv.js
+++ b/lib/createTestDiv.js
@@ -1,4 +1,4 @@
-var div
+let div
module.exports = function () {
if (div && div.parentNode) {
diff --git a/lib/elementTester.js b/lib/elementTester.js
deleted file mode 100644
index 5c69418..0000000
--- a/lib/elementTester.js
+++ /dev/null
@@ -1,165 +0,0 @@
-var chai = require('chai')
-var expect = chai.expect
-var elementsToString = require('./elementsToString')
-require('array.prototype.find').shim()
-
-function assertElementProperties ($, elements, expected, getProperty, exact) {
- function assertion (actual, expected) {
- if (exact) {
- expect(actual, 'expected element to have exact text ' + JSON.stringify(expected) + ' but contained ' + JSON.stringify(actual)).to.equal(expected.toString())
- } else {
- expect(actual, 'expected element to contain ' + JSON.stringify(expected) + ' but contained ' + JSON.stringify(actual)).to.contain(expected)
- }
- }
-
- if (expected instanceof Array) {
- var actualTexts = elements.toArray().map(function (item) {
- return getProperty($(item))
- })
- var originalActualTexts = actualTexts.slice(0)
-
- expect(actualTexts.length, 'expected ' + JSON.stringify(actualTexts) + ' to respectively contain ' + JSON.stringify(expected)).to.eql(expected.length)
-
- var comparer = exact === true
- ? function (a, b) { return a === b }
- : function (a, b) { return a.indexOf(b) !== -1 }
-
- var found = []
- expected.forEach(function (value) {
- var foundValue = actualTexts.find(function (actual) {
- return comparer(actual, value)
- })
- if (foundValue !== undefined) {
- actualTexts.splice(actualTexts.indexOf(foundValue), 1)
- found.push(value)
- }
- })
-
- if (found.length !== expected.length) {
- expect(originalActualTexts).to.eql(expected)
- }
-
- try {
- expect(found).to.eql(expected)
- } catch (e) {
- if (found.slice().sort().toString() === expected.slice().sort().toString()) {
- e.message += '\nThe text was found but in a different order than specified - maybe you need some sorting?'
- }
- throw e
- }
- } else {
- var elementText = getProperty($(elements))
- assertion(elementText, expected)
- }
-}
-
-function getNormalisedText (el) {
- return (el.innerText() || '').replace(/ +/g, ' ').replace(/ *\r?\n */g, '\n')
-}
-
-function getValue (e, property) {
- var val = e.val()
- // Fails with missing value attribute in VDOM without 'string' test (returns SoftSetHook{value: ''})
- return (typeof val === 'string' && val) || ''
-}
-
-module.exports = {
- css: function ($el, message, css) {
- if (!$el.is(css)) {
- throw new Error(message || ('expected elements ' + elementsToString($el) + ' to have css ' + css))
- }
- },
- elements: function ($el, message, predicate) {
- if (!predicate($el.toArray())) {
- throw new Error(message || 'expected elements to pass predicate')
- }
- },
- text: function ($el, message, text) {
- assertElementProperties(this.get('$'), $el, text, function (e) { return getNormalisedText(e) }, text === '')
- },
- length: function ($el, message, length) {
- if ($el.length !== length) {
- throw new Error(message || ('expected ' + elementsToString($el) + ' to have ' + length + ' elements'))
- }
- },
- html: function ($el, message, html) {
- assertElementProperties(this.get('$'), $el, html, function (e) { return e.html() })
- },
- checked: function ($el, message, checked) {
- var $ = this.get('$')
- var elements = $el.toArray()
-
- if (checked instanceof Array) {
- var elementsChecked = elements.map(function (element) {
- return !!$(element).prop('checked')
- })
- expect(elementsChecked, 'expected ' + elementsToString($el) + ' to have checked states ' + JSON.stringify(checked)).to.eql(checked)
- } else {
- var elementsNotMatching = elements.filter(function (element) {
- return $(element).prop('checked') !== checked
- })
- expect(elementsNotMatching.length, 'expected ' + elementsToString($el) + ' to be ' + (checked ? 'checked' : 'unchecked')).to.equal(0)
- }
- },
- exactText: function ($el, message, exactText) {
- assertElementProperties(this.get('$'), $el, exactText, function (e) { return getNormalisedText(e) }, true)
- },
- value: function ($el, message, value) {
- assertElementProperties(this.get('$'), $el, value, getValue, value === '')
- },
- exactValue: function ($el, message, exactValue) {
- assertElementProperties(this.get('$'), $el, exactValue, getValue, true)
- },
- attributes: function ($el, message, attributes) {
- var $ = this.get('$')
- var elements = $el.toArray()
-
- if (attributes instanceof Array) {
- expect(elements.length).to.equal(attributes.length, 'expected the matched elements to be the same length as the expected attributes')
- elements.forEach(function (el, index) {
- var attributesForElement = attributes[index]
- Object.keys(attributesForElement).forEach(function (attributeKey) {
- expect($(el).attr(attributeKey)).to.equal(attributesForElement[attributeKey])
- })
- })
- } else {
- elements.forEach(function (el) {
- Object.keys(attributes).forEach(function (attributeKey) {
- expect($(el).attr(attributeKey)).to.equal(attributes[attributeKey])
- })
- })
- }
- },
-
- label: function ($el, message, label) {
- var $ = this.get('$')
- var links = $el.toArray().filter(function (el) {
- if ($(el).is('a')) {
- var anchor = $(el)
- var href = anchor.attr('href')
- return typeof href !== typeof undefined &&
- href !== false &&
- (
- anchor.text() === label ||
- anchor.attr('id') === label ||
- anchor.attr('title') === label ||
- anchor.find('img').toArray().filter(function (img) {
- return $(img).attr('alt') === label
- }).length > 0
- )
- }
- if ($(el).is('button, input[type=submit], input[type=button], input[type=reset]')) {
- var button = $(el)
- return button.attr('id') === label ||
- button.text() === label ||
- (typeof button.attr('value') === 'string' && button.attr('value').indexOf(label) > -1) ||
- (typeof button.attr('title') === 'string' && button.attr('title').indexOf(label) > -1) ||
- button.find('img').toArray().filter(function (img) {
- return typeof $(img).attr('alt') === 'string' && $(img).attr('alt').indexOf(label) > -1
- }).length > 0
- }
- })
-
- expect(links.length).to.equal(1, message)
- }
-}
diff --git a/lib/elementsToString.js b/lib/elementsToString.js
deleted file mode 100644
index d07bd46..0000000
--- a/lib/elementsToString.js
+++ /dev/null
@@ -1,5 +0,0 @@
-module.exports = function elementsToString (els) {
- return els.toArray().map(function (el) {
- if (el && el.outerHTML) { return el.outerHTML.replace(el.innerHTML, '') }
- }).join(', ')
-}
diff --git a/lib/errorHandler.js b/lib/errorHandler.js
deleted file mode 100644
index 917ed09..0000000
--- a/lib/errorHandler.js
+++ /dev/null
@@ -1,6 +0,0 @@
-module.exports = function (error) {
- return function (e) {
- e.stack = error.stack
- throw e
- }
-}
diff --git a/lib/expectOneElement.js b/lib/expectOneElement.js
deleted file mode 100644
index 0ca1f53..0000000
--- a/lib/expectOneElement.js
+++ /dev/null
@@ -1,8 +0,0 @@
-var chai = require('chai')
-var expect = chai.expect
-var elementsToString = require('./elementsToString')
-
-module.exports = function expectOneElement (scope, elements) {
- var msg = 'expected to find exactly one element: ' + scope.printFinders(scope._finders) + ', but found :' + elementsToString(elements)
- expect(elements.length, msg).to.equal(1)
-}
diff --git a/lib/fieldDefinitions.ts b/lib/fieldDefinitions.ts
new file mode 100644
index 0000000..f6ded82
--- /dev/null
+++ b/lib/fieldDefinitions.ts
@@ -0,0 +1,62 @@
+import { Query } from './Query'
+import { match } from './match'
+import { withPlaceholders } from './inputSelectors'
+import * as matchers from './matchers'
+import Dom from './Dom'
+
+export const button = {
+ name: 'button',
+ finder: (query: Query, name) => {
+ return query.findCss('button, input[type=button], input[type=submit], input[type=reset], a').containing(name)
+ }
+}
+
+export const label = inputSelector => ({
+ name: 'label',
+ finder: (query: Query, name) => {
+ return query.find('label').containing(name).find(inputSelector(query))
+ },
+})
+
+export const labelFor = {
+ name: 'label-for',
+ finder: (query: Query, name) => {
+ return query.find('label[for]').containing(name).map(label => {
+ const id = label.getAttribute('for')
+ return label.ownerDocument.getElementById(id)
+ }, 'for attribute').filter(Boolean)
+ },
+}
+
+export const ariaLabel = {
+ name: 'aria-label',
+ finder: (query: Query, name) => {
+ return query.find('[aria-label]').filter(element => {
+ const label = element.getAttribute('aria-label')
+ return match(label, name).isMatch
+ }, 'aria-label')
+ },
+}
+
+export const ariaLabelledBy = {
+ name: 'aria-labelledby',
+ finder: (query: Query, name) => {
+ return query.find('[aria-labelledby]').filter(element => {
+ const id = element.getAttribute('aria-labelledby')
+ const labelElement = element.ownerDocument.getElementById(id)
+ if (labelElement) {
+ const dom = new Dom()
+ return match(dom.elementInnerText(labelElement), name).isMatch
+ }
+ }, 'aria-label')
+ },
+}
+
+export const placeholder = {
+ name: 'placeholder',
+ finder: (query: Query, name) => {
+ return query.find(withPlaceholders).containing(matchers.elementAttributes({
+ placeholder: name,
+ }))
+ },
+}
diff --git a/lib/finders.js b/lib/finders.js
deleted file mode 100644
index 7a53a7f..0000000
--- a/lib/finders.js
+++ /dev/null
@@ -1,283 +0,0 @@
-var Options = require('./options')
-var expectOneElement = require('./expectOneElement')
-var window = require('global')
-
-module.exports = {
- elementFinder: function (css) {
- var $ = this.get('$')
- var self = this
- return {
- find: function (element) {
- var els = $(element).find(css)
- if (self.get('visibleOnly')) {
- els = els.filter(function (index) {
- var el = this[index] || this
- var ignoreVisibilityOfTags = ['OPTION']
- if (el && ignoreVisibilityOfTags.indexOf(el.tagName) !== -1) {
- el = el.parentNode
- }
- return $(el).is(':visible')
- })
- }
- if (els.length > 0) {
- return els
- }
- },
-
- toString: function () {
- return css
- }
- }
- },
-
- addFinder: function (finder) {
- var finders = this._finders && (this._finders.slice() || [])
- finders.push(finder)
- return this.clone({_finders: finders})
- },
-
- createElementTester: function (criteria) {
- var self = this
-
- if (typeof criteria === 'string') {
- criteria = { css: criteria }
- }
-
- if (typeof criteria === 'function') {
- criteria = { predicate: criteria }
- }
-
- return {
- find: function ($el) {
- var message = criteria.message
- Object.keys(criteria).forEach(function (key) {
- var value = criteria[key]
- var tester = self._elementTesters[key]
-
- if (value !== undefined && tester !== undefined) {
- tester.call(self, $el, message, value)
- }
- })
- return $el
- },
-
- toString: function () {
- return criteria.message || criteria.css || criteria.text
- }
- }
- },
-
- find: function (selector, options) {
- var message = JSON.stringify(options)
- var scope = this.addFinder(this.elementFinder(selector))
-
- if (options) {
- var tester = this.createElementTester(options)
-
- return scope.filter(function (element) {
- try {
- return tester.find(element)
- } catch (error) {
- return false
- }
- }, message)
- } else {
- return scope
- }
- },
-
- containing: function (selector, options) {
- var $ = this.get('$')
- var message = options && JSON.stringify(options)
- var findElements = this.elementFinder(selector)
- var finder
-
- if (options) {
- var tester = this.createElementTester(options)
-
- finder = {
- find: function (elements) {
- var found = findElements.find(elements)
- var tested = found.toArray().filter(function (element) {
- try {
- tester.find(element)
- return true
- } catch (error) {
- return false
- }
- })
-
- if (tested.length > 0) {
- return tested
- }
- },
-
- toString: function () {
- return selector + (message ? ' ' + message : '')
- }
- }
- } else {
- finder = findElements
- }
-
- return this.addFinder({
- find: function (elements) {
- var els = Array.prototype.filter.call(elements, function (el) {
- try {
- return finder.find(el)
- } catch (e) {
- return false
- }
- })
-
- if (els.length > 0) {
- return $(els)
- }
- },
-
- toString: function () {
- return ':has(' + finder.toString() + ')'
- }
- })
- },
-
- linkOrButton: function (label) {
- return this.find(
- 'a, button, input[type=submit], input[type=button], input[type=reset]',
- {label: label},
- '[linkOrButton: ' + label + ']'
- )
- },
-
- button: function (label) {
- return this.find(
- 'button, input[type=submit], input[type=button], input[type=reset]',
- {label: label},
- '[button: ' + label + ']'
- )
- },
-
- link: function (label) {
- return this.find('a', {label: label}, '[link: ' + label + ']')
- },
-
- elements: function (options) {
- options = Options.default(options, {allowMultiple: true})
- return this.resolve(options)
- },
-
- element: function (options) {
- var $ = this.get('$')
- return this.resolve(options).then(function (elements) {
- return $(elements[0])
- })
- },
-
- printFinders: function (finders) {
- return finders.map(function (f) { return f.toString() }).join(' ').replace(/\s+:/g, ':')
- },
-
- findElements: function (options) {
- var $ = this.get('$')
- var self = this
- var allowMultiple = Options.get(options, 'allowMultiple')
-
- function findWithFinder (el, finderIndex) {
- var finder = self._finders[finderIndex]
-
- if (finder) {
- var found = finder.find(el)
-
- if (!found) {
- throw new Error('expected to find: ' + self.printFinders(self._finders))
- }
-
- return findWithFinder(found, finderIndex + 1)
- } else {
- return el
- }
- };
-
- function selector () {
- var selector = self._selector
- if (
- selector &&
- typeof window.Element !== 'undefined' &&
- selector instanceof window.Element &&
- selector.tagName === 'IFRAME'
- ) {
- return selector.contentDocument
- } else if (
- selector &&
- typeof selector.prop === 'function' &&
- selector.prop('tagName') === 'IFRAME'
- ) {
- return selector[0].contentDocument
- } else {
- return selector || 'html'
- }
- }
-
- var elements = findWithFinder($(selector()), 0)
- if (!allowMultiple) {
- expectOneElement(self, elements)
- }
-
- return elements.toArray()
- },
-
- resolve: function (options) {
- var self = this
- var defaultTimeout = this.get('timeout') || 1000
- var retryOptions = Options.remove(options, ['timeout', 'interval'])
- retryOptions.timeout = retryOptions.timeout || defaultTimeout
-
- var result = this.retry(retryOptions, function () {
- return self.findElements(options)
- })
-
- return result
- },
-
- notResolve: function (options) {
- var self = this
-
- return this.retry(options, function () {
- var found = false
- try {
- self.findElements({allowMultiple: true})
- found = true
- } catch (e) {
- }
- if (found) {
- throw new Error("didn't expect to find element: " + self.printFinders(self._finders))
- }
- })
- },
- filter: function (filter, message) {
- var $ = this.get('$')
- return this.addFinder({
- find: function (elements) {
- var filteredElements = elements.toArray().filter(function (element) {
- return filter($(element))
- })
-
- if (filteredElements && filteredElements.length > 0) {
- return $(filteredElements)
- }
- },
-
- toString: function () {
- return message || '[filter]'
- }
- })
- },
-
- enabled: function () {
- return this.filter(function (element) {
- var tagName = element.prop('tagName')
- return !((tagName === 'BUTTON' || tagName === 'INPUT') && element.prop('disabled'))
- }, '[disabled=false]')
- }
-
-}
diff --git a/lib/inputSelectors.js b/lib/inputSelectors.js
new file mode 100644
index 0000000..70d7943
--- /dev/null
+++ b/lib/inputSelectors.js
@@ -0,0 +1,62 @@
+const selectors = (...sels) => {
+ return sels.join(',')
+}
+
+const input = 'input'
+const text = 'input[type=text],input:not([type])'
+const hidden = 'input[type=hidden]'
+const email = 'input[type=email]'
+const password = 'input[type=password]'
+const search = 'input[type=search]'
+const tel = 'input[type=tel]'
+const url = 'input[type=url]'
+const number = 'input[type=number]'
+const date = 'input[type=date]'
+const radio = 'input[type=radio]'
+const checkbox = 'input[type=checkbox]'
+const datetimeLocal = 'input[type=datetime-local]'
+const month = 'input[type=month]'
+const time = 'input[type=time]'
+const week = 'input[type=week]'
+const range = 'input[type=range]'
+const textarea = 'textarea'
+
+const canSetText = selectors(
+ text,
+ hidden,
+ email,
+ password,
+ search,
+ tel,
+ url,
+ number,
+ date,
+ datetimeLocal,
+ month,
+ time,
+ week,
+ range,
+ textarea
+)
+
+const canGetText = selectors(
+ input,
+ textarea,
+)
+
+const withPlaceholders = selectors(
+ input,
+ textarea,
+)
+
+const canBeClicked = selectors(
+ radio,
+ checkbox
+)
+
+module.exports = {
+ canSetText,
+ canGetText,
+ withPlaceholders,
+ canBeClicked
+}
diff --git a/lib/jquery.js b/lib/jquery.js
deleted file mode 100644
index deb61f9..0000000
--- a/lib/jquery.js
+++ /dev/null
@@ -1,62 +0,0 @@
-var jquery = require('jquery')
-function dispatchEvent (element, eventType) {
- var event
-
- if (eventType === 'click') {
- element.click()
- } else {
- if (document.createEvent) {
- event = document.createEvent('Event')
- event.initEvent(eventType, true, true)
- element.dispatchEvent(event)
- } else {
- event = document.createEventObject()
- event.eventType = eventType
- event.eventName = eventType
- element.fireEvent('on' + event.eventType, event)
- }
- }
-}
-
-if (jquery.fn) {
- jquery.fn.extend({
- innerText: function () {
- var el = this[0].body || this[0]
- return el.innerText || el.textContent
- },
-
- trigger: function (eventType) {
- for (var i = 0; i < this.length; i++) {
- var element = this[i]
- if (eventType === 'submit' && element.form) {
- if (!jquery.preventFormSubmit) {
- element.form.submit()
- }
- dispatchEvent(element.form, eventType)
- } else {
- dispatchEvent(element, eventType)
- }
- }
-
- return this
- },
-
- on: function (eventType, cb) {
- for (var i = 0; i < this.length; i++) {
- var element = this[i]
- element.addEventListener(eventType, cb, false)
- }
- return this
- },
-
- focus: function () {
- for (var i = 0; i < this.length; i++) {
- var element = this[i]
- element.focus()
- }
- return this
- }
- })
-}
-
-module.exports = jquery
diff --git a/lib/match.ts b/lib/match.ts
new file mode 100644
index 0000000..7c691c1
--- /dev/null
+++ b/lib/match.ts
@@ -0,0 +1,87 @@
+const object = require('lowscore/object')
+
+// TODO: get rid of `any`
+export function match (actual: any, expected: any): {isMatch: boolean, actual: any, expected: any} {
+ if (typeof expected === 'function') {
+ try {
+ expected(actual)
+ return {
+ isMatch: true,
+ actual,
+ expected: actual,
+ }
+ } catch (e) {
+ if (e.actual && e.expected) {
+ return {
+ isMatch: false,
+ actual: e.actual,
+ expected: e.expected,
+ }
+ } else {
+ throw e
+ }
+ }
+ } else if (expected instanceof RegExp) {
+ const isMatch = expected.test(actual)
+
+ return {
+ isMatch,
+ actual,
+ expected: isMatch ? actual : expected,
+ }
+ } else if (expected instanceof Array) {
+ if (actual.length !== expected.length) {
+ return {
+ isMatch: false,
+ actual,
+ expected,
+ }
+ }
+
+ const items = expected.map((expectedValue, index) => {
+ const actualValue = actual[index]
+
+ return match(actualValue, expectedValue)
+ })
+
+ return {
+ isMatch: items.every(i => i.isMatch),
+ actual: items.map(i => i.actual),
+ expected: items.map(i => i.expected),
+ }
+ } else if (expected.constructor === Object) {
+ let isMatch = true
+
+ if (actual == undefined) {
+ return {
+ isMatch: false,
+ actual,
+ expected,
+ }
+ }
+
+ const result = object(Object.keys(expected).map(key => {
+ const expectedValue = expected[key]
+ const actualValue = actual[key]
+
+ const m = match(actualValue, expectedValue)
+ if (!m.isMatch) {
+ isMatch = false
+ }
+
+ return [key, m.actual]
+ }))
+
+ return {
+ isMatch,
+ actual: result,
+ expected,
+ }
+ }
+
+ return {
+ isMatch: actual === expected,
+ actual,
+ expected,
+ }
+}
diff --git a/lib/matchers.ts b/lib/matchers.ts
new file mode 100644
index 0000000..fbd95de
--- /dev/null
+++ b/lib/matchers.ts
@@ -0,0 +1,17 @@
+import {match} from './match'
+import BrowserMonkeyAssertionError from './BrowserMonkeyAssertionError'
+import { Query } from './Query'
+
+export function elementAttributes (expected): (query: Query) => void {
+ return query => {
+ const element = query.elementResult()
+ const {isMatch, actual} = match(element, expected)
+
+ if (!isMatch) {
+ throw new BrowserMonkeyAssertionError('attributes did not match', {
+ actual,
+ expected,
+ })
+ }
+ }
+}
diff --git a/lib/mount.js b/lib/mount.js
deleted file mode 100644
index 6c18855..0000000
--- a/lib/mount.js
+++ /dev/null
@@ -1,47 +0,0 @@
-var window = require('global')
-var document = window.document
-
-function Mount (app, options) {
- this.app = app
- this.startApp = options.startApp.bind(this)
- this.stopApp = options.stopApp.bind(this)
-}
-
-Mount.prototype.start = function () {
- var monkey = this.startApp()
-
- monkey.set({
- app: this.app,
- mount: this
- })
-
- return monkey
-}
-
-Mount.prototype.stop = function () {
- this.stopApp()
-}
-
-Mount.runningInNode =
- (typeof process !== 'undefined') &&
- (typeof process.versions.node !== 'undefined') &&
- (typeof process.versions.electron === 'undefined')
-
-module.exports = Mount
-
-function addRefreshButton () {
- var refreshLink = document.createElement('a')
- refreshLink.href = window.location.href
- refreshLink.innerText = 'refresh'
- document.body.appendChild(refreshLink)
- document.body.appendChild(document.createElement('hr'))
-}
-
-if (Mount.runningInNode) {
- require('./stubBrowser')
-} else {
- if (/\/debug\.html$/.test(window.location.pathname)) {
- window.localStorage['debug'] = 'browser-monkey'
- addRefreshButton()
- }
-}
diff --git a/lib/normaliseText.js b/lib/normaliseText.js
new file mode 100644
index 0000000..4d5dc00
--- /dev/null
+++ b/lib/normaliseText.js
@@ -0,0 +1,3 @@
+module.exports = function normaliseText (text) {
+ return text.replace(/ +/g, ' ').replace(/ *\r?\n */g, '\n').trim()
+}
diff --git a/lib/options.js b/lib/options.js
deleted file mode 100644
index 4ad2251..0000000
--- a/lib/options.js
+++ /dev/null
@@ -1,58 +0,0 @@
-function Options (options) {
- this.options = options
- this.isOptionsObject = typeof options === 'object'
- this.validOptions = []
-}
-
-Options.get = function (options, propertyName) {
- if (typeof options === 'object') {
- var value = options[propertyName]
- return value
- }
-}
-
-Options.remove = function (options, propertyNames) {
- var newOptions = {}
-
- if (typeof options === 'object') {
- propertyNames.forEach(function (propertyName) {
- newOptions[propertyName] = options[propertyName]
- delete options[propertyName]
- })
- }
-
- return newOptions
-}
-
-Options.default = function (options, defaults) {
- var newOptions = typeof options === 'object' ? options : {}
-
- Object.keys(defaults).forEach(function (key) {
- if (!newOptions.hasOwnProperty(key)) {
- newOptions[key] = defaults[key]
- }
- })
-
- return newOptions
-}
-
-Options.prototype.option = function (name) {
- this.validOptions.push(name)
- if (this.isOptionsObject) {
- var value = this.options.hasOwnProperty(name) ? this.options[name] : undefined
- delete this.options[name]
- return value
- }
-}
-
-Options.prototype.validate = function () {
- if (this.isOptionsObject) {
- var keys = Object.keys(this.options)
-
- if (keys.length > 0) {
- throw new Error('properties ' + keys.join(', ') + ' not recognised, try ' + this.validOptions.join(', '))
- }
- }
-}
-
-module.exports = Options
diff --git a/lib/polyfills.js b/lib/polyfills.js
new file mode 100644
index 0000000..4728139
--- /dev/null
+++ b/lib/polyfills.js
@@ -0,0 +1,77 @@
+module.exports.MouseEvent = (function () {
+ try {
+ new window.MouseEvent('click')
+ return window.MouseEvent
+ } catch (e) {
+ // Need to polyfill - fall through
+ }
+
+ const MouseEventPolyfill = function (eventType, params) {
+ params = params || { bubbles: false, cancelable: false }
+ var mouseEvent = document.createEvent('MouseEvent')
+ mouseEvent.initMouseEvent(
+ eventType,
+ params.bubbles,
+ params.cancelable,
+ window,
+ 0,
+ params.screenX || 0,
+ params.screenY || 0,
+ params.clientX || 0,
+ params.clientY || 0,
+ false,
+ false,
+ false,
+ false,
+ 0,
+ null
+ )
+
+ return mouseEvent
+ }
+
+ MouseEventPolyfill.prototype = window.Event.prototype
+
+ return MouseEventPolyfill
+})()
+
+// based on https://github.com/lifaon74/events-polyfill/blob/5ccca4002aa07f16ed1c298145f20c06d3544a29/src/constructors/KeyboardEvent.js
+module.exports.KeyboardEvent = (function () {
+ try {
+ new window.KeyboardEvent('keyup')
+ return window.KeyboardEvent
+ } catch (e) {
+ // Need to polyfill - fall through
+ }
+
+ const KeyboardEventPolyfill = function (eventType, params) {
+ params = params || { bubbles: true, cancelable: false }
+
+ const modKeys = [
+ params.ctrlKey ? 'Control' : '',
+ params.shiftKey ? 'Shift' : '',
+ params.altKey ? 'Alt' : '',
+ params.altGrKey ? 'AltGr' : '',
+ params.metaKey ? 'Meta' : ''
+ ].filter(Boolean).join(' ')
+
+ const keyEvent = document.createEvent('KeyboardEvent')
+ keyEvent.initKeyboardEvent(
+ eventType,
+ !!params.bubbles,
+ !!params.cancelable,
+ window,
+ '',
+ params.key,
+ 0,
+ modKeys,
+ !!params.repeat
+ )
+
+ return keyEvent
+ }
+
+ KeyboardEventPolyfill.prototype = window.Event.prototype
+
+ return KeyboardEventPolyfill
+})()
diff --git a/lib/promise.js b/lib/promise.js
deleted file mode 100644
index 04ef42b..0000000
--- a/lib/promise.js
+++ /dev/null
@@ -1,16 +0,0 @@
-var FinishedPromise = require('finished-promise')
-var trytryagain = require('trytryagain')
-
-function immediately (retryOptions, fn) {
- return FinishedPromise.resolve().then(fn)
-}
-
-module.exports = {
- promise: function () {
- return this.get('immediate') ? FinishedPromise : Promise
- },
-
- retry: function (options, fn) {
- return this.get('immediate') ? immediately(options, fn) : trytryagain(options, fn)
- }
-}
diff --git a/lib/reloadButton.js b/lib/reloadButton.js
index 3d7d8fd..59ac931 100644
--- a/lib/reloadButton.js
+++ b/lib/reloadButton.js
@@ -1,8 +1,8 @@
-var hobostyle = require('hobostyle')
-var extend = require('lowscore/extend')
+const hobostyle = require('hobostyle')
+const extend = require('lowscore/extend')
module.exports = function reloadButton (_options) {
- var options = extend({
+ const options = extend({
style: true,
class: 'browser-monkey-reload'
}, _options || {})
@@ -26,12 +26,12 @@ module.exports = function reloadButton (_options) {
'}')
}
- var existingLinks = document.querySelectorAll('a.' + options['class'])
+ const existingLinks = document.querySelectorAll('a.' + options['class'])
;[].forEach.call(existingLinks, function (link) {
document.body.removeChild(link)
})
- var link = document.createElement('a')
+ const link = document.createElement('a')
link.className = options['class']
link.href = window.location.href
link.innerText = '⟳ reload'
diff --git a/lib/retry.ts b/lib/retry.ts
new file mode 100644
index 0000000..a19eb70
--- /dev/null
+++ b/lib/retry.ts
@@ -0,0 +1,17 @@
+export default async function retry(fn: () => T, {interval = 10, timeout = 1000} = {}): Promise {
+ const startTime = Date.now()
+ let firstTry = true
+
+ for (;;) {
+ try {
+ return fn()
+ } catch (e) {
+ if (Date.now() > startTime + timeout) {
+ throw e
+ } else {
+ await new Promise(resolve => setTimeout(resolve, firstTry ? 0 : interval))
+ firstTry = false
+ }
+ }
+ }
+}
diff --git a/lib/selector.js b/lib/selector.js
deleted file mode 100644
index 62334ab..0000000
--- a/lib/selector.js
+++ /dev/null
@@ -1,78 +0,0 @@
-var elementTester = require('./elementTester')
-var global = require('global')
-
-function Selector (selector, finders, options) {
- this._selector = selector
- this._finders = finders || []
- this._options = options || { visibleOnly: true, $: require('./jquery'), document: global.document, immediate: false, timeout: 1000 }
- this._handlers = []
- this._elementTesters = elementTester
-}
-
-Selector.prototype.set = function (options) {
- var self = this
- Object.keys(options).forEach(function (key) {
- self._options[key] = options[key]
- })
- return this
-}
-
-Selector.prototype.get = function (key) {
- return this._options[key]
-}
-
-Selector.prototype.clone = function (extension) {
- var clone = new this.constructor()
- var self = this
-
- Object.keys(self).forEach(function (key) {
- clone[key] = self[key]
- })
-
- Object.keys(extension).forEach(function (key) {
- clone[key] = extension[key]
- })
-
- return clone
-}
-
-Selector.prototype.on = function (handler) {
- this._handlers.push(handler)
- return this
-}
-
-Selector.prototype.handleEvent = function () {
- var args = arguments
-
- this._handlers.forEach(function (handler) {
- handler.apply(undefined, args)
- })
-}
-
-Selector.prototype.scope = function (scope) {
- if (scope instanceof Selector) {
- return this.clone(scope)
- } else {
- return this.clone({_selector: scope})
- }
-}
-
-Selector.prototype.extend = function (methods) {
- return this.component(methods)
-}
-
-Selector.prototype.component = function (methods) {
- function Component () {
- Selector.apply(this, arguments)
- }
-
- Component.prototype = new this.constructor()
- Object.keys(methods).forEach(function (method) {
- Component.prototype[method] = methods[method]
- })
- Component.prototype.constructor = Component
-
- return new Component().scope(this)
-}
-
-module.exports = Selector
diff --git a/lib/sendkeys.js b/lib/sendkeys.js
deleted file mode 100644
index fa41405..0000000
--- a/lib/sendkeys.js
+++ /dev/null
@@ -1,37 +0,0 @@
-function dispatchEvent (el, type, char) {
- el.trigger(type, {charCode: char})
-}
-
-function sendkey (el, char) {
- dispatchEvent(el, 'keydown', char)
- dispatchEvent(el, 'keypress', char)
- dispatchEvent(el, 'input')
- dispatchEvent(el, 'keyup', char)
-}
-
-function sendkeys (el, text) {
- var originalValue = el.val()
-
- if (text.length === 0) {
- el.val('')
- sendkey(el, '')
- } else {
- for (var n = 0; n < text.length; ++n) {
- var char = text[n]
- var value = text.substring(0, n + 1)
- el.val(value)
- sendkey(el, char)
- }
- }
-
- if (originalValue !== text) {
- dispatchEvent(el, 'change')
- }
-};
-
-sendkeys.html = function (el, html) {
- el.innerHTML = html
- dispatchEvent(el, 'input')
-}
-
-module.exports = sendkeys
diff --git a/lib/stubBrowser.js b/lib/stubBrowser.js
index 706322b..c381e00 100644
--- a/lib/stubBrowser.js
+++ b/lib/stubBrowser.js
@@ -1,5 +1,3 @@
-var window = require('global')
-
var registeredEvents = {}
var pushState, replaceState
diff --git a/lib/toExecutedTransform.ts b/lib/toExecutedTransform.ts
new file mode 100644
index 0000000..f060ee3
--- /dev/null
+++ b/lib/toExecutedTransform.ts
@@ -0,0 +1,10 @@
+import { ExecutedTransform } from './ExecutedTransform'
+import { ExecutedSimpleTransform } from './ExecutedSimpleTransform'
+
+export default function toExecutedTransform (value: any): ExecutedTransform {
+ if (value instanceof ExecutedTransform) {
+ return value
+ } else {
+ return new ExecutedSimpleTransform(value)
+ }
+}
diff --git a/package.json b/package.json
index c0b5749..033c955 100644
--- a/package.json
+++ b/package.json
@@ -1,66 +1,95 @@
{
"name": "browser-monkey",
- "version": "2.7.2",
+ "version": "3.0.0-beta.13",
"description": "reliable dom testing",
- "main": "index.js",
+ "main": "dist/index.js",
+ "files": [
+ "dist/*",
+ "lib/*",
+ "test/*",
+ "index.ts",
+ "*Mount.js*"
+ ],
"scripts": {
- "test": "standard && mocha && karma start --single-run",
- "update-readme-example-links": "node ./docs/update-readme-example-links.js"
+ "test": "yarn test-electron-mocha && yarn test-mocha && yarn test-karma && yarn test-jest && eslint .",
+ "test-karma": "karma start --single-run",
+ "test-electron-mocha": "yarn electron-mocha test/*Spec.{js,ts} test/mount/*Spec.{js,ts}",
+ "test-electron-mocha-watch": "rg --files | entr yarn test-electron-mocha",
+ "electron-mocha": "electron-mocha --disable-site-isolation-trials --full-trace --color --main electron/foreignIframe.js -r ts-node/register --renderer",
+ "test-mocha": "yarn mocha test/*Spec.{js,ts}",
+ "test-jest": "yarn jest",
+ "watch-types": "tsc -w --noEmit",
+ "mocha": "TEST_JSDOM=true mocha -r test/register.js -r test/jsdom.js",
+ "prepare": "tsc -p tsconfig.build.json && ./export-mounts.js && node -r ./HyperdomMount.js -r ./ReactMount.js -e '1'",
+ "docs": "yarn build-docs && yarn docsify serve --no-open --port 3003 ./docs-dist",
+ "watch-docs": "ls docs/*.md | entr -r yarn docs",
+ "build-docs": "rm -rf ./docs-dist && cp -r ./docs ./docs-dist && codesandbox-example-links --output-dir=./docs-dist ./docs/*.md",
+ "publish-docs": "yarn build-docs && gh-pages -t -d docs-dist"
},
"author": "Tim Macfarlane ",
"license": "MIT",
"devDependencies": {
- "2vdom": "^0.2.0",
- "angular": "^1.6.0",
- "babel-cli": "^6.24.0",
- "babel-preset-latest": "^6.24.0",
- "babel-preset-react": "^6.16.0",
- "babel-register": "^6.24.0",
- "babelify": "^7.3.0",
- "browserify": "13.3.0",
- "browserify-optional": "^1.0.1",
- "codesandbox": "^1.3.6",
- "detect-node": "^2.0.3",
- "express": "^4.15.2",
- "httpism": "^2.6.2",
- "hyperdom": "^0.11",
- "is-node": "^1.0.2",
- "karma": "3.1.4",
- "karma-browserify": "^6.0.0",
+ "@babel/core": "^7.14.3",
+ "@babel/polyfill": "^7.12.1",
+ "@babel/preset-env": "^7.14.4",
+ "@babel/preset-react": "^7.13.13",
+ "@babel/register": "^7.13.16",
+ "@types/mocha": "^8.2.2",
+ "@typescript-eslint/eslint-plugin": "^4.25.0",
+ "@typescript-eslint/parser": "^4.25.0",
+ "assert": "latest",
+ "babel-loader": "^8.2.2",
+ "chai": "4.3.4",
+ "codesandbox-example-links": "^1.1.0",
+ "docsify-cli": "^4.4.3",
+ "electron": "13.0.1",
+ "electron-mocha": "^10.0.0",
+ "eslint": "^7.27.0",
+ "eslint-config-standard": "^16.0.3",
+ "eslint-plugin-import": "^2.23.4",
+ "eslint-plugin-mocha": "^9.0.0",
+ "eslint-plugin-node": "^11.1.0",
+ "eslint-plugin-promise": "^5.1.0",
+ "eslint-plugin-standard": "^5.0.0",
+ "express": "^4.16.4",
+ "gh-pages": "^3.2.0",
+ "hyperdom": "^2.1.0",
+ "jest": "^27.0.3",
+ "karma": "6.3.2",
"karma-browserstack-launcher": "https://github.com/karma-runner/karma-browserstack-launcher",
- "karma-chrome-launcher": "2.2.0",
+ "karma-chrome-launcher": "3.1.0",
"karma-cli": "2.0.0",
"karma-env-preprocessor": "0.1.1",
- "karma-firefox-launcher": "^1.1.0",
+ "karma-firefox-launcher": "^2.1.0",
"karma-ievms": "0.1.0",
- "karma-mocha": "1.3.0",
+ "karma-mocha": "2.0.1",
"karma-mocha-reporter": "^2.2.5",
- "lie": "3.1.1",
- "mocha": "5.0.5",
- "must": "^0.13.4",
- "react": "^15.4.1",
- "react-dom": "^15.4.1",
- "server-destroy": "^1.0.1",
- "standard": "^10.0.0",
- "vdom-query": "https://github.com/featurist/vdom-query",
- "virtual-dom": "^2.1.1",
- "watchify": "^3.7.0"
+ "karma-sourcemap-loader": "^0.3.8",
+ "karma-webpack": "https://github.com/rahcusa/karma-webpack",
+ "mocha": "8.4.0",
+ "process": "latest",
+ "react": "^17.0.2",
+ "react-dom": "^17.0.2",
+ "ts-jest": "^27.0.2",
+ "ts-loader": "^9.2.2",
+ "ts-node": "^10.0.0",
+ "webpack": "^5.38.1",
+ "webpack-cli": "^4.7.0"
},
"dependencies": {
- "array.prototype.find": "^2.0.3",
- "chai": "3.5.0",
- "debug": "^2.6.3",
- "detect-browser": "^1.6.2",
- "finished-promise": "0.0.2",
- "global": "^4.3.1",
+ "debug": "^4.3.1",
"hobostyle": "1.0.0",
- "jquery": "^3.2.1",
+ "iframe-resizer": "^4.3.2",
+ "keycode": "^2.2.0",
"lowscore": "^1.12.1",
- "trytryagain": "1.2.0"
+ "object-inspect": "1.10.3",
+ "pluralize": "^8.0.0",
+ "typescript": "^4.3.2"
},
"standard": {
"env": [
- "mocha"
+ "mocha",
+ "browser"
],
"ignore": [
"docs/codesandbox/**/*"
@@ -82,5 +111,13 @@
"bugs": {
"url": "https://github.com/featurist/browser-monkey/issues"
},
- "homepage": "https://github.com/featurist/browser-monkey"
+ "homepage": "https://github.com/featurist/browser-monkey",
+ "jest": {
+ "testMatch": [
+ "/test/jest/**.test.ts"
+ ],
+ "transform": {
+ "^.+\\.(ts|tsx)$": "ts-jest"
+ }
+ }
}
diff --git a/react.js b/react.js
index 2108091..4aa1c22 100644
--- a/react.js
+++ b/react.js
@@ -1,18 +1,18 @@
var Mount = require('./lib/mount')
var React = require('react')
var ReactDOM = require('react-dom')
-var createMonkey = require('./create')
+var {Query} = require('./lib/Query')
var createTestDiv = require('./lib/createTestDiv')
-module.exports = function (app) {
- return new Mount(app, {
+module.exports = function (App, props) {
+ return new Mount(App, {
stopApp: function () {
},
startApp: function () {
var div = createTestDiv()
- ReactDOM.render(React.createElement(this.app.constructor, null), div)
+ ReactDOM.render(React.createElement(App, props), div)
- return createMonkey(document.body)
+ return new Query(document.body)
}
}).start()
}
diff --git a/test/.eslintrc.js b/test/.eslintrc.js
new file mode 100644
index 0000000..8f4efbd
--- /dev/null
+++ b/test/.eslintrc.js
@@ -0,0 +1,5 @@
+module.exports = {
+ env: {
+ mocha: true
+ }
+}
diff --git a/test/actionsSpec.js b/test/actionsSpec.js
deleted file mode 100644
index f466473..0000000
--- a/test/actionsSpec.js
+++ /dev/null
@@ -1,519 +0,0 @@
-var demand = require('must')
-var domTest = require('./domTest')
-var retry = require('trytryagain')
-
-describe('actions', function () {
- describe('clicking', function () {
- domTest('stack trace', function (browser, dom) {
- return browser.find('div')
- .click()
- .assertStackTrace(__filename)
- }, {
- mochaOnly: true
- })
-
- domTest('should eventually click an element', function (browser, dom, $) {
- var promise = browser.find('.element').click()
- var clicked = false
-
- dom.eventuallyInsert(
- $('
').on('click', function () {
- clicked = true
- })
- )
-
- return promise.then(function () {
- demand(clicked).to.equal(true)
- })
- })
-
- domTest('sends mousedown mouseup and click events', function (browser, dom) {
- var events = []
-
- dom.insert('
').on('mousedown', function () {
- events.push('mousedown')
- }).on('mouseup', function () {
- events.push('mouseup')
- }).on('click', function () {
- events.push('click')
- })
-
- return browser.find('.element').click().then(function () {
- demand(events).to.eql(['mousedown', 'mouseup', 'click'])
- })
- })
-
- domTest('mousedown mouseup and click events bubble up to parent', function (browser, dom) {
- var events = []
-
- dom.insert('').on('mousedown', function () {
- events.push('mousedown')
- }).on('mouseup', function () {
- events.push('mouseup')
- }).on('click', function () {
- events.push('click')
- })
-
- return browser.find('.inner-element').click().then(function () {
- demand(events).to.eql(['mousedown', 'mouseup', 'click'])
- })
- }, {vdom: false})
-
- domTest('waits until checkbox is enabled before clicking', function (browser, dom) {
- var promise = browser.find('input[type=checkbox]').click()
- var clicked
- var buttonState = 'disabled'
-
- var button = dom.insert(' ')
- button.on('click', function () {
- clicked = buttonState
- })
-
- setTimeout(function () {
- button.prop('disabled', false)
- buttonState = 'enabled'
- }, 10)
-
- return promise.then(function () {
- demand(clicked).to.equal('enabled')
- })
- })
-
- domTest('waits until button is enabled before clicking', function (browser, dom) {
- var promise = browser.find('button', {text: 'a button'}).click()
- var clicked
- var buttonState = 'disabled'
-
- var button = dom.insert('a button ')
- button.on('click', function () {
- clicked = buttonState
- })
-
- setTimeout(function () {
- button.prop('disabled', false)
- buttonState = 'enabled'
- }, 10)
-
- return promise.then(function () {
- demand(clicked).to.equal('enabled')
- })
- })
- })
-
- describe('select', function () {
- domTest('stack trace', function (browser, dom) {
- return browser.find('div')
- .select({text: 'Text'})
- .assertStackTrace(__filename)
- }, {
- mochaOnly: true
- })
-
- describe('text', function () {
- domTest('respects timeout option', function (browser, dom, $) {
- var promise = browser.find('.element').select({text: 'Second', timeout: 3})
-
- dom.eventuallyInsert(
- $('First Second ')
- , 6)
-
- return demand(promise).reject.with.error()
- })
-
- domTest('respects timeout option, when passed separately from text', function (browser, dom, $) {
- var promise = browser.find('.element').select('Second', {timeout: 3})
-
- dom.eventuallyInsert(
- $('First Second ')
- , 6)
-
- return demand(promise).reject.with.error('expected to find: .element select option {"timeout":3,"text":"Second"}')
- })
-
- domTest('eventually selects an option element using the text', function (browser, dom, $) {
- var promise = browser.find('.element').select({text: 'Second'})
- var selectedItem
-
- dom.eventuallyInsert(
- $('First Second ').on('change', function () {
- selectedItem = $(this).find('option[selected]').text()
- })
- )
-
- return promise.then(function () {
- demand(selectedItem).to.equal('Second')
- })
- })
-
- domTest('eventually selects an option element using the text, when text is passed as a string', function (browser, dom, $) {
- var promise = browser.find('.element').select('Second')
- var selectedItem
-
- dom.eventuallyInsert(
- $('First Second ').on('change', function () {
- selectedItem = $(this).find('option[selected]').text()
- })
- )
-
- return promise.then(function () {
- demand(selectedItem).to.equal('Second')
- })
- })
-
- domTest('should eventually select an option element using a partial match', function (browser, dom, $) {
- var promise = browser.find('.element').select({text: 'Seco'})
- var selectedItem
-
- dom.eventuallyInsert(
- $('First Second ').on('change', function (e) {
- selectedItem = $(this).find('option[selected]').text()
- })
- )
-
- return promise.then(function () {
- demand(selectedItem).to.equal('Second')
- })
- })
-
- domTest('selects the first match if multiple available', function (browser, dom, $) {
- var selectedItem
-
- var select = dom.insert('Item Item ').on('change', function (e) {
- selectedItem = select.val()
- })
-
- return browser.find('select').select({text: 'Item'}).then(function () {
- demand(selectedItem).to.equal('1')
- })
- })
-
- domTest('selects an option that eventually appears', function (browser, dom, $) {
- var promise = browser.find('.element').select({text: 'Second'})
- var selectedItem
-
- var select = dom.insert(' ').on('change', function (e) {
- selectedItem = $(this).find('option[selected]').text()
- })
-
- setTimeout(function () {
- select.append('First Second ')
- }, 20)
-
- return promise.then(function () {
- demand(selectedItem).to.equal('Second')
- })
- })
-
- domTest('errors when the specified option does not exist', function (browser, dom) {
- var promise = browser.find('.element').select({text: 'Does not exist'})
-
- dom.eventuallyInsert('First Second ')
-
- return demand(promise).reject.with.error()
- })
-
- domTest('errors when the input is not a select', function (browser, dom) {
- var promise = browser.find('.element').select({text: 'Whatevs'})
- dom.eventuallyInsert('
')
- return demand(promise).reject.with.error(/to have css select/)
- })
-
- domTest('selects an option using text that is falsy', function (browser, dom, $) {
- var promise = browser.find('.element').select({text: 0})
- var selectedItem
-
- dom.insert('0 1 ').on('change', function (e) {
- selectedItem = $(this).find('option[selected]').text()
- })
-
- return promise.then(function () {
- demand(selectedItem).to.equal('0')
- })
- })
- })
-
- describe('exactText', function () {
- domTest('should select an option using exact text that would otherwise match multiple options', function (browser, dom, $) {
- var promise = browser.find('.element').select({exactText: 'Mr'})
- var selectedItem
-
- dom.insert('Mr Mrs ').on('change', function (e) {
- selectedItem = $(this).find('option[selected]').text()
- })
-
- return promise.then(function () {
- demand(selectedItem).to.equal('Mr')
- })
- })
-
- domTest('should select an option using exact text that is falsy', function (browser, dom, $) {
- var promise = browser.find('.element').select({exactText: 0})
- var selectedItem
-
- dom.insert('0 1 ').on('change', function (e) {
- selectedItem = $(this).find('option[selected]').text()
- })
-
- return promise.then(function () {
- demand(selectedItem).to.equal('0')
- })
- })
- })
- })
-
- describe('submit', function () {
- domTest('stack trace', function (browser, dom) {
- return browser.find('div')
- .submit()
- .assertStackTrace(__filename)
- }, {
- mochaOnly: true
- })
-
- domTest('should submit the form', function (browser, dom) {
- var submitted = false
- var promise = browser.find('input').submit()
-
- dom.insert('').on('submit', function (ev) {
- submitted = true
- })
-
- return promise.then(function () {
- demand(submitted).to.equal(true)
- })
- })
-
- domTest('should submit the form when submit button is clicked', function (browser, dom) {
- var submitted = false
- var promise = browser.find('input').click()
-
- dom.insert('').on('submit', function (ev) {
- ev.preventDefault()
- submitted = true
- })
-
- return promise.then(function () {
- demand(submitted).to.equal(true)
- })
- }, {vdom: false})
- })
-
- describe('typeIn', function () {
- domTest('stack trace', function (browser, dom) {
- return browser.find('input')
- .typeIn('hello')
- .assertStackTrace(__filename)
- }, {
- mochaOnly: true
- })
-
- var allowedToTypeInto = [
- ' ',
- ' ',
- ' ',
- ' ',
- ' ',
- ' ',
- ' ',
- ' ',
- '