├── 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 | 2 | 3 | 4 | 5 | 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 | [![npm](https://flat.badgen.net/npm/v/foxr)](https://www.npmjs.com/package/foxr) [![install size](https://flat.badgen.net/packagephobia/install/foxr)](https://packagephobia.now.sh/result?p=foxr) [![tests](https://flat.badgen.net/travis/deepsweet/foxr/master?label=tests)](https://travis-ci.org/deepsweet/foxr) [![coverage](https://flat.badgen.net/codecov/c/github/deepsweet/foxr/master)](https://codecov.io/github/deepsweet/foxr) 4 | 5 | Node.js API to control Firefox. 6 | 7 | logo 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 | --------------------------------------------------------------------------------