├── logo.sketch
├── .gitignore
├── test
├── extension
│ └── manifest.json
├── Marionette.ts
├── api
│ ├── JSHandle.ts
│ ├── Foxr.ts
│ ├── Browser.ts
│ ├── ElementHandle.ts
│ └── Page.ts
├── json-protocol.ts
└── helpers
│ ├── firefox.ts
│ └── wait-for-marionette.ts
├── types
└── signal-exit.d.ts
├── .editorconfig
├── src
├── Error.ts
├── index.ts
├── api
│ ├── JSHandle.ts
│ ├── types.ts
│ ├── Foxr.ts
│ ├── Browser.ts
│ ├── ElementHandle.ts
│ └── Page.ts
├── keys.ts
├── json-protocol.ts
├── utils.ts
└── Marionette.ts
├── codecov.yml
├── logo.svg
├── tsconfig.json
├── .travis.yml
├── license.md
├── package.json
└── readme.md
/logo.sketch:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/deepsweet/foxr/HEAD/logo.sketch
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | build/
3 | coverage/
4 | tmp/
5 | .npmrc
6 | *.log
7 | .DS_Store
8 |
--------------------------------------------------------------------------------
/test/extension/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 |
3 | "description": "test",
4 | "manifest_version": 2,
5 | "name": "test",
6 | "version": "0.0.1"
7 |
8 | }
9 |
--------------------------------------------------------------------------------
/types/signal-exit.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'signal-exit' {
2 | function onExit(callback: (code: number, signal: string) => void): void
3 |
4 | export = onExit
5 | }
6 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
--------------------------------------------------------------------------------
/src/Error.ts:
--------------------------------------------------------------------------------
1 | export default class FoxrError extends Error {
2 | constructor (message: string) {
3 | super(message)
4 | Error.captureStackTrace(this, FoxrError)
5 |
6 | this.name = 'FoxrError'
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/test/Marionette.ts:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import Marionette from '../src/Marionette'
3 |
4 | test('Marionette', (t) => {
5 | t.ok(
6 | Marionette,
7 | 'test me'
8 | )
9 | t.end()
10 | })
11 |
--------------------------------------------------------------------------------
/test/api/JSHandle.ts:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import JSHandle from '../../src/api/JSHandle'
3 |
4 | test('JSHandle', (t) => {
5 | t.ok(
6 | JSHandle,
7 | 'test me'
8 | )
9 | t.end()
10 | })
11 |
--------------------------------------------------------------------------------
/codecov.yml:
--------------------------------------------------------------------------------
1 | coverage:
2 | parsers:
3 | javascript:
4 | enable_partials: yes
5 | status:
6 | project: no
7 | patch: no
8 | comment:
9 | layout: files
10 | behavior: once
11 | require_changes: yes
12 |
--------------------------------------------------------------------------------
/logo.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "strict": true,
4 | "allowSyntheticDefaultImports": true,
5 | "noEmit": true,
6 | "pretty": true,
7 | "lib": ["esnext", "dom"],
8 | "moduleResolution": "node",
9 | "typeRoots": [ "types/", "node_modules/@types/" ]
10 | },
11 | "exclude": ["node_modules/", "coverage/", "build/", "tmp/"]
12 | }
13 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import Foxr from './api/Foxr'
2 | import Browser from './api/Browser'
3 | import Page from './api/Page'
4 | import JSHandle from './api/JSHandle'
5 | import ElementHandle from './api/ElementHandle'
6 |
7 | export default new Foxr()
8 |
9 | export type TBrowser = Browser
10 | export type TPage = Page
11 | export type TJSHandle = JSHandle
12 | export type TElementHandle = ElementHandle
13 |
--------------------------------------------------------------------------------
/test/json-protocol.ts:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import { createParseStream, parse, stringify } from '../src/json-protocol'
3 |
4 | test('json-protocol: createParseStream', (t) => {
5 | t.ok(
6 | createParseStream,
7 | 'test me'
8 | )
9 | t.end()
10 | })
11 |
12 | test('json-protocol: parse', (t) => {
13 | t.ok(
14 | parse,
15 | 'test me'
16 | )
17 | t.end()
18 | })
19 |
20 | test('json-protocol: stringify', (t) => {
21 | t.ok(
22 | stringify,
23 | 'test me'
24 | )
25 | t.end()
26 | })
27 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | # https://docs.travis-ci.com/user/customizing-the-build/
2 | sudo: required
3 | services:
4 | - docker
5 | git:
6 | depth: 1
7 | language: node_js
8 | node_js:
9 | - 8
10 | - 10
11 | env:
12 | global:
13 | - PATH=$HOME/.yarn/bin:$PATH
14 | before_install:
15 | - curl -o- -L https://yarnpkg.com/install.sh | bash
16 | cache:
17 | yarn: true
18 | directories:
19 | - node_modules
20 | branches:
21 | only:
22 | - master
23 | matrix:
24 | fast_finish: true
25 | before_script: docker pull deepsweet/firefox-headless-remote:62
26 | script: yarn start ci
27 |
--------------------------------------------------------------------------------
/src/api/JSHandle.ts:
--------------------------------------------------------------------------------
1 | import { EventEmitter } from 'events'
2 | import Page from './Page'
3 | import { TJSHandleId, TSend } from './types'
4 | import { getElementId } from '../utils'
5 |
6 | const cache = new Map()
7 |
8 | class JSHandle extends EventEmitter {
9 | public _handleId: TJSHandleId | null
10 | public _elementId: string
11 |
12 | constructor (params: { page: Page, id: TJSHandleId, send: TSend }) {
13 | super()
14 |
15 | this._handleId = params.id
16 | this._elementId = getElementId(params.id)
17 |
18 | if (cache.has(this._elementId)) {
19 | return cache.get(this._elementId) as JSHandle
20 | }
21 |
22 | cache.set(this._elementId, this)
23 |
24 | params.page.on('close', () => {
25 | cache.clear()
26 | })
27 | }
28 | }
29 |
30 | export default JSHandle
31 |
--------------------------------------------------------------------------------
/test/helpers/firefox.ts:
--------------------------------------------------------------------------------
1 | import { resolve } from 'path'
2 | import execa from 'execa'
3 | import { Test } from 'blue-tape'
4 | import waitForMarionette from './wait-for-marionette'
5 |
6 | const localExtPath = resolve('test', 'extension')
7 | export const containerExtPath = '/home/firefox/extension'
8 |
9 | export const runFirefox = () => execa('docker',
10 | `run -id --rm --shm-size 2g -v ${localExtPath}:${containerExtPath} -p 2828:2828 --name foxr-firefox deepsweet/firefox-headless-remote:68`.split(' ')
11 | )
12 |
13 | export const stopFirefox = () => execa('docker',
14 | 'stop --time 5 foxr-firefox'.split(' '),
15 | { reject: false }
16 | )
17 |
18 | export const testWithFirefox = (test: (t: Test) => Promise) => async (t: Test) => {
19 | try {
20 | // await stopFirefox()
21 | await runFirefox()
22 | await waitForMarionette(2828)
23 | await test(t)
24 | } finally {
25 | await stopFirefox()
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/test/helpers/wait-for-marionette.ts:
--------------------------------------------------------------------------------
1 | import { Socket } from 'net'
2 |
3 | const checkForMarionette = (port: number, host: string) => {
4 | return new Promise((resolve, reject) => {
5 | const socket = new Socket()
6 | let isAvailablePort = false
7 |
8 | socket
9 | .setTimeout(200)
10 | .once('connect', () => {
11 | socket.once('data', () => {
12 | isAvailablePort = true
13 |
14 | socket.destroy()
15 | })
16 | })
17 | .once('timeout', () => {
18 | socket.destroy()
19 | })
20 | .once('error', () => {
21 | resolve(false)
22 | })
23 | .once('close', () => {
24 | if (isAvailablePort) {
25 | resolve(true)
26 | } else {
27 | resolve(false)
28 | }
29 | })
30 | .connect(port, host)
31 | })
32 | }
33 |
34 | const sleep = (timeout: number) => new Promise((resolve) => setTimeout(resolve, timeout))
35 |
36 | export default async (port: number, host = 'localhost') => {
37 | while (!(await checkForMarionette(port, host))) {
38 | await sleep(100)
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/license.md:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 |
3 | Copyright (c) 2018–present Kir Belevich
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/api/types.ts:
--------------------------------------------------------------------------------
1 | import { TJsonValue } from 'typeon'
2 | import JSHandle from './JSHandle'
3 | import ElementHandle from './ElementHandle'
4 | import Marionette from '../Marionette'
5 |
6 | export type TStringifiableFunction = (...args: Array) => TJsonValue | Promise | void
7 |
8 | export type TJSHandleId = {
9 | [key: string]: string
10 | }
11 |
12 | export type TEvaluateArg = TJsonValue | JSHandle | ElementHandle
13 |
14 | export type TEvaluateResult = {
15 | error: string | null,
16 | value: TJsonValue | void
17 | }
18 |
19 | export type TEvaluateHandleResult = {
20 | error: string | null,
21 | value: TJSHandleId | null
22 | }
23 |
24 | export type TEvaluateResults = {
25 | error: string | null,
26 | value: TJsonValue[] | void[]
27 | }
28 |
29 | export type TMouseButton = 'left' | 'middle' | 'right'
30 |
31 | export type TClickOptions = {
32 | button?: TMouseButton,
33 | clickCount?: number
34 | }
35 |
36 | export type TSend = Marionette['send']
37 |
38 | export type TInstallAddonResult = {
39 | value: string | null
40 | }
41 |
42 | export type TPref = string | number | boolean
43 | export type TGetPrefResult = TPref | undefined | null
44 |
45 | export enum Context {
46 | CHROME = 'chrome',
47 | CONTENT = 'content'
48 | }
49 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "foxr",
3 | "version": "0.10.1",
4 | "description": "Node.js API to control Firefox",
5 | "keywords": "firefox, headless, remote, marionette, puppeteer, selenium",
6 | "homepage": "https://github.com/deepsweet/foxr",
7 | "repository": "deepsweet/foxr",
8 | "author": "Kir Belevich (https://twitter.com/deepsweet)",
9 | "license": "MIT",
10 | "files": [
11 | "build/"
12 | ],
13 | "main": "build/index.js",
14 | "types": "build/index.d.ts",
15 | "engines": {
16 | "node": ">=8.6.0"
17 | },
18 | "dependencies": {
19 | "@babel/runtime": "^7.4.5",
20 | "execa": "^1.0.0",
21 | "signal-exit": "^3.0.2",
22 | "typeon": "^0.3.0"
23 | },
24 | "devDependencies": {
25 | "@deepsweet/eslint-config-node-ts": "^0.2.1",
26 | "@deepsweet/start-preset-node-ts-lib": "^0.8.2",
27 | "@types/blue-tape": "^0.1.32",
28 | "@types/execa": "^0.9.0",
29 | "@types/node": "^11.12.2",
30 | "blue-tape": "^1.0.0",
31 | "mocku": "^0.5.0",
32 | "spyfn": "^0.2.0",
33 | "typescript": "^3.4.1"
34 | },
35 | "eslintConfig": {
36 | "extends": "@deepsweet/eslint-config-node-ts",
37 | "rules": {
38 | "import/named": "off"
39 | }
40 | },
41 | "eslintIgnore": [
42 | "build/",
43 | "coverage/",
44 | "node_modules/"
45 | ]
46 | }
47 |
--------------------------------------------------------------------------------
/test/api/Foxr.ts:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import Foxr from '../../src/api/Foxr'
3 | import Browser from '../../src/api/Browser'
4 | import { testWithFirefox } from '../helpers/firefox'
5 |
6 | test('Foxr: `connect()`', testWithFirefox(async (t) => {
7 | const foxr = new Foxr()
8 | const browser = await foxr.connect({
9 | defaultViewport: {
10 | width: 801,
11 | height: 601
12 | }
13 | })
14 |
15 | t.true(
16 | browser instanceof Browser,
17 | 'should return `browser`'
18 | )
19 |
20 | const pages = await browser.pages()
21 |
22 | t.deepEqual(
23 | await pages[0].viewport(),
24 | {
25 | width: 801,
26 | height: 601
27 | },
28 | 'should change default viewport'
29 | )
30 | }))
31 |
32 | test.skip('Foxr: `launch()`', async (t) => {
33 | // TODO: download Firefox for real
34 | const firefoxPath = '/Applications/Firefox.app/Contents/MacOS/firefox'
35 |
36 | const foxr = new Foxr()
37 |
38 | const browser1 = await foxr.launch({
39 | executablePath: firefoxPath
40 | })
41 |
42 | t.true(
43 | browser1 instanceof Browser,
44 | 'should return `browser`'
45 | )
46 |
47 | await browser1.close()
48 |
49 | try {
50 | // @ts-ignore
51 | await foxr.launch()
52 | t.fail('should not get here')
53 | } catch (err) {
54 | t.equal(
55 | err.message,
56 | '`executablePath` option is required, Foxr doesn\'t download Firefox automatically',
57 | 'should throw if there is no `executablePath` option'
58 | )
59 | }
60 | })
61 |
--------------------------------------------------------------------------------
/src/keys.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | Null: '\ue000',
3 | Cancel: '\ue001',
4 | Abort: '\ue001',
5 | Help: '\ue002',
6 | Backspace: '\ue003',
7 | Tab: '\ue004',
8 | Clear: '\ue005',
9 | Return: '\ue006',
10 | Enter: '\ue007',
11 | NumpadEnter: '\ue007',
12 | Shift: '\ue008',
13 | ShiftLeft: '\ue008',
14 | ShiftRight: '\ue008',
15 | ControlLeft: '\ue009',
16 | ControlRight: '\ue009',
17 | Alt: '\ue00a',
18 | AltLeft: '\ue00a',
19 | AltRight: '\ue00a',
20 | Pause: '\ue00b',
21 | Escape: '\ue00c',
22 | Space: '\ue00d',
23 | PageUp: '\ue00e',
24 | PageDown: '\ue00f',
25 | End: '\ue010',
26 | Home: '\ue011',
27 | ArrowLeft: '\ue012',
28 | ArrowUp: '\ue013',
29 | ArrowRight: '\ue014',
30 | ArrowDown: '\ue015',
31 | Insert: '\ue016',
32 | Delete: '\ue017',
33 | Semicolon: '\ue018',
34 | Equal: '\ue019',
35 | NumpadEqual: '\ue019',
36 | Numpad0: '\ue01a',
37 | Numpad1: '\ue01b',
38 | Numpad2: '\ue01c',
39 | Numpad3: '\ue01d',
40 | Numpad4: '\ue01e',
41 | Numpad5: '\ue01f',
42 | Numpad6: '\ue020',
43 | Numpad7: '\ue021',
44 | Numpad8: '\ue022',
45 | Numpad9: '\ue023',
46 | NumpadMultiply: '\ue024',
47 | NumpadAdd: '\ue025',
48 | // TODO: ???
49 | // SEPARATOR: '\ue026',
50 | NumpadSubstract: '\ue027',
51 | NumpadDecimal: '\ue028',
52 | NumpadDivide: '\ue029',
53 | F1: '\ue031',
54 | F2: '\ue032',
55 | F3: '\ue033',
56 | F4: '\ue034',
57 | F5: '\ue035',
58 | F6: '\ue036',
59 | F7: '\ue037',
60 | F8: '\ue038',
61 | F9: '\ue039',
62 | F10: '\ue03a',
63 | F11: '\ue03b',
64 | F12: '\ue03c',
65 | Meta: '\ue03d',
66 | MetaLeft: '\ue03d',
67 | // MetaRight has different code
68 | Command: '\ue03d'
69 | }
70 |
--------------------------------------------------------------------------------
/src/json-protocol.ts:
--------------------------------------------------------------------------------
1 | import { Transform } from 'stream'
2 | import { jsonParse, jsonStringify, TJsonValue } from 'typeon'
3 |
4 | const SEPARATOR = ':'
5 | const SEPARATOR_CODE = SEPARATOR.charCodeAt(0)
6 |
7 | export const createParseStream = () => {
8 | let remainingLength = 0
9 | let currentBuffer = Buffer.alloc(0)
10 |
11 | return new Transform({
12 | readableObjectMode: true,
13 | transform (chunk, encoding, callback) {
14 | let pos = 0
15 |
16 | if (remainingLength === 0) {
17 | pos = chunk.indexOf(SEPARATOR_CODE)
18 | remainingLength = parseInt(chunk.slice(0, pos).toString(), 10)
19 | pos += 1
20 | }
21 |
22 | const remainingData = chunk.slice(pos, pos + remainingLength)
23 | currentBuffer = Buffer.concat([currentBuffer, remainingData])
24 | remainingLength -= remainingData.length
25 | pos += remainingData.length
26 |
27 | if (remainingLength === 0) {
28 | this.push(jsonParse(currentBuffer.toString()))
29 |
30 | currentBuffer = Buffer.alloc(0)
31 | }
32 |
33 | if (pos < chunk.length) {
34 | return this._transform(chunk.slice(pos), encoding, callback)
35 | }
36 |
37 | callback()
38 | }
39 | })
40 | }
41 |
42 | export const parse = (buf: Buffer) => {
43 | const stream = createParseStream()
44 | let result: TJsonValue = {}
45 |
46 | stream.once('data', (data) => {
47 | result = data
48 | })
49 |
50 | stream.write(buf)
51 | stream.end()
52 |
53 | return result
54 | }
55 |
56 | export const stringify = (data: TJsonValue) => {
57 | const str = jsonStringify(data)
58 | const length = Buffer.byteLength(str)
59 |
60 | return `${length}${SEPARATOR}${str}`
61 | }
62 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { writeFile } from 'fs'
2 | import { promisify } from 'util'
3 | import { Socket } from 'net'
4 | import { TEvaluateArg, TJSHandleId } from './api/types'
5 | import JSHandle from './api/JSHandle'
6 |
7 | export const pWriteFile = promisify(writeFile)
8 |
9 | export const MOUSE_BUTTON = {
10 | left: 0,
11 | middle: 1,
12 | right: 2
13 | }
14 |
15 | export const mapEvaluateArgs = (args: TEvaluateArg[]) => args.map((arg) => {
16 | if (arg instanceof JSHandle) {
17 | return arg._handleId
18 | }
19 |
20 | return arg
21 | })
22 |
23 | export const getElementId = (JSHandleId: TJSHandleId) => Object.values(JSHandleId)[0]
24 |
25 | // ESLint fails to parse this written as arrow function
26 | export function hasKey (obj: T, key: any): key is keyof T {
27 | return key in obj
28 | }
29 |
30 | const CHECK_PORT_TIMEOUT = 200
31 |
32 | export const checkPort = (host: string, port: number): Promise => {
33 | return new Promise((resolve) => {
34 | const socket = new Socket()
35 | let isAvailablePort = false
36 |
37 | socket
38 | .setTimeout(CHECK_PORT_TIMEOUT)
39 | .once('connect', () => {
40 | isAvailablePort = true
41 |
42 | socket.destroy()
43 | })
44 | .once('timeout', () => {
45 | socket.destroy()
46 | })
47 | .once('error', () => {
48 | resolve(false)
49 | })
50 | .once('close', () => {
51 | if (isAvailablePort) {
52 | resolve(true)
53 | } else {
54 | resolve(false)
55 | }
56 | })
57 | .connect(
58 | port,
59 | host
60 | )
61 | })
62 | }
63 |
64 | export const sleep = (timeout: number): Promise => new Promise((resolve) => setTimeout(resolve, timeout))
65 |
66 | export const waitForPort = async (host: string, port: number): Promise => {
67 | while (!(await checkPort(host, port))) {
68 | await sleep(CHECK_PORT_TIMEOUT)
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/api/Foxr.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-use-before-define */
2 | import execa from 'execa'
3 | // @ts-ignore
4 | import onExit from 'signal-exit'
5 | import Marionette from '../Marionette'
6 | import Browser from './Browser'
7 | import { waitForPort } from '../utils'
8 | import { TSend } from './types'
9 |
10 | const DEFAULT_HOST = 'localhost'
11 | const DEFAULT_PORT = 2828
12 |
13 | export type TConnectOptions = {
14 | host?: string,
15 | port?: number,
16 | defaultViewport?: {
17 | width?: number,
18 | height?: number
19 | }
20 | }
21 |
22 | export type TLaunchOptions = {
23 | args?: string[],
24 | dumpio?: boolean,
25 | executablePath: string,
26 | headless?: boolean
27 | } & TConnectOptions
28 |
29 | class Foxr {
30 | async _setViewport (send: TSend, { width, height }: { width: number, height: number }): Promise {
31 | type TResult = {
32 | value: {
33 | widthDelta: number,
34 | heightDelta: number
35 | }
36 | }
37 |
38 | const { value: result } = await send('WebDriver:ExecuteScript', {
39 | script: `return {
40 | widthDelta: window.outerWidth - window.innerWidth,
41 | heightDelta: window.outerHeight - window.innerHeight
42 | }`
43 | }) as TResult
44 |
45 | await send('WebDriver:SetWindowRect', {
46 | width: width + result.widthDelta,
47 | height: height + result.heightDelta
48 | })
49 | }
50 |
51 | async connect (options?: TConnectOptions): Promise {
52 | const { host, port, defaultViewport: { width, height } } = {
53 | host: DEFAULT_HOST,
54 | port: DEFAULT_PORT,
55 | ...options,
56 | defaultViewport: {
57 | width: 800,
58 | height: 600,
59 | ...options && options.defaultViewport
60 | }
61 | }
62 |
63 | const marionette = new Marionette()
64 |
65 | await marionette.connect(host, port)
66 | await marionette.send('WebDriver:NewSession', { capabilities: {} })
67 | await this._setViewport(marionette.send, { width, height })
68 |
69 | const browser = new Browser({ send: marionette.send })
70 |
71 | marionette.once('close', async ({ isManuallyClosed }) => {
72 | if (!isManuallyClosed) {
73 | browser.emit('disconnected')
74 | }
75 | })
76 |
77 | browser.once('disconnected', () => {
78 | marionette.disconnect()
79 | })
80 |
81 | return browser
82 | }
83 |
84 | async launch (userOptions: TLaunchOptions): Promise {
85 | const options = {
86 | headless: true,
87 | dumpio: false,
88 | ...userOptions
89 | } as TLaunchOptions
90 |
91 | if (typeof options.executablePath !== 'string') {
92 | throw new Error('`executablePath` option is required, Foxr doesn\'t download Firefox automatically')
93 | }
94 |
95 | const args = ['-marionette', '-safe-mode', '-no-remote']
96 |
97 | if (options.headless === true) {
98 | args.push('-headless')
99 | }
100 |
101 | if (Array.isArray(options.args)) {
102 | args.push(...options.args)
103 | }
104 |
105 | const firefoxProcess = execa(options.executablePath, args, {
106 | detached: true,
107 | stdio: options.dumpio ? 'inherit' : 'ignore'
108 | })
109 |
110 | onExit(() => {
111 | firefoxProcess.kill()
112 | })
113 |
114 | firefoxProcess.unref()
115 |
116 | await waitForPort(DEFAULT_HOST, DEFAULT_PORT)
117 |
118 | return this.connect(options)
119 | }
120 | }
121 |
122 | export default Foxr
123 |
--------------------------------------------------------------------------------
/src/Marionette.ts:
--------------------------------------------------------------------------------
1 | import { EventEmitter } from 'events'
2 | import { Socket } from 'net'
3 | import { TJsonArray, TJsonMap, TJsonValue } from 'typeon'
4 | import FoxrError from './Error'
5 | import { createParseStream, parse, stringify } from './json-protocol'
6 |
7 | const CONNECTION_TIMEOUT = 10000
8 |
9 | export type TMarionetteError = {
10 | error: string,
11 | message: string,
12 | stacktrace: string
13 | }
14 |
15 | class Marionette extends EventEmitter {
16 | private globalId: number
17 | private queue: {
18 | id: number,
19 | key?: string,
20 | resolve: (arg: TJsonValue) => void,
21 | reject: (error: Error) => void
22 | }[]
23 | private socket: Socket
24 | private isManuallyClosed: boolean
25 |
26 | constructor () {
27 | super()
28 |
29 | this.globalId = 0
30 | this.queue = []
31 | this.socket = new Socket()
32 | this.isManuallyClosed = false
33 |
34 | this.send = this.send.bind(this)
35 | }
36 |
37 | async connect (host: string, port: number) {
38 | // TODO: extract everything about socket as separate "transport" module
39 | await new Promise((resolve, reject) => {
40 | const rejectAndDestroy = (error: Error) => {
41 | reject(error)
42 | this.socket.destroy()
43 | }
44 |
45 | this.socket
46 | .setTimeout(CONNECTION_TIMEOUT)
47 | .once('connect', () => {
48 | this.socket.once('data', (rawData) => {
49 | const data = parse(rawData)
50 |
51 | if (data.applicationType === 'gecko') {
52 | if (data.marionetteProtocol === 3) {
53 | return resolve()
54 | }
55 |
56 | return rejectAndDestroy(new FoxrError('Foxr works only with Marionette protocol v3'))
57 | }
58 |
59 | rejectAndDestroy(new FoxrError('Unsupported Marionette protocol'))
60 | })
61 | })
62 | .once('timeout', () => rejectAndDestroy(new Error('Socket connection timeout')))
63 | .once('error', (err) => rejectAndDestroy(err))
64 | .once('end', () => {
65 | this.emit('close', { isManuallyClosed: this.isManuallyClosed })
66 | })
67 | .connect(port, host)
68 | })
69 |
70 | const parseStream = createParseStream()
71 |
72 | parseStream.on('data', (data: [number, number, TMarionetteError | null, TJsonMap | TJsonArray]) => {
73 | const [type, id, error, result] = data
74 |
75 | if (type === 1) {
76 | this.queue = this.queue.filter((item) => {
77 | if (item.id === id) {
78 | if (error !== null) {
79 | item.reject(new FoxrError(error.message))
80 | } else if (typeof item.key === 'string') {
81 | item.resolve((result as TJsonMap)[item.key])
82 | } else {
83 | item.resolve(result)
84 | }
85 |
86 | return false
87 | }
88 |
89 | return true
90 | })
91 | }
92 | })
93 |
94 | this.socket.pipe(parseStream)
95 | }
96 |
97 | disconnect () {
98 | this.isManuallyClosed = true
99 |
100 | this.socket.end()
101 | }
102 |
103 | async send (name: string, params: TJsonMap = {}, key?: string) {
104 | return new Promise((resolve, reject) => {
105 | const data = stringify([0, this.globalId, name, params])
106 |
107 | this.socket.write(data, 'utf8', () => {
108 | this.queue.push({ id: this.globalId, key, resolve, reject })
109 | this.globalId += 1
110 | })
111 | })
112 | }
113 | }
114 |
115 | export default Marionette
116 |
--------------------------------------------------------------------------------
/src/api/Browser.ts:
--------------------------------------------------------------------------------
1 | import { EventEmitter } from 'events'
2 | import Page from './Page'
3 | import {
4 | TSend,
5 | TInstallAddonResult,
6 | Context,
7 | TGetPrefResult
8 | } from './types'
9 |
10 | class Browser extends EventEmitter {
11 | private _send: TSend
12 |
13 | constructor (arg: { send: TSend }) {
14 | super()
15 |
16 | this._send = arg.send
17 | }
18 |
19 | async close (): Promise {
20 | await this._send('Marionette:AcceptConnections', { value: false })
21 | await this._send('Marionette:Quit')
22 |
23 | this.emit('disconnected')
24 | }
25 |
26 | async disconnect (): Promise {
27 | await this._send('WebDriver:DeleteSession')
28 |
29 | this.emit('disconnected')
30 | }
31 |
32 | async getPref (pref: string, defaultBranch: boolean = false): Promise {
33 | await this._setContext(Context.CHROME)
34 |
35 | const value = await this._send(
36 | 'WebDriver:ExecuteScript',
37 | {
38 | script: `let [pref, defaultBranch] = arguments;
39 | Cu.import('resource://gre/modules/Preferences.jsm');
40 |
41 | let prefs = new Preferences({defaultBranch});
42 |
43 | return prefs.get(pref);`,
44 | args: [pref, defaultBranch]
45 | },
46 | 'value') as TGetPrefResult
47 |
48 | await this._setContext(Context.CONTENT)
49 |
50 | return value
51 | }
52 |
53 | async install (path: string, isTemporary: boolean): Promise {
54 | const { value } = await this._send('Addon:Install', {
55 | path,
56 | temporary: isTemporary
57 | }) as TInstallAddonResult
58 |
59 | return value
60 | }
61 |
62 | async newPage (): Promise {
63 | await this._send('WebDriver:ExecuteScript', {
64 | script: 'window.open()'
65 | })
66 |
67 | const pages = await this._send('WebDriver:GetWindowHandles') as string[]
68 | const newPageId = pages[pages.length - 1]
69 |
70 | await this._send('WebDriver:SwitchToWindow', {
71 | name: newPageId,
72 | focus: true
73 | })
74 |
75 | return new Page({
76 | browser: this,
77 | id: newPageId,
78 | send: this._send
79 | })
80 | }
81 |
82 | async pages (): Promise {
83 | const ids = await this._send('WebDriver:GetWindowHandles') as string[]
84 |
85 | return ids.map((id) => new Page({
86 | browser: this,
87 | id,
88 | send: this._send
89 | }))
90 | }
91 |
92 | private async _setContext (context: Context): Promise {
93 | await this._send('Marionette:SetContext', { value: context })
94 | }
95 |
96 | async setPref (pref: string, value: string | number | boolean, defaultBranch: boolean = false): Promise {
97 | await this._setContext(Context.CHROME)
98 |
99 | const error = await this._send('WebDriver:ExecuteScript', {
100 | script: `let [pref, value, defaultBranch] = arguments;
101 | Cu.import('resource://gre/modules/Preferences.jsm');
102 |
103 | let prefs = new Preferences({defaultBranch});
104 |
105 | try {
106 | prefs.set(pref,value);
107 | return null;
108 | } catch(e) {
109 | return e;
110 | }`,
111 | args: [pref, value, defaultBranch]
112 | }, 'value') as Error|null
113 |
114 | await this._setContext(Context.CONTENT)
115 |
116 | if (error) {
117 | throw new Error(`SetPref failed: ${error.message}`)
118 | }
119 | }
120 |
121 | async uninstall (id: string): Promise {
122 | await this._send('Addon:Uninstall', { id })
123 | }
124 | }
125 |
126 | export default Browser
127 |
--------------------------------------------------------------------------------
/src/api/ElementHandle.ts:
--------------------------------------------------------------------------------
1 | import Page from './Page'
2 | import { TJSHandleId, TClickOptions, TMouseButton, TSend } from './types'
3 | import JSHandle from './JSHandle'
4 | import { pWriteFile, MOUSE_BUTTON, hasKey, getElementId } from '../utils'
5 | import KEYS from '../keys'
6 |
7 | class ElementHandle extends JSHandle {
8 | private _page: Page
9 | private _send: TSend
10 | private _actionId: number | null
11 |
12 | constructor (params: { page: Page, id: TJSHandleId, send: TSend }) {
13 | super(params)
14 |
15 | this._page = params.page
16 | this._send = params.send
17 | this._actionId = null
18 | }
19 |
20 | private async _scrollIntoView (): Promise {
21 | /* istanbul ignore next */
22 | await this._page.evaluate((el) => {
23 | (el as Element).scrollIntoView()
24 | }, this._handleId)
25 | }
26 |
27 | async $ (selector: string): Promise {
28 | try {
29 | const id = await this._send('WebDriver:FindElement', {
30 | element: this._elementId,
31 | value: selector,
32 | using: 'css selector'
33 | }, 'value') as TJSHandleId
34 |
35 | return new ElementHandle({
36 | page: this._page,
37 | id,
38 | send: this._send
39 | })
40 | } catch (err) {
41 | if (err.message.startsWith('Unable to locate element')) {
42 | return null
43 | }
44 |
45 | throw err
46 | }
47 | }
48 |
49 | async $$ (selector: string): Promise {
50 | const ids = await this._send('WebDriver:FindElements', {
51 | element: this._elementId,
52 | value: selector,
53 | using: 'css selector'
54 | }) as TJSHandleId[]
55 |
56 | return ids.map((id) => new ElementHandle({
57 | page: this._page,
58 | id,
59 | send: this._send
60 | }))
61 | }
62 |
63 | async click (userOptions?: TClickOptions): Promise {
64 | const options = {
65 | button: 'left',
66 | clickCount: 1,
67 | ...userOptions
68 | }
69 | const mouseButton = MOUSE_BUTTON[options.button as TMouseButton]
70 |
71 | await this._scrollIntoView()
72 |
73 | const id = await this._send('Marionette:ActionChain', {
74 | chain: [
75 | ['click', this._elementId, mouseButton, options.clickCount]
76 | ],
77 | nextId: this._actionId
78 | }, 'value') as number
79 |
80 | this._actionId = id
81 | }
82 |
83 | async focus (): Promise {
84 | await this._send('WebDriver:ExecuteScript', {
85 | 'script': 'arguments[0].focus()',
86 | args: [this._handleId]
87 | })
88 | }
89 |
90 | async hover (): Promise {
91 | await this._scrollIntoView()
92 |
93 | const id = await this._send('Marionette:ActionChain', {
94 | chain: [
95 | ['move', this._elementId]
96 | ],
97 | nextId: this._actionId
98 | }, 'value') as number
99 |
100 | this._actionId = id
101 | }
102 |
103 | async press (key: string): Promise {
104 | if (hasKey(KEYS, key)) {
105 | await this._send('WebDriver:ElementSendKeys', {
106 | id: this._elementId,
107 | text: KEYS[key]
108 | })
109 |
110 | return
111 | }
112 |
113 | if (key.length === 1) {
114 | await this._send('WebDriver:ElementSendKeys', {
115 | id: this._elementId,
116 | text: key
117 | })
118 |
119 | return
120 | }
121 |
122 | throw new Error(`Unknown key: ${key}`)
123 | }
124 |
125 | async screenshot (options: { path?: string } = {}): Promise {
126 | const result = await this._send('WebDriver:TakeScreenshot', {
127 | id: this._elementId,
128 | full: false,
129 | hash: false
130 | }, 'value') as string
131 | const buffer = Buffer.from(result, 'base64')
132 |
133 | if (typeof options.path === 'string') {
134 | await pWriteFile(options.path, buffer)
135 | }
136 |
137 | return buffer
138 | }
139 |
140 | async type (text: string): Promise {
141 | await this._send('WebDriver:ElementSendKeys', {
142 | id: this._elementId,
143 | text
144 | })
145 | }
146 | }
147 |
148 | export default ElementHandle
149 |
--------------------------------------------------------------------------------
/test/api/Browser.ts:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import foxr from '../../src/'
3 | import Page from '../../src/api/Page'
4 | import { testWithFirefox, stopFirefox, containerExtPath } from '../helpers/firefox'
5 | import { createSpy, getSpyCalls } from 'spyfn'
6 |
7 | test('Browser: `close()` + `disconnected` event', testWithFirefox(async (t) => {
8 | const browser = await foxr.connect()
9 | const onDisconnectSpy = createSpy(() => {})
10 |
11 | browser.on('disconnected', onDisconnectSpy)
12 |
13 | // TODO: figure out how to test this for real
14 | await browser.close()
15 |
16 | t.deepEqual(
17 | getSpyCalls(onDisconnectSpy),
18 | [[]],
19 | 'should emit `disconnected` event'
20 | )
21 | }))
22 |
23 | test('Browser: socket close + `disconnected` event', testWithFirefox(async (t) => {
24 | const browser = await foxr.connect()
25 | const onDisconnectSpy = createSpy(() => {})
26 |
27 | browser.on('disconnected', onDisconnectSpy)
28 |
29 | await stopFirefox()
30 |
31 | t.deepEqual(
32 | getSpyCalls(onDisconnectSpy),
33 | [[]],
34 | 'should emit `disconnected` event'
35 | )
36 | }))
37 |
38 | test('Browser: multiple sessions + `disconnect()` + `disconnected` events', testWithFirefox(async (t) => {
39 | const browser1 = await foxr.connect()
40 | const onDisconnectSpy1 = createSpy(() => {})
41 |
42 | browser1.on('disconnected', onDisconnectSpy1)
43 |
44 | // TODO: figure out how to test this for real
45 | await browser1.disconnect()
46 |
47 | const browser2 = await foxr.connect()
48 | const onDisconnectSpy2 = createSpy(() => {})
49 |
50 | browser2.on('disconnected', onDisconnectSpy2)
51 |
52 | await browser2.disconnect()
53 |
54 | t.deepEqual(
55 | getSpyCalls(onDisconnectSpy1),
56 | [[]],
57 | 'should emit `disconnected` event from the 1st session'
58 | )
59 |
60 | t.deepEqual(
61 | getSpyCalls(onDisconnectSpy2),
62 | [[]],
63 | 'should emit `disconnected` event from the 2nd session'
64 | )
65 | }))
66 |
67 | test('Browser: `newPage()`', testWithFirefox(async (t) => {
68 | const browser = await foxr.connect()
69 | const pagesBefore = await browser.pages()
70 |
71 | const page1 = await browser.newPage()
72 | const page2 = await browser.newPage()
73 |
74 | const pagesAfter = await browser.pages()
75 |
76 | t.equal(
77 | pagesBefore.length + 2,
78 | pagesAfter.length,
79 | 'should create 2 pages'
80 | )
81 |
82 | t.true(
83 | page1 instanceof Page,
84 | 'should create real page 1'
85 | )
86 |
87 | t.true(
88 | page2 instanceof Page,
89 | 'should create real page 2'
90 | )
91 | }))
92 |
93 | test('Browser: `pages()`', testWithFirefox(async (t) => {
94 | const browser = await foxr.connect()
95 | const pages = await browser.pages()
96 |
97 | t.true(
98 | pages.every((page) => page instanceof Page),
99 | 'should return array of pages'
100 | )
101 |
102 | t.deepEqual(
103 | pages,
104 | await browser.pages(),
105 | 'should return the same pages twice'
106 | )
107 | }))
108 |
109 | test('Browser: `install()` + `uninstall()`', testWithFirefox(async (t) => {
110 | const browser = await foxr.connect()
111 | const id = await browser.install(containerExtPath, true)
112 |
113 | t.true(
114 | typeof id === 'string' && id !== '',
115 | 'should install test extension'
116 | )
117 |
118 | if (id === null) {
119 | return t.fail('unable to install test extension')
120 | }
121 |
122 | try {
123 | await browser.install('impossible_path', true)
124 | t.fail()
125 | } catch (err) {
126 | t.pass('should fail to install invalid extension')
127 | }
128 |
129 | try {
130 | await browser.uninstall(id)
131 | t.pass('should uninstall test extension')
132 | } catch (err) {
133 | t.fail(err)
134 | }
135 |
136 | try {
137 | await browser.uninstall(id)
138 | t.fail()
139 | } catch (err) {
140 | t.true(
141 | err.message.includes('candidate is null'),
142 | 'should fail to uninstall already uninstalled test extension'
143 | )
144 | }
145 | }))
146 |
147 | test('Browser: `getPref()` + `setPref()`', testWithFirefox(async (t) => {
148 | const browser = await foxr.connect()
149 |
150 | let defaultBranchPref = await browser.getPref('browser.startup.page', true)
151 | let nonDefaultBranchPref = await browser.getPref('browser.startup.page', false)
152 | t.assert(defaultBranchPref === 1 && nonDefaultBranchPref === 0, 'should give the default value')
153 |
154 | await browser.setPref('browser.startup.page', 0, true)
155 | defaultBranchPref = await browser.getPref('browser.startup.page', true)
156 | t.equal(defaultBranchPref, 0, 'should give the new value in default branch')
157 |
158 | await browser.setPref('browser.startup.page', 1, false)
159 | nonDefaultBranchPref = await browser.getPref('browser.startup.page', false)
160 | t.equal(nonDefaultBranchPref, 1, 'should give the new value in non-default branch')
161 |
162 | await browser.setPref('browser.startup.page', 1, true)
163 | defaultBranchPref = await browser.getPref('browser.startup.page', true)
164 | t.equal(defaultBranchPref, 1, 'should give the new value in default branch')
165 |
166 | await browser.setPref('browser.startup.page', 0, false)
167 | nonDefaultBranchPref = await browser.getPref('browser.startup.page', false)
168 | t.equal(nonDefaultBranchPref, 0, 'should give the new value in non-default branch')
169 |
170 | try {
171 | await browser.setPref('browser.startup.page', 'string', true)
172 | t.fail()
173 | } catch (err) {
174 | t.pass('should fail to set pref of invalid type')
175 | }
176 | }))
177 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # foxr
2 |
3 | [](https://www.npmjs.com/package/foxr) [](https://packagephobia.now.sh/result?p=foxr) [](https://travis-ci.org/deepsweet/foxr) [](https://codecov.io/github/deepsweet/foxr)
4 |
5 | Node.js API to control Firefox.
6 |
7 |
8 |
9 | * uses a built-in [Marionette](https://vakila.github.io/blog/marionette-act-i-automation/) through [remote protocol](https://firefox-source-docs.mozilla.org/testing/marionette/marionette/index.html)
10 | * no [Selenium WebDriver](https://github.com/SeleniumHQ/selenium/wiki/FirefoxDriver) is needed
11 | * works with [Headless mode](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Headless_mode)
12 | * compatible subset of [Puppeteer](https://github.com/GoogleChrome/puppeteer) API
13 |
14 | At this point Foxr is more a proof of concept, [work is pretty much in progress](https://github.com/deepsweet/foxr/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3Aenhancement).
15 |
16 | ## Example
17 |
18 | Run a locally installed Firefox:
19 |
20 | ```sh
21 | /path/to/firefox -headless -marionette -safe-mode
22 | ```
23 |
24 | Or a [dockerized version](https://github.com/deepsweet/firefox-headless-remote):
25 |
26 | ```sh
27 | docker run -it --rm --shm-size 2g -p 2828:2828 deepsweet/firefox-headless-remote:68
28 | ```
29 |
30 | ```js
31 | import foxr from 'foxr'
32 | // const foxr = require('foxr').default
33 |
34 | (async () => {
35 | try {
36 | const browser = await foxr.connect()
37 | const page = await browser.newPage()
38 |
39 | await page.goto('https://example.com')
40 | await page.screenshot({ path: 'example.png' })
41 | await browser.close()
42 | } catch (error) {
43 | console.error(error)
44 | }
45 | })()
46 | ```
47 |
48 | ## Install
49 |
50 | ```sh
51 | yarn add --dev foxr
52 | # or
53 | npm install --save-dev foxr
54 | ```
55 |
56 | ## API
57 |
58 | ### Foxr
59 |
60 | #### `connect`
61 |
62 | Connect to the Marionette endpoint.
63 |
64 | ```ts
65 | type TConnectOptions = {
66 | host?: string,
67 | port?: number,
68 | defaultViewport?: {
69 | width?: number,
70 | height?: number
71 | }
72 | }
73 |
74 | foxr.connect(options?: TConnectOptions): Promise
75 | ```
76 |
77 | * `host` – `'localhost'` by default
78 | * `port` – `2828` by default
79 | * `defaultViewport`
80 | * `width` – `800` by default
81 | * `height` – `600` by default
82 |
83 | #### `launch`
84 |
85 | ```ts
86 | type TLaunchOptions = {
87 | args?: string[],
88 | dumpio?: boolean,
89 | executablePath: string,
90 | headless?: boolean
91 | } & TConnectOptions
92 |
93 | foxr.launch(options?: TLaunchOptions): Promise
94 | ```
95 |
96 | * `args` – array of additional args, `['-marionette', '-safe-mode', '-no-remote']` by default
97 | * `dumpio` – print browser process stdout and stderr, `false` by default
98 | * `executablePath` – path to Firefox executable, required
99 | * `headless` – whether to run browser in headless mode, `true` by default
100 |
101 | ### Browser
102 |
103 | #### `close`
104 |
105 | ```ts
106 | browser.close(): Promise
107 | ```
108 |
109 | #### `disconnect`
110 |
111 | ```ts
112 | browser.disconnect(): Promise
113 | ```
114 |
115 | #### `newPage`
116 |
117 | ```ts
118 | browser.newPage(): Promise
119 | ```
120 |
121 | #### `pages`
122 |
123 | ```ts
124 | browser.pages(): Promise
125 | ```
126 |
127 | #### `install`
128 |
129 | ```ts
130 | browser.install(extensionPath: string, isTemporary: boolean): Promise
131 | ```
132 |
133 | #### `uninstall`
134 |
135 | ```ts
136 | browser.install(extensionId: string): Promise
137 | ```
138 |
139 | #### `getPref`
140 |
141 | ```ts
142 | browser.getPref(pref: string, defaultBranch: boolean = false): Promise
143 | ```
144 |
145 | #### `setPref`
146 |
147 | ```ts
148 | browser.setPref(pref: string, value: string | number | boolean, defaultBranch: boolean = false): Promise
149 | ```
150 |
151 | ### Page
152 |
153 | #### `$`
154 |
155 | ```ts
156 | page.$(selector: string): Promise
157 | ```
158 |
159 | #### `$$`
160 |
161 | ```ts
162 | page.$$(selector: string): Promise
163 | ```
164 |
165 | #### `$eval`
166 |
167 | ```ts
168 | page.$eval(selector: string, func: TSerializableFunction, ...args: TEvaluateArg[]): Promise
169 | ```
170 |
171 | #### `$$eval`
172 |
173 | ```ts
174 | page.$$eval(selector: string, func: TSerializableFunction, ...args: TEvaluateArg[]): Promise>
175 | ```
176 |
177 | #### `bringToFront`
178 |
179 | ```ts
180 | page.bringToFront(): Promise
181 | ```
182 |
183 | #### `browser`
184 |
185 | ```ts
186 | page.browser(): TBrowser
187 | ```
188 |
189 | #### `close`
190 |
191 | ```ts
192 | page.close(): Promise
193 | ```
194 |
195 | #### `content`
196 |
197 | ```ts
198 | page.content(): Promise
199 | ```
200 |
201 | #### `evaluate`
202 |
203 | ```ts
204 | page.evaluate(target: string): Promise
205 | page.evaluate(target: TSerializableFunction, ...args: TEvaluateArg[]): Promise
206 | ```
207 |
208 | #### `evaluateHandle`
209 |
210 | ```ts
211 | page.evaluate(target: string): Promise
212 | page.evaluate(target: TSerializableFunction, ...args: TEvaluateArg[]): Promise
213 | ```
214 |
215 | #### `focus`
216 |
217 | ```ts
218 | page.focus(selector: string): Promise
219 | ```
220 |
221 | #### `goto`
222 |
223 | ```ts
224 | page.goto(url: string): Promise
225 | ```
226 |
227 | #### `screenshot`
228 |
229 | ```ts
230 | page.screenshot(options?: { path?: string }): Promise
231 | ```
232 |
233 | #### `setContent`
234 |
235 | ```ts
236 | page.setContent(html: string): Promise
237 | ```
238 |
239 | #### `title`
240 |
241 | ```ts
242 | page.title(): Promise
243 | ```
244 |
245 | #### `url`
246 |
247 | ```ts
248 | page.url(): Promise
249 | ```
250 |
251 | #### `viewport`
252 |
253 | ```ts
254 | page.viewport(): Promise<{ width: number, height: number }>
255 | ```
256 |
257 | ### JSHandle
258 |
259 | …
260 |
261 | ### ElementHandle
262 |
263 | #### `$`
264 |
265 | ```ts
266 | elementHandle.$(selector: string): Promise
267 | ```
268 |
269 | #### `$$`
270 |
271 | ```ts
272 | elementHandle.$$(selector: string): Promise
273 | ```
274 |
275 | #### `click`
276 |
277 | ```ts
278 | type TOptions = {
279 | button?: 'left' | 'middle' | 'right',
280 | clickCount?: number
281 | }
282 |
283 | elementHandle.click(options?: TOptions): Promise
284 | ```
285 |
286 | #### `focus`
287 |
288 | ```ts
289 | elementHandle.focus(): Promise
290 | ```
291 |
292 | #### `hover`
293 |
294 | ```ts
295 | elementHandle.hover(): Promise
296 | ```
297 |
298 | #### `press`
299 |
300 | ```ts
301 | elementHandle.press(key: string): Promise
302 | ```
303 |
304 | Where `key` is of the [possible keys](./src/keys.ts) or a single character.
305 |
306 | #### `screenshot`
307 |
308 | ```ts
309 | elementHandle.screenshot(options?: { path?: string }): Promise
310 | ```
311 |
312 | #### `type`
313 |
314 | ```ts
315 | elementHandle.type(text: string): Promise
316 | ```
317 |
318 | ## Development
319 |
320 | See [my Start task runner preset](https://github.com/deepsweet/_/tree/master/packages/start-preset-node-ts-lib) for details.
321 |
322 | ## References
323 |
324 | * Python Client: [API](https://marionette-client.readthedocs.io/en/latest/reference.html), [source](https://searchfox.org/mozilla-central/source/testing/marionette/client/)
325 | * Perl Client: [API](https://metacpan.org/pod/Firefox::Marionette), [source](https://metacpan.org/source/DDICK/Firefox-Marionette-0.57/lib/Firefox)
326 | * Node.js client (outdated): [source](https://github.com/mozilla-b2g/gaia/tree/master/tests/jsmarionette/client)
327 | * [Marionette Google Group](https://groups.google.com/forum/#!forum/mozilla.tools.marionette)
328 |
--------------------------------------------------------------------------------
/test/api/ElementHandle.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import test from 'blue-tape'
3 | import foxr from '../../src/'
4 | import ElementHandle from '../../src/api/ElementHandle'
5 | import { testWithFirefox } from '../helpers/firefox'
6 | import { getSpyCalls, createSpy } from 'spyfn'
7 | import { mock, unmock } from 'mocku'
8 |
9 | test('ElementHandle `$()`', testWithFirefox(async (t) => {
10 | const browser = await foxr.connect()
11 | const page = await browser.newPage()
12 |
13 | await page.setContent('hello
')
14 |
15 | const div = await page.$('div')
16 |
17 | if (div === null) {
18 | t.fail('There should be div')
19 | return
20 | }
21 |
22 | t.equal(
23 | await div.$('h2'),
24 | null,
25 | 'should return null if nothing has been found'
26 | )
27 |
28 | const element = await div.$('h1')
29 |
30 | t.true(
31 | element instanceof ElementHandle,
32 | 'should return a single Element'
33 | )
34 |
35 | t.equal(
36 | element,
37 | await div.$('h1'),
38 | 'should return the same element twice'
39 | )
40 |
41 | try {
42 | await div.$('(')
43 | t.fail()
44 | } catch (err) {
45 | t.true(
46 | err.message.startsWith('Given css selector expression'),
47 | 'should throw'
48 | )
49 | }
50 | }))
51 |
52 | test('ElementHandle `$$()`', testWithFirefox(async (t) => {
53 | const browser = await foxr.connect()
54 | const page = await browser.newPage()
55 |
56 | await page.setContent('hello
world
')
57 |
58 | const div = await page.$('div')
59 |
60 | if (div === null) {
61 | t.fail('There should be div')
62 | return
63 | }
64 |
65 | t.deepEqual(
66 | await div.$$('h1'),
67 | [],
68 | 'should return empty array if nothing has been found'
69 | )
70 |
71 | const elements = await div.$$('h2')
72 |
73 | t.true(
74 | elements.length === 2 && elements.every((el) => el instanceof ElementHandle),
75 | 'should return multiple Elements'
76 | )
77 |
78 | t.deepEqual(
79 | elements,
80 | await div.$$('h2'),
81 | 'should return the same elements twice'
82 | )
83 | }))
84 |
85 | test('ElementHandle `click()`', testWithFirefox(async (t) => {
86 | const browser = await foxr.connect()
87 | const page = await browser.newPage()
88 |
89 | await page.setContent('hi
')
90 |
91 | // >There are two properties for finding out which mouse button has been clicked: which and button.
92 | // >Please note that these properties don’t always work on a click event.
93 | // >To safely detect a mouse button you have to use the mousedown or mouseup events.
94 | // https://www.quirksmode.org/js/events_properties.html#button
95 | await page.evaluate(() => {
96 | const el = document.querySelector('div')
97 |
98 | if (el !== null) {
99 | el.addEventListener('mouseup', (e) => {
100 | // @ts-ignore
101 | window.__click__ = {
102 | button: e.button
103 | }
104 | })
105 |
106 | el.addEventListener('dblclick', () => {
107 | // @ts-ignore
108 | window.__dblclick__ = true
109 | })
110 | }
111 | })
112 |
113 | const target = await page.$('div')
114 |
115 | if (target === null) {
116 | t.fail('There should be element')
117 | return
118 | }
119 |
120 | // TODO: test for `scrollIntoView()`
121 | await target.click()
122 |
123 | t.deepEqual(
124 | await page.evaluate('window.__click__'),
125 | { button: 0 },
126 | 'should click with the left mouse button by default'
127 | )
128 |
129 | await target.click({
130 | button: 'left'
131 | })
132 |
133 | t.deepEqual(
134 | await page.evaluate('window.__click__'),
135 | { button: 0 },
136 | 'should click with the left mouse button'
137 | )
138 |
139 | await target.click({
140 | button: 'middle'
141 | })
142 |
143 | t.deepEqual(
144 | await page.evaluate('window.__click__'),
145 | { button: 1 },
146 | 'should click with the middle mouse button'
147 | )
148 |
149 | await target.click({
150 | button: 'right'
151 | })
152 |
153 | t.deepEqual(
154 | await page.evaluate('window.__click__'),
155 | { button: 2 },
156 | 'should click with the right mouse button'
157 | )
158 |
159 | await target.click({
160 | clickCount: 2
161 | })
162 |
163 | t.true(
164 | await page.evaluate('window.__dblclick__'),
165 | 'should double click'
166 | )
167 | }))
168 |
169 | test('ElementHandle `focus()`', testWithFirefox(async (t) => {
170 | const browser = await foxr.connect()
171 | const page = await browser.newPage()
172 |
173 | await page.setContent('')
174 |
175 | const target = await page.$('input')
176 |
177 | if (target === null) {
178 | t.fail('There should be element')
179 | return
180 | }
181 |
182 | const activeElementBefore = await page.evaluate('document.activeElement.tagName')
183 |
184 | await target.focus()
185 |
186 | const activeElementAfter = await page.evaluate('document.activeElement.tagName')
187 |
188 | t.true(
189 | activeElementBefore !== activeElementAfter && activeElementAfter === 'INPUT',
190 | 'should focus element'
191 | )
192 | }))
193 |
194 | test('ElementHandle `hover()`', testWithFirefox(async (t) => {
195 | const browser = await foxr.connect()
196 | const page = await browser.newPage()
197 |
198 | await page.setContent('hi
')
199 |
200 | await page.evaluate(() => {
201 | const el = document.querySelector('div')
202 |
203 | if (el !== null) {
204 | el.addEventListener('mouseenter', (e) => {
205 | // @ts-ignore
206 | window.__hover__ = true
207 | })
208 | }
209 | })
210 |
211 | const target = await page.$('div')
212 |
213 | if (target === null) {
214 | t.fail('There should be element')
215 | return
216 | }
217 |
218 | // TODO: test for `scrollIntoView()`
219 | await target.hover()
220 |
221 | t.true(
222 | await page.evaluate('window.__hover__'),
223 | 'should hover'
224 | )
225 | }))
226 |
227 | test('ElementHandle `press()`', testWithFirefox(async (t) => {
228 | const browser = await foxr.connect()
229 | const page = await browser.newPage()
230 |
231 | await page.setContent('')
232 |
233 | const target = await page.$('input')
234 |
235 | if (target === null) {
236 | t.fail('There should be element')
237 | return
238 | }
239 |
240 | await target.press('Backspace')
241 | await target.press('ArrowLeft')
242 | await target.press('Backspace')
243 | await target.press('ArrowLeft')
244 | await target.press('ArrowLeft')
245 | await target.press('Backspace')
246 | await target.press('ArrowLeft')
247 | await target.press('Delete')
248 | await target.press('e')
249 |
250 | t.equal(
251 | await page.evaluate('document.querySelector("input").value'),
252 | 'hello',
253 | 'should press keys'
254 | )
255 |
256 | try {
257 | // @ts-ignore
258 | await target.press('Pepe')
259 | t.fail()
260 | } catch (err) {
261 | t.equal(
262 | err.message,
263 | 'Unknown key: Pepe',
264 | 'should throw if there is no such a key'
265 | )
266 | }
267 | }))
268 |
269 | test('ElementHandle `screenshot()`', testWithFirefox(async (t) => {
270 | const writeFileSpy = createSpy(({ args }) => args[args.length - 1](null))
271 |
272 | mock('../../src/', {
273 | fs: {
274 | ...fs,
275 | writeFile: writeFileSpy
276 | }
277 | })
278 |
279 | const { default: foxr } = await import('../../src/')
280 | const browser = await foxr.connect()
281 | const page = await browser.newPage()
282 |
283 | await page.setContent('hello
')
284 |
285 | const div = await page.$('div')
286 |
287 | if (div === null) {
288 | t.fail('There should be div')
289 | return
290 | }
291 |
292 | const screenshot1 = await div.screenshot()
293 |
294 | t.true(
295 | Buffer.isBuffer(screenshot1) && screenshot1.length > 0,
296 | 'should return non-empty Buffer'
297 | )
298 |
299 | const screenshot2 = await div.screenshot({ path: 'test.png' })
300 | const spyArgs = getSpyCalls(writeFileSpy)[0]
301 |
302 | t.equal(
303 | spyArgs[0],
304 | 'test.png',
305 | 'path: should handle `path` option'
306 | )
307 |
308 | t.true(
309 | Buffer.isBuffer(spyArgs[1]) && spyArgs[1].length > 0,
310 | 'path: should write screenshot to file'
311 | )
312 |
313 | t.true(
314 | Buffer.isBuffer(screenshot2) && screenshot2.length > 0,
315 | 'path: should return non-empty buffer'
316 | )
317 |
318 | unmock('../../src/')
319 | }))
320 |
321 | test('ElementHandle `type()`', testWithFirefox(async (t) => {
322 | const browser = await foxr.connect()
323 | const page = await browser.newPage()
324 |
325 | await page.setContent('')
326 |
327 | const target = await page.$('input')
328 |
329 | if (target === null) {
330 | t.fail('There should be element')
331 | return
332 | }
333 |
334 | await target.type('hello')
335 |
336 | t.equal(
337 | await page.evaluate('document.querySelector("input").value'),
338 | 'hello',
339 | 'should type'
340 | )
341 | }))
342 |
--------------------------------------------------------------------------------
/src/api/Page.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-use-before-define */
2 | import { EventEmitter } from 'events'
3 | import { TJsonValue } from 'typeon'
4 | import { pWriteFile, mapEvaluateArgs } from '../utils'
5 | import Browser from './Browser'
6 | import ElementHandle from './ElementHandle'
7 | import {
8 | TEvaluateResult,
9 | TStringifiableFunction,
10 | TEvaluateHandleResult,
11 | TEvaluateResults,
12 | TEvaluateArg,
13 | TJSHandleId,
14 | TSend
15 | } from './types'
16 | import JSHandle from './JSHandle'
17 |
18 | const cache = new Map()
19 |
20 | class Page extends EventEmitter {
21 | private _browser: Browser
22 | private _id: string
23 | private _send: TSend
24 |
25 | constructor (params: { browser: Browser, id: string, send: TSend }) {
26 | super()
27 |
28 | this._browser = params.browser
29 | this._id = params.id
30 | this._send = params.send
31 |
32 | if (cache.has(params.id)) {
33 | return cache.get(params.id) as Page
34 | }
35 |
36 | cache.set(params.id, this)
37 |
38 | params.browser.on('disconnected', async () => {
39 | this.emit('close')
40 | cache.clear()
41 | })
42 | }
43 |
44 | async $ (selector: string): Promise {
45 | try {
46 | const id = await this._send('WebDriver:FindElement', {
47 | value: selector,
48 | using: 'css selector'
49 | }, 'value') as TJSHandleId
50 |
51 | return new ElementHandle({
52 | page: this,
53 | id,
54 | send: this._send
55 | })
56 | } catch (err) {
57 | if (err.message.startsWith('Unable to locate element')) {
58 | return null
59 | }
60 |
61 | throw err
62 | }
63 | }
64 |
65 | async $$ (selector: string): Promise {
66 | const ids = await this._send('WebDriver:FindElements', {
67 | value: selector,
68 | using: 'css selector'
69 | }) as TJSHandleId[]
70 |
71 | return ids.map((id) => new ElementHandle({
72 | page: this,
73 | id,
74 | send: this._send
75 | }))
76 | }
77 |
78 | async $eval (selector: string, func: TStringifiableFunction, ...args: TEvaluateArg[]): Promise {
79 | const result = await this._send('WebDriver:ExecuteAsyncScript', {
80 | script: `
81 | const resolve = arguments[arguments.length - 1]
82 | const el = document.querySelector(arguments[0])
83 | const args = Array.prototype.slice.call(arguments, 1, arguments.length - 1)
84 |
85 | if (el === null) {
86 | return resolve({ error: 'unable to find element' })
87 | }
88 |
89 | Promise.resolve()
90 | .then(() => (${func.toString()})(el, ...args))
91 | .then((value) => resolve({ error: null, value }))
92 | .catch((error) => resolve({ error: error instanceof Error ? error.message : error }))
93 | `,
94 | args: [selector, ...mapEvaluateArgs(args)]
95 | }, 'value') as TEvaluateResult
96 |
97 | if (result.error !== null) {
98 | throw new Error(`Evaluation failed: ${result.error}`)
99 | }
100 |
101 | return result.value
102 | }
103 |
104 | async $$eval (selector: string, func: TStringifiableFunction, ...args: TEvaluateArg[]): Promise> {
105 | const result = await this._send('WebDriver:ExecuteAsyncScript', {
106 | script: `
107 | const resolve = arguments[arguments.length - 1]
108 | const els = Array.from(document.querySelectorAll(arguments[0]))
109 | const args = Array.prototype.slice.call(arguments, 1, arguments.length - 1)
110 |
111 | Promise.all(
112 | els.map((el) => Promise.resolve().then(() => (${func.toString()})(el, ...args)))
113 | )
114 | .then((value) => resolve({ error: null, value }))
115 | .catch((error) => resolve({ error: error instanceof Error ? error.message : error }))
116 | `,
117 | args: [selector, ...mapEvaluateArgs(args)]
118 | }, 'value') as TEvaluateResults
119 |
120 | if (result.error !== null) {
121 | throw new Error(`Evaluation failed: ${result.error}`)
122 | }
123 |
124 | return result.value
125 | }
126 |
127 | async bringToFront (): Promise {
128 | await this._send('WebDriver:SwitchToWindow', {
129 | name: this._id,
130 | focus: true
131 | })
132 | }
133 |
134 | browser (): Browser {
135 | return this._browser
136 | }
137 |
138 | async close (): Promise {
139 | await this._send('WebDriver:ExecuteScript', {
140 | script: 'window.close()'
141 | })
142 |
143 | this.emit('close')
144 | cache.delete(this._id)
145 | }
146 |
147 | content (): Promise {
148 | return this._send('WebDriver:GetPageSource', {}, 'value') as Promise
149 | }
150 |
151 | async evaluate (target: TStringifiableFunction | string, ...args: TEvaluateArg[]): Promise {
152 | let result = null
153 |
154 | if (typeof target === 'function') {
155 | result = await this._send('WebDriver:ExecuteAsyncScript', {
156 | script: `
157 | const args = Array.prototype.slice.call(arguments, 0, arguments.length - 1)
158 | const resolve = arguments[arguments.length - 1]
159 |
160 | Promise.resolve()
161 | .then(() => (${target.toString()})(...args))
162 | .then((value) => resolve({ error: null, value }))
163 | .catch((error) => resolve({ error: error instanceof Error ? error.message : error }))
164 | `,
165 | args: mapEvaluateArgs(args)
166 | }, 'value') as TEvaluateResult
167 | } else {
168 | result = await this._send('WebDriver:ExecuteAsyncScript', {
169 | script: `
170 | const resolve = arguments[0]
171 |
172 | Promise.resolve()
173 | .then(() => ${target})
174 | .then((value) => resolve({ error: null, value }))
175 | .catch((error) => resolve({ error: error instanceof Error ? error.message : error }))
176 | `
177 | }, 'value') as TEvaluateResult
178 | }
179 |
180 | if (result.error !== null) {
181 | throw new Error(`Evaluation failed: ${result.error}`)
182 | }
183 |
184 | return result.value
185 | }
186 |
187 | async evaluateHandle (target: TStringifiableFunction | string, ...args: TEvaluateArg[]): Promise {
188 | let result = null
189 |
190 | if (typeof target === 'function') {
191 | result = await this._send('WebDriver:ExecuteAsyncScript', {
192 | script: `
193 | const args = Array.prototype.slice.call(arguments, 0, arguments.length - 1)
194 | const resolve = arguments[arguments.length - 1]
195 |
196 | Promise.resolve()
197 | .then(() => (${target.toString()})(...args))
198 | .then((value) => {
199 | if (value instanceof Element) {
200 | resolve({ error: null, value })
201 | } else {
202 | resolve({ error: null, value: null })
203 | }
204 | })
205 | .catch((error) => resolve({ error: error instanceof Error ? error.message : error }))
206 | `,
207 | args: mapEvaluateArgs(args)
208 | }, 'value') as TEvaluateHandleResult
209 | } else {
210 | result = await this._send('WebDriver:ExecuteAsyncScript', {
211 | script: `
212 | const resolve = arguments[0]
213 |
214 | Promise.resolve()
215 | .then(() => ${target})
216 | .then((value) => {
217 | if (value instanceof Element) {
218 | resolve({ error: null, value })
219 | } else {
220 | resolve({ error: null, value: null })
221 | }
222 | })
223 | .catch((error) => resolve({ error: error instanceof Error ? error.message : error }))
224 | `
225 | }, 'value') as TEvaluateHandleResult
226 | }
227 |
228 | if (result.error !== null) {
229 | throw new Error(`Evaluation failed: ${result.error}`)
230 | }
231 |
232 | if (result.value === null) {
233 | throw new Error('Unable to get a JSHandle')
234 | }
235 |
236 | return new JSHandle({
237 | page: this,
238 | id: result.value,
239 | send: this._send
240 | })
241 | }
242 |
243 | async focus (selector: string): Promise {
244 | await this.evaluate(`{
245 | const el = document.querySelector('${selector}')
246 |
247 | if (el === null) {
248 | throw new Error('unable to find element')
249 | }
250 |
251 | if (!(el instanceof HTMLElement)) {
252 | throw new Error('Found element is not HTMLElement and not focusable')
253 | }
254 |
255 | el.focus()
256 | }`)
257 | }
258 |
259 | async goto (url: string): Promise {
260 | await this._send('WebDriver:Navigate', { url })
261 | }
262 |
263 | async screenshot (options: { path?: string } = {}): Promise {
264 | const result = await this._send('WebDriver:TakeScreenshot', {
265 | full: true,
266 | hash: false
267 | }, 'value') as string
268 | const buffer = Buffer.from(result, 'base64')
269 |
270 | if (typeof options.path === 'string') {
271 | await pWriteFile(options.path, buffer)
272 | }
273 |
274 | return buffer
275 | }
276 |
277 | async setContent (html: string): Promise {
278 | await this._send('WebDriver:ExecuteScript', {
279 | script: 'document.documentElement.innerHTML = arguments[0]',
280 | args: [html]
281 | })
282 | }
283 |
284 | title (): Promise {
285 | return this._send('WebDriver:GetTitle', {}, 'value') as Promise
286 | }
287 |
288 | url (): Promise {
289 | return this._send('WebDriver:GetCurrentURL', {}, 'value') as Promise
290 | }
291 |
292 | async viewport (): Promise<{ width: number, height: number }> {
293 | type TResult = {
294 | width: number,
295 | height: number
296 | }
297 |
298 | return await this.evaluate(`
299 | ({
300 | width: window.innerWidth,
301 | height: window.innerHeight
302 | })
303 | `) as TResult
304 | }
305 | async goBack (): Promise {
306 | await this._send('WebDriver:Back', {})
307 | }
308 | async goForward (): Promise {
309 | await this._send('WebDriver:Forward', {})
310 | }
311 | }
312 |
313 | export default Page
314 |
--------------------------------------------------------------------------------
/test/api/Page.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import test from 'blue-tape'
3 | import { mock, unmock } from 'mocku'
4 | import { createSpy, getSpyCalls } from 'spyfn'
5 | import foxr from '../../src/'
6 | import ElementHandle from '../../src/api/ElementHandle'
7 | import { testWithFirefox } from '../helpers/firefox'
8 | import JSHandle from '../../src/api/JSHandle'
9 |
10 | test('Page: `close` event on browser close', testWithFirefox(async (t) => {
11 | const browser = await foxr.connect()
12 | const page = await browser.newPage()
13 | const onCloseSpy = createSpy(() => {})
14 |
15 | page.on('close', onCloseSpy)
16 |
17 | await browser.close()
18 |
19 | t.deepEqual(
20 | getSpyCalls(onCloseSpy),
21 | [[]],
22 | 'should emit `close` event'
23 | )
24 | }))
25 |
26 | test('Page: `close` event on browser disconnect', testWithFirefox(async (t) => {
27 | const browser = await foxr.connect()
28 | const page = await browser.newPage()
29 | const onCloseSpy = createSpy(() => {})
30 |
31 | page.on('close', onCloseSpy)
32 |
33 | await browser.disconnect()
34 |
35 | t.deepEqual(
36 | getSpyCalls(onCloseSpy),
37 | [[]],
38 | 'should emit `close` event'
39 | )
40 | }))
41 |
42 | test('Page: `$()`', testWithFirefox(async (t) => {
43 | const browser = await foxr.connect()
44 | const page = await browser.newPage()
45 |
46 | await page.setContent('hello
world
')
47 |
48 | t.equal(
49 | await page.$('h1'),
50 | null,
51 | 'should return null if nothing has been found'
52 | )
53 |
54 | const element = await page.$('h2')
55 |
56 | t.true(
57 | element !== null && element instanceof ElementHandle,
58 | 'should return a single Element'
59 | )
60 |
61 | t.equal(
62 | element,
63 | await await page.$('h2'),
64 | 'should return the same element twice'
65 | )
66 |
67 | try {
68 | await page.$('(')
69 | t.fail()
70 | } catch (err) {
71 | t.true(
72 | err.message.startsWith('Given css selector expression'),
73 | 'should throw'
74 | )
75 | }
76 | }))
77 |
78 | test('Page: `$$()`', testWithFirefox(async (t) => {
79 | const browser = await foxr.connect()
80 | const page = await browser.newPage()
81 |
82 | await page.setContent('hello
world
')
83 |
84 | t.deepEqual(
85 | await page.$$('h1'),
86 | [],
87 | 'should return empty array if nothing has been found'
88 | )
89 |
90 | const elements = await page.$$('h2')
91 |
92 | t.true(
93 | elements.length === 2 && elements.every((el) => el instanceof ElementHandle),
94 | 'should return multiple Elements'
95 | )
96 |
97 | t.deepEqual(
98 | elements,
99 | await page.$$('h2'),
100 | 'should return the same elements twice'
101 | )
102 | }))
103 |
104 | test('Page: `$eval()`', testWithFirefox(async (t) => {
105 | const browser = await foxr.connect()
106 | const page = await browser.newPage()
107 |
108 | await page.setContent('hi
')
109 |
110 | t.equal(
111 | // @ts-ignore
112 | await page.$eval('h1', (el) => el.tagName),
113 | 'H1',
114 | 'should evaluate function without arguments'
115 | )
116 |
117 | t.equal(
118 | // @ts-ignore
119 | await page.$eval('h1', (el, foo, bar) => `${el.tagName}-${foo}-${bar}`, 'foo', 'bar'),
120 | 'H1-foo-bar',
121 | 'should evaluate function with arguments'
122 | )
123 |
124 | const bodyJSHandle = await page.evaluateHandle('document.body')
125 | const bodyElementHandle = await page.$('body')
126 |
127 | t.equal(
128 | // @ts-ignore
129 | await page.$eval('h1', (el, handle) => `${el.tagName}-${handle.tagName}`, bodyJSHandle),
130 | 'H1-BODY',
131 | 'should evaluate function with arguments as JSHandle'
132 | )
133 |
134 | t.equal(
135 | // @ts-ignore
136 | await page.$eval('h1', (el, handle) => `${el.tagName}-${handle.tagName}`, bodyElementHandle),
137 | 'H1-BODY',
138 | 'should evaluate function with arguments as ElementHandle'
139 | )
140 |
141 | try {
142 | await page.$eval('h1', () => { throw new Error('oops') })
143 | t.fail()
144 | } catch (err) {
145 | t.equal(
146 | err.message,
147 | 'Evaluation failed: oops',
148 | 'should evaluate functions that throws'
149 | )
150 | }
151 |
152 | t.equal(
153 | // @ts-ignore
154 | await page.$eval('h1', (el) => Promise.resolve(el.tagName)),
155 | 'H1',
156 | 'should evaluate function that returns a resolved Promise'
157 | )
158 |
159 | t.equal(
160 | // @ts-ignore
161 | await page.$eval('h1', (el, foo, bar) => Promise.resolve(`${el.tagName}-${foo}-${bar}`), 'foo', 'bar'),
162 | 'H1-foo-bar',
163 | 'should evaluate function with arguments that returns a resolved Promise'
164 | )
165 |
166 | t.equal(
167 | // @ts-ignore
168 | await page.$eval('h1', (el, handle) => Promise.resolve(`${el.tagName}-${handle.tagName}`), bodyJSHandle),
169 | 'H1-BODY',
170 | 'should evaluate function with arguments as JSHandle that returns a resolved Promise'
171 | )
172 |
173 | t.equal(
174 | // @ts-ignore
175 | await page.$eval('h1', (el, handle) => Promise.resolve(`${el.tagName}-${handle.tagName}`), bodyElementHandle),
176 | 'H1-BODY',
177 | 'should evaluate function with arguments as ElementHandle that returns a resolved Promise'
178 | )
179 |
180 | try {
181 | await page.$eval('h1', () => Promise.reject(new Error('oops')))
182 | t.fail()
183 | } catch (err) {
184 | t.equal(
185 | err.message,
186 | 'Evaluation failed: oops',
187 | 'should evaluate functions that returns a rejected Promise'
188 | )
189 | }
190 |
191 | try {
192 | // @ts-ignore
193 | await page.$eval('h2', (el) => el.tagName)
194 | t.fail()
195 | } catch (err) {
196 | t.equal(
197 | err.message,
198 | 'Evaluation failed: unable to find element',
199 | 'should throw if there is no such an element'
200 | )
201 | }
202 | }))
203 |
204 | test('Page: `$$eval()`', testWithFirefox(async (t) => {
205 | const browser = await foxr.connect()
206 | const page = await browser.newPage()
207 |
208 | await page.setContent('hi
hello
')
209 |
210 | t.deepEqual(
211 | // @ts-ignore
212 | await page.$$eval('h2', (el) => el.textContent),
213 | ['hi', 'hello'],
214 | 'should evaluate function without arguments'
215 | )
216 |
217 | t.deepEqual(
218 | // @ts-ignore
219 | await page.$$eval('h2', (el, foo, bar) => `${el.textContent}-${foo}-${bar}`, 'foo', 'bar'),
220 | ['hi-foo-bar', 'hello-foo-bar'],
221 | 'should evaluate function with arguments'
222 | )
223 |
224 | const bodyJSHandle = await page.evaluateHandle('document.body')
225 | const bodyElementHandle = await page.$('body')
226 |
227 | t.deepEqual(
228 | // @ts-ignore
229 | await page.$$eval('h2', (el, handle) => `${el.textContent}-${handle.tagName}`, bodyJSHandle),
230 | ['hi-BODY', 'hello-BODY'],
231 | 'should evaluate function with JSHandle as arguments'
232 | )
233 |
234 | t.deepEqual(
235 | // @ts-ignore
236 | await page.$$eval('h2', (el, handle) => `${el.textContent}-${handle.tagName}`, bodyElementHandle),
237 | ['hi-BODY', 'hello-BODY'],
238 | 'should evaluate function with ElementHandle as arguments'
239 | )
240 |
241 | try {
242 | await page.$$eval('h2', () => { throw new Error('oops') })
243 | t.fail()
244 | } catch (err) {
245 | t.equal(
246 | err.message,
247 | 'Evaluation failed: oops',
248 | 'should evaluate functions that throws'
249 | )
250 | }
251 |
252 | t.deepEqual(
253 | // @ts-ignore
254 | await page.$$eval('h2', (el) => Promise.resolve(el.textContent)),
255 | ['hi', 'hello'],
256 | 'should evaluate function that returns a resolved Promise'
257 | )
258 |
259 | t.deepEqual(
260 | // @ts-ignore
261 | await page.$$eval('h2', (el, foo, bar) => Promise.resolve(`${el.textContent}-${foo}-${bar}`), 'foo', 'bar'),
262 | ['hi-foo-bar', 'hello-foo-bar'],
263 | 'should evaluate function with arguments that returns a resolved Promise'
264 | )
265 |
266 | t.deepEqual(
267 | // @ts-ignore
268 | await page.$$eval('h2', (el, handle) => Promise.resolve(`${el.textContent}-${handle.tagName}`), bodyJSHandle),
269 | ['hi-BODY', 'hello-BODY'],
270 | 'should evaluate function with JSHandle as arguments that returns a resolved Promise'
271 | )
272 |
273 | t.deepEqual(
274 | // @ts-ignore
275 | await page.$$eval('h2', (el, handle) => Promise.resolve(`${el.textContent}-${handle.tagName}`), bodyElementHandle),
276 | ['hi-BODY', 'hello-BODY'],
277 | 'should evaluate function with ElementHandle as arguments that returns a resolved Promise'
278 | )
279 |
280 | try {
281 | await page.$$eval('h2', () => Promise.reject(new Error('oops')))
282 | t.fail()
283 | } catch (err) {
284 | t.equal(
285 | err.message,
286 | 'Evaluation failed: oops',
287 | 'should evaluate functions that returns a rejected Promise'
288 | )
289 | }
290 |
291 | t.deepEqual(
292 | // @ts-ignore
293 | await page.$$eval('h1', (el) => el.textContent),
294 | [],
295 | 'should return an emptry array if nothing has been found'
296 | )
297 | }))
298 |
299 | test('Page: `bringToFront()`', testWithFirefox(async (t) => {
300 | const browser = await foxr.connect()
301 |
302 | const page1 = await browser.newPage()
303 | await page1.setContent('page1')
304 |
305 | const page2 = await browser.newPage()
306 | await page2.setContent('page2')
307 |
308 | t.equal(
309 | await page1.title(),
310 | 'page2',
311 | 'should perform actions only with current page'
312 | )
313 |
314 | await page1.bringToFront()
315 |
316 | t.equal(
317 | await page1.title(),
318 | 'page1',
319 | 'should activate page'
320 | )
321 | }))
322 |
323 | test('Page: `browser()`', testWithFirefox(async (t) => {
324 | const browser = await foxr.connect()
325 | const page = await browser.newPage()
326 |
327 | t.strictEqual(
328 | browser,
329 | page.browser(),
330 | 'should return an underlying browser instance'
331 | )
332 | }))
333 |
334 | test('Page: `close()` + `close` event', testWithFirefox(async (t) => {
335 | const browser = await foxr.connect()
336 | const page = await browser.newPage()
337 | const onCloseSpy = createSpy(() => {})
338 |
339 | page.on('close', onCloseSpy)
340 |
341 | await page.close()
342 |
343 | const pages = await browser.pages()
344 |
345 | t.equal(
346 | pages.length,
347 | 1,
348 | 'should close page'
349 | )
350 |
351 | t.deepEqual(
352 | getSpyCalls(onCloseSpy),
353 | [[]],
354 | 'should emit `close` event'
355 | )
356 | }))
357 |
358 | test('Page: `setContent()` + `content()`', testWithFirefox(async (t) => {
359 | const browser = await foxr.connect()
360 | const page = await browser.newPage()
361 | const html = 'hello
'
362 |
363 | t.equal(
364 | await page.content(),
365 | '',
366 | 'content() should return page HTML'
367 | )
368 |
369 | await page.setContent(html)
370 |
371 | t.equal(
372 | await page.content(),
373 | `${html}`,
374 | 'setContent() should set page HTML'
375 | )
376 | }))
377 |
378 | test('Page: `evaluate()`', testWithFirefox(async (t) => {
379 | const browser = await foxr.connect()
380 | const page = await browser.newPage()
381 |
382 | t.equal(
383 | await page.evaluate('2 + 2'),
384 | 4,
385 | 'should evaluate strings'
386 | )
387 |
388 | try {
389 | await page.evaluate('{ throw 123 }')
390 | t.fail()
391 | } catch (err) {
392 | t.equal(
393 | err.message,
394 | 'Evaluation failed: 123',
395 | 'should evaluate strings that throws'
396 | )
397 | }
398 |
399 | t.equal(
400 | await page.evaluate('Promise.resolve(2 + 2)'),
401 | 4,
402 | 'should evaluate resolved Promises as string'
403 | )
404 |
405 | try {
406 | await page.evaluate('Promise.reject(123)')
407 | t.fail()
408 | } catch (err) {
409 | t.equal(
410 | err.message,
411 | 'Evaluation failed: 123',
412 | 'should evaluate rejected Promises as string'
413 | )
414 | }
415 |
416 | t.equal(
417 | await page.evaluate(() => 1 + 2),
418 | 3,
419 | 'should evaluate functions without arguments'
420 | )
421 |
422 | t.equal(
423 | // @ts-ignore
424 | await page.evaluate((x, y) => { return x + y }, 1, 2),
425 | 3,
426 | 'should evaluate functions with arguments'
427 | )
428 |
429 | const bodyJSHandle = await page.evaluateHandle('document.body')
430 | const bodyElementHandle = await page.$('body')
431 |
432 | t.equal(
433 | // @ts-ignore
434 | await page.evaluate((handle) => handle.tagName, bodyJSHandle),
435 | 'BODY',
436 | 'should evaluate functions with JSHandle as arguments'
437 | )
438 |
439 | t.equal(
440 | // @ts-ignore
441 | await page.evaluate((handle) => handle.tagName, bodyElementHandle),
442 | 'BODY',
443 | 'should evaluate functions with ElementHandle as arguments'
444 | )
445 |
446 | try {
447 | await page.evaluate(() => { throw new Error('oops') })
448 | t.fail()
449 | } catch (err) {
450 | t.equal(
451 | err.message,
452 | 'Evaluation failed: oops',
453 | 'should evaluate functions that throws'
454 | )
455 | }
456 |
457 | t.equal(
458 | // @ts-ignore
459 | await page.evaluate((x, y) => Promise.resolve(x + y), 1, 2),
460 | 3,
461 | 'should evaluate functions with arguments that returns a resolved Promise'
462 | )
463 |
464 | t.equal(
465 | // @ts-ignore
466 | await page.evaluate((handle) => Promise.resolve(handle.tagName), bodyJSHandle),
467 | 'BODY',
468 | 'should evaluate functions with JSHandle as arguments that returns a resolved Promise'
469 | )
470 |
471 | t.equal(
472 | // @ts-ignore
473 | await page.evaluate((handle) => Promise.resolve(handle.tagName), bodyElementHandle),
474 | 'BODY',
475 | 'should evaluate functions with ElementHandle as arguments that returns a resolved Promise'
476 | )
477 |
478 | try {
479 | await page.evaluate(() => Promise.reject(new Error('oops')))
480 | t.fail()
481 | } catch (err) {
482 | t.equal(
483 | err.message,
484 | 'Evaluation failed: oops',
485 | 'should evaluate functions that returns a rejected Promise'
486 | )
487 | }
488 | }))
489 |
490 | test('Page: `evaluateHandle()`', testWithFirefox(async (t) => {
491 | const browser = await foxr.connect()
492 | const page = await browser.newPage()
493 |
494 | t.true(
495 | (await page.evaluateHandle('document.body')) instanceof JSHandle,
496 | 'should evaluate strings'
497 | )
498 |
499 | try {
500 | await page.evaluateHandle('{ throw 123 }')
501 | t.fail()
502 | } catch (err) {
503 | t.equal(
504 | err.message,
505 | 'Evaluation failed: 123',
506 | 'should evaluate strings that throws'
507 | )
508 | }
509 |
510 | try {
511 | await page.evaluateHandle('window._foo_')
512 | t.fail()
513 | } catch (err) {
514 | t.equal(
515 | err.message,
516 | 'Unable to get a JSHandle',
517 | 'should throw if no JSHandle has been returned from string'
518 | )
519 | }
520 |
521 | t.true(
522 | (await page.evaluateHandle('Promise.resolve(document.body)')) instanceof JSHandle,
523 | 'should evaluate resolved Promises as string'
524 | )
525 |
526 | try {
527 | await page.evaluateHandle('Promise.reject(123)')
528 | t.fail()
529 | } catch (err) {
530 | t.equal(
531 | err.message,
532 | 'Evaluation failed: 123',
533 | 'should evaluate rejected Promises as string'
534 | )
535 | }
536 |
537 | t.true(
538 | // @ts-ignore
539 | (await page.evaluateHandle(() => document.body)) instanceof JSHandle,
540 | 'should evaluate functions without arguments'
541 | )
542 |
543 | t.true(
544 | // @ts-ignore
545 | (await page.evaluateHandle((prop) => document[prop], 'body')) instanceof JSHandle,
546 | 'should evaluate functions with arguments'
547 | )
548 |
549 | const bodyJSHandle = await page.evaluateHandle('document.body')
550 | const bodyElementHandle = await page.$('body')
551 |
552 | t.true(
553 | // @ts-ignore
554 | (await page.evaluateHandle((handle) => handle, bodyJSHandle)) instanceof JSHandle,
555 | 'should evaluate functions with JSHandle as arguments'
556 | )
557 |
558 | t.true(
559 | // @ts-ignore
560 | (await page.evaluateHandle((handle) => handle, bodyElementHandle)) instanceof JSHandle,
561 | 'should evaluate functions with ElementHandle as arguments'
562 | )
563 |
564 | try {
565 | // @ts-ignore
566 | await page.evaluateHandle(() => window.__foo_)
567 | t.fail()
568 | } catch (err) {
569 | t.equal(
570 | err.message,
571 | 'Unable to get a JSHandle',
572 | 'should throw if no JSHandle has been returned from function'
573 | )
574 | }
575 |
576 | try {
577 | await page.evaluateHandle(() => { throw new Error('oops') })
578 | t.fail()
579 | } catch (err) {
580 | t.equal(
581 | err.message,
582 | 'Evaluation failed: oops',
583 | 'should evaluate functions that throws'
584 | )
585 | }
586 |
587 | t.true(
588 | // @ts-ignore
589 | (await page.evaluateHandle((prop) => Promise.resolve(document[prop]), 'body')) instanceof JSHandle,
590 | 'should evaluate functions with arguments that returns a resolved Promise'
591 | )
592 |
593 | try {
594 | // @ts-ignore
595 | await page.evaluateHandle(() => Promise.resolve(window.__foo_))
596 | t.fail()
597 | } catch (err) {
598 | t.equal(
599 | err.message,
600 | 'Unable to get a JSHandle',
601 | 'should throw if no JSHandle has been returned from function that returns a resolve Promise'
602 | )
603 | }
604 |
605 | t.true(
606 | // @ts-ignore
607 | (await page.evaluateHandle((handle) => Promise.resolve(handle), bodyJSHandle)) instanceof JSHandle,
608 | 'should evaluate functions with JSHandle arguments that returns a resolved Promise'
609 | )
610 |
611 | t.true(
612 | // @ts-ignore
613 | (await page.evaluateHandle((handle) => Promise.resolve(handle), bodyElementHandle)) instanceof JSHandle,
614 | 'should evaluate functions with ElementHandle arguments that returns a resolved Promise'
615 | )
616 |
617 | try {
618 | await page.evaluateHandle(() => Promise.reject(new Error('oops')))
619 | t.fail()
620 | } catch (err) {
621 | t.equal(
622 | err.message,
623 | 'Evaluation failed: oops',
624 | 'should evaluate functions that returns a rejected Promise'
625 | )
626 | }
627 | }))
628 |
629 | test('Page: `focus()`', testWithFirefox(async (t) => {
630 | const browser = await foxr.connect()
631 | const page = await browser.newPage()
632 |
633 | await page.setContent('')
634 |
635 | const activeElementBefore = await page.evaluate('document.activeElement.tagName')
636 |
637 | await page.focus('input')
638 |
639 | const activeElementAfter = await page.evaluate('document.activeElement.tagName')
640 |
641 | t.true(
642 | activeElementBefore !== activeElementAfter && activeElementAfter === 'INPUT',
643 | 'should focus element'
644 | )
645 |
646 | try {
647 | await page.focus('foo')
648 | t.fail()
649 | } catch (err) {
650 | t.equal(
651 | err.message,
652 | 'Evaluation failed: unable to find element',
653 | 'should throw if there is no such an element'
654 | )
655 | }
656 |
657 | try {
658 | await page.focus('svg')
659 | t.fail()
660 | } catch (err) {
661 | t.equal(
662 | err.message,
663 | 'Evaluation failed: Found element is not HTMLElement and not focusable',
664 | 'should throw if found element is not focusable'
665 | )
666 | }
667 | }))
668 |
669 | test('Page: `goto()` + `url()`', testWithFirefox(async (t) => {
670 | const browser = await foxr.connect()
671 | const page = await browser.newPage()
672 |
673 | await page.goto('data:text/html,hi')
674 |
675 | t.equal(
676 | await page.url(),
677 | 'data:text/html,hi',
678 | 'should change page url'
679 | )
680 |
681 | await page.goto('data:text/html,hello')
682 |
683 | t.equal(
684 | await page.url(),
685 | 'data:text/html,hello',
686 | 'should change page url again'
687 | )
688 | }))
689 |
690 | test('Page: `screenshot()`', testWithFirefox(async (t) => {
691 | const writeFileSpy = createSpy(({ args }) => args[args.length - 1](null))
692 |
693 | mock('../../src/', {
694 | fs: {
695 | ...fs,
696 | writeFile: writeFileSpy
697 | }
698 | })
699 |
700 | const { default: foxr } = await import('../../src/')
701 | const browser = await foxr.connect()
702 | const page = await browser.newPage()
703 |
704 | await page.setContent('hello
')
705 |
706 | const screenshot1 = await page.screenshot()
707 |
708 | t.true(
709 | Buffer.isBuffer(screenshot1) && screenshot1.length > 0,
710 | 'should return non-empty Buffer'
711 | )
712 |
713 | const screenshot2 = await page.screenshot({ path: 'test.png' })
714 | const spyArgs = getSpyCalls(writeFileSpy)[0]
715 |
716 | t.equal(
717 | spyArgs[0],
718 | 'test.png',
719 | 'path: should handle `path` option'
720 | )
721 |
722 | t.true(
723 | Buffer.isBuffer(spyArgs[1]) && spyArgs[1].length > 0,
724 | 'path: should write screenshot to file'
725 | )
726 |
727 | t.true(
728 | Buffer.isBuffer(screenshot2) && screenshot2.length > 0,
729 | 'path: should return non-empty buffer'
730 | )
731 |
732 | unmock('../../src/')
733 | }))
734 |
735 | test('Page: `viewport()`', testWithFirefox(async (t) => {
736 | const browser = await foxr.connect()
737 | const page = await browser.newPage()
738 |
739 | const result = await page.viewport()
740 |
741 | t.deepEqual(
742 | result,
743 | { width: 800, height: 600 },
744 | 'should return width and height'
745 | )
746 | }))
747 |
748 | test('Page: `title()`', testWithFirefox(async (t) => {
749 | const browser = await foxr.connect()
750 | const page = await browser.newPage()
751 |
752 | await page.setContent('hi')
753 |
754 | const title = await page.title()
755 |
756 | t.equal(
757 | title,
758 | 'hi',
759 | 'should get page title'
760 | )
761 | }))
762 | test('Page: `goback()`', testWithFirefox(async (t) => {
763 | const browser = await foxr.connect()
764 | const page = await browser.newPage()
765 |
766 | await page.goto('data:text/html,hi')
767 |
768 | t.equal(
769 | await page.url(),
770 | 'data:text/html,hi',
771 | 'should change page url'
772 | )
773 |
774 | await page.goto('data:text/html,hello')
775 |
776 | t.equal(
777 | await page.url(),
778 | 'data:text/html,hello',
779 | 'should change page url again'
780 | )
781 | await page.goBack()
782 | t.equal(
783 | await page.url(),
784 | 'data:text/html,hi',
785 | 'should go back to previous page'
786 | )
787 | }))
788 | test('Page: `goforward()`', testWithFirefox(async (t) => {
789 | const browser = await foxr.connect()
790 | const page = await browser.newPage()
791 |
792 | await page.goto('data:text/html,hi')
793 |
794 | t.equal(
795 | await page.url(),
796 | 'data:text/html,hi',
797 | 'should change page url'
798 | )
799 |
800 | await page.goto('data:text/html,hello')
801 |
802 | t.equal(
803 | await page.url(),
804 | 'data:text/html,hello',
805 | 'should change page url again'
806 | )
807 | await page.goBack()
808 | t.equal(
809 | await page.url(),
810 | 'data:text/html,hi',
811 | 'should go back to previous page'
812 | )
813 | await page.goForward()
814 | t.equal(
815 | await page.url(),
816 | 'data:text/html,hello',
817 | 'should go forward to next page'
818 | )
819 | }))
820 |
--------------------------------------------------------------------------------