diff --git a/README.md b/README.md index 549ae8d7..534d4ad5 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ const chromeless = new Chromeless({ - [`goto(url: string)`](docs/api.md#api-goto) - [`setUserAgent(useragent: string)`](docs/api.md#api-setUserAgent) - [`click(selector: string)`](docs/api.md#api-click) +- [`clickArrayElements(selector: string, arrayNumber: number)`](docs/api.md#api-clickarrayelements) - [`wait(timeout: number)`](docs/api.md#api-wait-timeout) - [`wait(selector: string)`](docs/api.md#api-wait-selector) - [`wait(fn: (...args: any[]) => boolean, ...args: any[])`] - Not implemented yet @@ -154,11 +155,13 @@ const chromeless = new Chromeless({ - [`mouseup(selector: string)`](docs/api.md#api-mouseup) - [`scrollTo(x: number, y: number)`](docs/api.md#api-scrollto) - [`scrollToElement(selector: string)`](docs/api.md#api-scrolltoelement) +- [`scrollToElementArrayElements(selector: string, arrayNumber: number)`](docs/api.md#api-scrolltoelement-array-elements) - [`setHtml(html: string)`](docs/api.md#api-sethtml) - [`setViewport(options: DeviceMetrics)`](docs/api.md#api-setviewport) - [`evaluate(fn: (...args: any[]) => void, ...args: any[])`](docs/api.md#api-evaluate) - [`inputValue(selector: string)`](docs/api.md#api-inputvalue) - [`exists(selector: string)`](docs/api.md#api-exists) +- [`existsArrayElement(selector: string, arrayNumber: number)`](docs/api.md#api-existsarrayelement) - [`screenshot()`](docs/api.md#api-screenshot) - [`pdf(options?: PdfOptions)`](docs/api.md#api-pdf) - [`html()`](docs/api.md#api-html) diff --git a/deployAWSChromeless.sh b/deployAWSChromeless.sh new file mode 100644 index 00000000..1cf7f696 --- /dev/null +++ b/deployAWSChromeless.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# We get the aws_access_key_id, aws_secret_access_key and region from the files where aws save the information +aws_access_key_id_string=`cat $HOME/.aws/credentials | grep aws_access_key_id` +aws_secret_access_key_string=`cat $HOME/.aws/credentials | grep aws_secret_access_key` +region=`cat $HOME/.aws/config | grep region` + +IFS=' = ' read -ra KEYID <<< "$aws_access_key_id_string" + +IFS=' = ' read -ra AKEY <<< "$aws_secret_access_key_string" + +IFS=' = ' read -ra REGION <<< "$region" + +# We run the command in a dockerfile to prevent package version errors. It's only necessary to have docker installed +docker build --build-arg "IOT_ENDPOINT=$(aws iot describe-endpoint --region=${REGION[1]} --output text)" --build-arg "REGION=${REGION[1]}" -t deploy-chromeless-git-version -f dockerfiles/Dockerfile.deploy . +docker run -e "AWS_SECRET_ACCESS_KEY=${AKEY[1]}" -e "AWS_ACCESS_KEY_ID=${KEYID[1]}" -ti deploy-chromeless-git-version \ No newline at end of file diff --git a/dockerfiles/Dockerfile.deploy b/dockerfiles/Dockerfile.deploy new file mode 100644 index 00000000..4ca81755 --- /dev/null +++ b/dockerfiles/Dockerfile.deploy @@ -0,0 +1,30 @@ +FROM node:latest + +USER root +# replace shell with bash so we can source files +RUN rm /bin/sh && ln -s /bin/bash /bin/sh + +# update the repository sources list +# and install dependencies +RUN apt-get update \ + && apt-get install -y curl \ + && apt-get install -y git-all \ + && apt-get install git \ + && apt-get install -y python python-dev python-pip python-virtualenv \ + && apt-get -y autoclean +RUN pip install awscli==1.11.131 + +# Get chromeless code and install all the containing packages + +WORKDIR /opt/app +# Get github code +RUN git clone https://github.com/graphcool/chromeless.git +WORKDIR /opt/app/chromeless/serverless +RUN npm install + +# We replace our aws account default region and iot endpoint with the one in github code +ARG IOT_ENDPOINT +ARG REGION +RUN sed -i 's/eu-west-1/'"$REGION"'/g' serverless.yml +RUN sed -i 's/${env:AWS_IOT_HOST}/'"$IOT_ENDPOINT"'/g' serverless.yml +ENTRYPOINT ["npm", "run", "deploy"] \ No newline at end of file diff --git a/docs/api.md b/docs/api.md index 40d609a0..330a4264 100644 --- a/docs/api.md +++ b/docs/api.md @@ -9,8 +9,9 @@ Chromeless provides TypeScript typings. - [`goto(url: string)`](#api-goto) - [`setUserAgent(useragent: string)`](#api-setuseragent) - [`click(selector: string)`](#api-click) +- [`clickArrayElements(selector: string, arrayNumber: number)`](#api-clickarrayelements) - [`wait(timeout: number)`](#api-wait-timeout) -- [`wait(selector: string)`](#api-wait-selector) +- [`wait(selector: string, timeout?: number)`](#api-wait-selector) - [`wait(fn: (...args: any[]) => boolean, ...args: any[])`] - Not implemented yet - [`clearCache()`](docs/api.md#api-clearcache) - [`focus(selector: string)`](#api-focus) @@ -23,11 +24,13 @@ Chromeless provides TypeScript typings. - [`mouseup(selector: string)`](#api-mouseup) - [`scrollTo(x: number, y: number)`](#api-scrollto) - [`scrollToElement(selector: string)`](#api-scrolltoelement) +- [`scrollToElementArrayElements(selector: string, arrayNumber: number)`](#api-scrolltoelementarrayelements) - [`setHtml(html: string)`](#api-sethtml) - [`setViewport(options: DeviceMetrics)`](#api-setviewport) - [`evaluate(fn: (...args: any[]) => void, ...args: any[])`](#api-evaluate) - [`inputValue(selector: string)`](#api-inputvalue) - [`exists(selector: string)`](#api-exists) +- [`existsArrayElement(selector: string, arrayNumber: number)`](#api-existsarrayelement) - [`screenshot()`](#api-screenshot) - [`pdf(options?: PdfOptions)`](#api-pdf) - [`html()`](#api-html) @@ -108,6 +111,24 @@ await chromeless.click('#button') --------------------------------------- + + +### click(selector: string): Chromeless + +Click on something in the DOM. + +__Arguments__ +- `selector` - DOM selector for element to click +- `arrayNumber` - Get the element in a certain position to click + +__Example__ + +```js +await chromeless.clickArrayElements('button', 19) +``` + +--------------------------------------- + ### wait(timeout: number): Chromeless @@ -127,17 +148,19 @@ await chromeless.wait(1000) -### wait(selector: string): Chromeless +### wait(selector: string, timeout?: number): Chromeless Wait until something appears. Useful for waiting for things to render. __Arguments__ - `selector` - DOM selector to wait for +- `timeout` - How long to wait for element to appear (default is value of waitTimeout option) __Example__ ```js await chromeless.wait('div#loaded') +await chromeless.wait('div#loaded', 1000) ``` --------------------------------------- @@ -299,13 +322,13 @@ await chromeless.mouseup('#placeholder') Scroll to somewhere in the document. __Arguments__ -- `x` - Offset from top of the document -- `y` - Offset from the left of the document +- `x` - Offset from the left of the document +- `y` - Offset from the top of the document __Example__ ```js -await chromeless.scrollTo(500, 0) +await chromeless.scrollTo(0, 500) ``` --------------------------------------- @@ -324,6 +347,23 @@ __Example__ ```js await chromeless.scrollToElement('.button') ``` +--------------------------------------- + + + +### scrollToElementArrayElements(selector: string, arrayNumber: number): Chromeless + +Scroll to location of element. Behavior is simiar to `` — target element will be at the top of viewport + +__Arguments__ +- `selector` - DOM selector for element to scroll to +- `arrayNumber` - Position of the element in array selector + +__Example__ + + ```js +await chromeless.scrollToElementArrayElements('button', 18) + ``` --------------------------------------- diff --git a/src/api.ts b/src/api.ts index 3390c848..fb64686d 100644 --- a/src/api.ts +++ b/src/api.ts @@ -89,8 +89,14 @@ export default class Chromeless implements Promise { return this } + clickArrayElements(selector: string, arrayNumber: number): Chromeless { + this.queue.enqueue({ type: 'clickArrayElements', selector, arrayNumber }) + + return this + } + wait(timeout: number): Chromeless - wait(selector: string): Chromeless + wait(selector: string, timeout?: number): Chromeless wait(fn: (...args: any[]) => boolean, ...args: any[]): Chromeless wait(firstArg, ...args: any[]): Chromeless { switch (typeof firstArg) { @@ -99,7 +105,7 @@ export default class Chromeless implements Promise { break } case 'string': { - this.queue.enqueue({ type: 'wait', selector: firstArg }) + this.queue.enqueue({ type: 'wait', selector: firstArg, timeout: args[0] }) break } case 'function': { @@ -174,6 +180,12 @@ export default class Chromeless implements Promise { return this } + scrollToElementArrayElements(selector: string, arrayNumber: number): Chromeless { + this.queue.enqueue({ type: 'scrollToElementArrayElements', selector, arrayNumber }) + + return this + } + setViewport(options: DeviceMetrics): Chromeless { this.queue.enqueue({ type: 'setViewport', options }) @@ -217,6 +229,16 @@ export default class Chromeless implements Promise { return new Chromeless({}, this) } + existsArrayElement(selector: string, arrayNumber: number): Chromeless { + this.lastReturnPromise = this.queue.process({ + type: 'returnArrayElementExists', + selector, + arrayNumber + }) + + return new Chromeless({}, this) + } + screenshot(): Chromeless { this.lastReturnPromise = this.queue.process({ type: 'returnScreenshot', diff --git a/src/chrome/local-runtime.ts b/src/chrome/local-runtime.ts index eda6727c..501869aa 100644 --- a/src/chrome/local-runtime.ts +++ b/src/chrome/local-runtime.ts @@ -13,9 +13,12 @@ import * as os from 'os' import * as path from 'path' import { nodeExists, + nodeArrayElementsExists, wait, waitForNode, + waitForNodeArrayElements, click, + clickArrayElements, evaluate, screenshot, html, @@ -24,6 +27,7 @@ import { getValue, scrollTo, scrollToElement, + scrollToElementArrayElements, setHtml, press, setViewport, @@ -57,10 +61,10 @@ export default class LocalRuntime { case 'setViewport': return setViewport(this.client, command.options) case 'wait': { - if (command.timeout) { + if (command.selector) { + return this.waitSelector(command.selector, command.timeout) + } else if (command.timeout) { return this.waitTimeout(command.timeout) - } else if (command.selector) { - return this.waitSelector(command.selector) } else { throw new Error('waitFn not yet implemented') } @@ -71,10 +75,14 @@ export default class LocalRuntime { return this.setUserAgent(command.useragent) case 'click': return this.click(command.selector) + case 'clickArrayElements': + return this.clickArrayElements(command.selector, command.arrayNumber) case 'returnCode': return this.returnCode(command.fn, ...command.args) case 'returnExists': return this.returnExists(command.selector) + case 'returnArrayElementExists': + return this.returnArrayElementExists(command.selector, command.arrayNumber) case 'returnScreenshot': return this.returnScreenshot() case 'returnHtml': @@ -91,6 +99,8 @@ export default class LocalRuntime { return this.scrollTo(command.x, command.y) case 'scrollToElement': return this.scrollToElement(command.selector) + case 'scrollToElementArrayElements': + return this.scrollToElementArrayElements(command.selector, command.arrayNumber) case 'deleteCookies': return this.deleteCookies(command.name, command.url) case 'clearCookies': @@ -149,9 +159,12 @@ export default class LocalRuntime { await wait(timeout) } - private async waitSelector(selector: string): Promise { - this.log(`Waiting for ${selector}`) - await waitForNode(this.client, selector, this.chromelessOptions.waitTimeout) + private async waitSelector( + selector: string, + waitTimeout: number = this.chromelessOptions.waitTimeout + ): Promise { + this.log(`Waiting for ${selector} ${waitTimeout}`) + await waitForNode(this.client, selector, waitTimeout) this.log(`Waited for ${selector}`) } @@ -178,6 +191,30 @@ export default class LocalRuntime { this.log(`Clicked on ${selector}`) } + private async clickArrayElements(selector: string, arrayNumber: number): Promise { + if (this.chromelessOptions.implicitWait) { + this.log(`clickArrayElements(): Waiting for ${selector}`) + await waitForNodeArrayElements( + this.client, + selector, + arrayNumber, + this.chromelessOptions.waitTimeout, + ) + } + + const exists = await nodeArrayElementsExists(this.client, selector, arrayNumber) + if (!exists) { + throw new Error(`clickArrayElements(): node for selector ${selector} doesn't exist`) + } + + const { scale } = this.chromelessOptions.viewport + if (this.chromelessOptions.scrollBeforeClick) { + await scrollToElementArrayElements(this.client, selector, arrayNumber) + } + await clickArrayElements(this.client, selector, arrayNumber, scale) + this.log(`Clicked on ${selector}`) + } + private async returnCode(fn: string, ...args: any[]): Promise { return (await evaluate(this.client, fn, ...args)) as T } @@ -198,6 +235,19 @@ export default class LocalRuntime { return scrollToElement(this.client, selector) } + private async scrollToElementArrayElements(selector: string, arrayNumber: number): Promise { + if (this.chromelessOptions.implicitWait) { + this.log(`scrollToElementArrayElements(): Waiting for ${selector}`) + await waitForNodeArrayElements( + this.client, + selector, + arrayNumber, + this.chromelessOptions.waitTimeout, + ) + } + return scrollToElementArrayElements(this.client, selector, arrayNumber) + } + private async mousedown(selector: string): Promise { if (this.chromelessOptions.implicitWait) { this.log(`mousedown(): Waiting for ${selector}`) @@ -347,6 +397,10 @@ export default class LocalRuntime { return await nodeExists(this.client, selector) } + async returnArrayElementExists(selector: string, arrayNumber: number): Promise { + return await nodeArrayElementsExists(this.client, selector, arrayNumber) + } + async returnInputValue(selector: string): Promise { const exists = await nodeExists(this.client, selector) if (!exists) { @@ -364,7 +418,8 @@ export default class LocalRuntime { process.env['CHROMELESS_S3_BUCKET_NAME'] && process.env['CHROMELESS_S3_BUCKET_URL'] ) { - const s3Path = `${cuid()}.png` + const prefix = process.env['CHROMELESS_S3_OBJECT_KEY_PREFIX'] || '' + const s3Path = `${prefix}${cuid()}.png` const s3 = new AWS.S3() await s3 .putObject({ diff --git a/src/types.ts b/src/types.ts index 16698bf5..93d4e849 100644 --- a/src/types.ts +++ b/src/types.ts @@ -84,6 +84,11 @@ export type Command = type: 'click' selector: string } + | { + type: 'clickArrayElements' + selector: string, + arrayNumber: number + } | { type: 'returnCode' fn: string @@ -97,6 +102,11 @@ export type Command = type: 'returnExists' selector: string } + | { + type: 'returnArrayElementExists' + selector: string, + arrayNumber: number + } | { type: 'returnValue' selector: string @@ -120,6 +130,11 @@ export type Command = type: 'scrollToElement' selector: string } + | { + type: 'scrollToElementArrayElements' + selector: string, + arrayNumber: number + } | { type: 'setHtml' html: string diff --git a/src/util.ts b/src/util.ts index 5c92faf7..55489fc8 100644 --- a/src/util.ts +++ b/src/util.ts @@ -90,6 +90,48 @@ export async function waitForNode( } } +export async function waitForNodeArrayElements( + client: Client, + selector: string, + arrayNumber: number, + waitTimeout: number, +): Promise { + debugger + const { Runtime } = client + const getNode = (selector, arrayNumber) => { + return document.querySelectorAll(selector)[arrayNumber] + } + + const result = await Runtime.evaluate({ + expression: `(${getNode})(\`${selector}\`)`, + }) + + if (result.result.value === null) { + const start = new Date().getTime() + return new Promise((resolve, reject) => { + const interval = setInterval(async () => { + if (new Date().getTime() - start > waitTimeout) { + clearInterval(interval) + reject( + new Error(`wait("${selector}") timed out after ${waitTimeout}ms`), + ) + } + + const result = await Runtime.evaluate({ + expression: `(${getNode})(\`${selector}\`)`, + }) + + if (result.result.value !== null) { + clearInterval(interval) + resolve() + } + }, 500) + }) + } else { + return + } +} + export async function wait(timeout: number): Promise { return new Promise((resolve, reject) => setTimeout(resolve, timeout)) } @@ -108,7 +150,24 @@ export async function nodeExists( const result = await Runtime.evaluate({ expression, }) + return result.result.value +} +export async function nodeArrayElementsExists( + client: Client, + selector: string, + arrayNumber: number +): Promise { + const { Runtime } = client + const exists = (selector,arrayNumber) => { + return !!(document.querySelectorAll(selector)[arrayNumber]) + } + + const expression = `(${exists})(\`${selector}\`,${arrayNumber})` + console.log('expression', expression) + const result = await Runtime.evaluate({ + expression, + }) return result.result.value } @@ -142,6 +201,41 @@ export async function getClientRect(client, selector): Promise { return JSON.parse(result.result.value) as ClientRect } +export async function getClientRectArrayElements(client, selector, arrayNumber): Promise { + const { Runtime } = client + + const code = (selector, arrayNumber) => { + const elements = document.querySelectorAll(selector) + if (elements.length < arrayNumber) { + return undefined + } + + const element = elements[arrayNumber] + + if (!element) { + return undefined + } + + const rect = element.getBoundingClientRect() + return JSON.stringify({ + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + height: rect.height, + width: rect.width, + }) + } + const expression = `(${code})(\`${selector}\`,${arrayNumber})` + const result = await Runtime.evaluate({ expression }) + + if (!result.result.value) { + throw new Error(`No element found for selector: ${selector}`) + } + + return JSON.parse(result.result.value) as ClientRect +} + export async function click(client: Client, selector: string, scale: number) { const clientRect = await getClientRect(client, selector) const { Input } = client @@ -163,6 +257,27 @@ export async function click(client: Client, selector: string, scale: number) { }) } +export async function clickArrayElements(client: Client, selector: string, arrayNumber: number, scale: number) { + const clientRect = await getClientRectArrayElements(client, selector, arrayNumber) + const { Input } = client + + const options = { + x: Math.round((clientRect.left + clientRect.width / 2) * scale), + y: Math.round((clientRect.top + clientRect.height / 2) * scale), + button: 'left', + clickCount: 1, + } + + await Input.dispatchMouseEvent({ + ...options, + type: 'mousePressed', + }) + await Input.dispatchMouseEvent({ + ...options, + type: 'mouseReleased', + }) +} + export async function focus(client: Client, selector: string): Promise { const { DOM } = client const dom = await DOM.getDocument() @@ -181,7 +296,6 @@ export async function evaluate( const { Runtime } = client const jsonArgs = JSON.stringify(args) const argStr = jsonArgs.substr(1, jsonArgs.length - 2) - const expression = ` (() => { const expressionResult = (${fn})(${argStr}); @@ -309,6 +423,16 @@ export async function scrollToElement( return scrollTo(client, clientRect.left, clientRect.top) } +export async function scrollToElementArrayElements( + client: Client, + selector: string, + arrayNumber: number, +): Promise { + const clientRect = await getClientRectArrayElements(client, selector, arrayNumber) + + return scrollTo(client, clientRect.left, clientRect.top) +} + export async function setHtml(client: Client, html: string): Promise { const { Page } = client