├── .gitignore ├── .npmignore ├── CNAME ├── LICENSE ├── _config.yml ├── bin └── cli.js ├── commitlint.config.js ├── index.js ├── lib ├── commands │ ├── _commands.test.js │ ├── clear-screen.js │ ├── clear-screen.test.js │ ├── clear-terminal.js │ ├── clear-terminal.test.js │ ├── dump.js │ ├── dump.test.js │ ├── goto.js │ ├── goto.test.js │ └── playbill.js ├── events │ ├── browser.enter.js │ ├── client.enter.js │ ├── director.enter.js │ ├── director.exit.js │ ├── director.reset.js │ └── page.load.js ├── index.js ├── scriptwriter.js └── scriptwriter.test.js ├── package-lock.json ├── package.json └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # IDE 107 | .vscode/ 108 | 109 | # docs 110 | docs/ 111 | .notes.md -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # repo 2 | CNAME 3 | _config.yml 4 | # coverate 5 | .nyc_output 6 | coverage 7 | # IDE 8 | .vscode 9 | # local only 10 | docs 11 | .notes.md 12 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | scriptwriter.dev -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Paul Grenier 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - jekyll-relative-links 3 | relative_links: 4 | enabled: true 5 | collections: true 6 | include: 7 | - README.md 8 | # - LICENSE.md 9 | # - COPYING.md 10 | # - CODE_OF_CONDUCT.md 11 | # - CONTRIBUTING.md 12 | # - ISSUE_TEMPLATE.md 13 | # - PULL_REQUEST_TEMPLATE.md -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node --experimental-repl-await 2 | 3 | 'use strict'; 4 | const { resolve } = require('path'); 5 | const meow = require('meow'); 6 | const dlv = require('dlv'); 7 | const Scriptwriter = require('../'); 8 | const cli = meow( 9 | ` 10 | Usage 11 | $ scriptwriter [--no-headless] [--device ] [--config ] 12 | [--browser ] [--no-js] [--no-csp] 13 | Options 14 | --no-headless, --no-h Run as headless=false 15 | --device, -d Load a device profile from Playwright 16 | --config, -c Pass a config file to Scriptwriter 17 | --browser, -b Change browsers (default: chromium) 18 | --no-js Disable JavaScript 19 | --no-csp Bypass CSP 20 | --aom, -a Launch with Accessibility Object Model (AOM) enabled 21 | --user, -u Launch with a Persistent Context 22 | Examples 23 | $ scriptwriter 24 | $ scriptwriter --no-headless 25 | $ scriptwriter --device 'iPhone X' 26 | $ scriptwriter --config ./config.js 27 | $ scriptwriter -c ./config.json --no-h 28 | $ scriptwriter --no-js --b firefox 29 | `, 30 | { 31 | flags: { 32 | headless: { 33 | type: 'boolean', 34 | default: true, 35 | alias: 'h', 36 | }, 37 | device: { 38 | type: 'string', 39 | alias: 'd', 40 | }, 41 | config: { 42 | type: 'string', 43 | alias: 'c', 44 | }, 45 | browser: { 46 | type: 'string', 47 | default: 'chromium', 48 | alias: 'b', 49 | }, 50 | js: { 51 | type: 'boolean', 52 | default: true, 53 | }, 54 | csp: { 55 | type: 'boolean', 56 | default: true, 57 | }, 58 | aom: { 59 | type: 'boolean', 60 | default: false, 61 | alias: 'a', 62 | }, 63 | user: { 64 | type: 'string', 65 | default: '', 66 | alias: 'u', 67 | }, 68 | }, 69 | } 70 | ); 71 | 72 | const { config, browser, headless, csp, js, device, aom, user } = cli.flags; 73 | console.log(cli.flags); 74 | const file = config ? require(resolve(config)) : {}; 75 | const use = (path, fallback) => dlv(file, path, fallback); 76 | const normalizedConfig = { 77 | browserType: use('browserType', browser), 78 | userDataDir: use('userDataDir', user), 79 | launch: { 80 | headless: use('launch.headless', headless), 81 | args: use('launch.args', []), 82 | bypassCSP: use('launch.csp', !csp), 83 | }, 84 | context: { 85 | javaScriptEnabled: use('context.javaScriptEnabled', js), 86 | }, 87 | device: use('device', device), 88 | }; 89 | const aomFlag = '--enable-blink-features=AccessibilityObjectModel'; 90 | if (aom && !normalizedConfig.launch.args.includes(aomFlag)) { 91 | normalizedConfig.launch.args.push(aomFlag); 92 | } 93 | const scriptwriter = new Scriptwriter(normalizedConfig); 94 | scriptwriter.init(); 95 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/'); 2 | -------------------------------------------------------------------------------- /lib/commands/_commands.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const Scriptwriter = require('../scriptwriter'); 3 | 4 | test.serial('commands must bust cache on .clear', async (t) => { 5 | const scriptwriter = new Scriptwriter(); 6 | t.is(scriptwriter.replServer, null); 7 | let allReady = waitFor(scriptwriter, 'ready', ['client']); 8 | await scriptwriter.init(); 9 | await allReady; 10 | const replServer = scriptwriter.company.director; 11 | const playbill = replServer.commands.playbill.action; 12 | allReady = waitFor(scriptwriter, 'ready', ['client']); 13 | replServer.commands.clear.action.call(replServer); 14 | await allReady; 15 | t.isNot(playbill, replServer.commands.playbill.action); 16 | scriptwriter.company.director.close(); 17 | }); 18 | -------------------------------------------------------------------------------- /lib/commands/clear-screen.js: -------------------------------------------------------------------------------- 1 | exports.name = 'clearScreen'; 2 | exports.command = { 3 | help: 'Clears the screen', 4 | action() { 5 | const { scriptwriter } = this.context; 6 | scriptwriter.log(scriptwriter.escapes.clearScreen); 7 | this.displayPrompt(); 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /lib/commands/clear-screen.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const clearScreen = require('./clear-screen'); 3 | const Scriptwriter = require('../scriptwriter'); 4 | 5 | test.serial('uses the correct interface', (t) => { 6 | t.truthy(clearScreen.name); 7 | t.truthy(clearScreen.command.help); 8 | t.truthy(clearScreen.command.action); 9 | }); 10 | 11 | test.serial('action', (t) => { 12 | const scriptwriter = new Scriptwriter(); 13 | let displayPromptCalled = false; 14 | const mockReplServer = { 15 | context: { 16 | scriptwriter, 17 | }, 18 | displayPrompt() { 19 | displayPromptCalled = true; 20 | }, 21 | }; 22 | clearScreen.command.action.call(mockReplServer); 23 | t.true(displayPromptCalled); 24 | }); 25 | -------------------------------------------------------------------------------- /lib/commands/clear-terminal.js: -------------------------------------------------------------------------------- 1 | exports.name = 'clearTerminal'; 2 | exports.command = { 3 | help: 'Clears the screen and scroll buffer', 4 | action() { 5 | const { scriptwriter } = this.context; 6 | scriptwriter.log(scriptwriter.escapes.clearTerminal); 7 | this.displayPrompt(); 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /lib/commands/clear-terminal.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const clearTerminal = require('./clear-terminal'); 3 | const Scriptwriter = require('../scriptwriter'); 4 | 5 | test.serial('uses the correct interface', (t) => { 6 | t.truthy(clearTerminal.name); 7 | t.truthy(clearTerminal.command.help); 8 | t.truthy(clearTerminal.command.action); 9 | }); 10 | 11 | test.serial('action', (t) => { 12 | const scriptwriter = new Scriptwriter(); 13 | let displayPromptCalled = false; 14 | const mockReplServer = { 15 | context: { 16 | scriptwriter, 17 | }, 18 | displayPrompt() { 19 | displayPromptCalled = true; 20 | }, 21 | }; 22 | clearTerminal.command.action.call(mockReplServer); 23 | t.true(displayPromptCalled); 24 | }); 25 | -------------------------------------------------------------------------------- /lib/commands/dump.js: -------------------------------------------------------------------------------- 1 | exports.name = 'dump'; 2 | exports.command = { 3 | help: `serializes and saves an object as .json (args: space separated file and the object to dump)`, 4 | action(file_objVar) { 5 | const [file, objVar] = file_objVar.split(/\s+/); 6 | if (!file || !objVar) return; 7 | const line = `await fs.promises.writeFile("${file}.json", JSON.stringify(${objVar}, null, ' '));`; 8 | this.eval(line, this.context, '', () => { 9 | this.lines.push(line); 10 | }); 11 | this.displayPrompt(); 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /lib/commands/dump.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const dump = require('./dump'); 3 | const Scriptwriter = require('../scriptwriter'); 4 | 5 | test.serial('uses the correct interface', (t) => { 6 | t.truthy(dump.name); 7 | t.truthy(dump.command.help); 8 | t.truthy(dump.command.action); 9 | }); 10 | 11 | test.serial('action', (t) => { 12 | const scriptwriter = new Scriptwriter(); 13 | let displayPromptCalled = false; 14 | const mockReplServer = { 15 | context: { 16 | scriptwriter, 17 | }, 18 | displayPrompt() { 19 | displayPromptCalled = true; 20 | }, 21 | lines: [], 22 | eval(line, context, file, cb) { 23 | cb(); 24 | }, 25 | }; 26 | dump.command.action.call(mockReplServer, 'myobj {a:1}'); 27 | t.true(displayPromptCalled); 28 | t.deepEqual( 29 | mockReplServer.lines, 30 | [ 31 | `await fs.promises.writeFile("myobj.json", JSON.stringify({a:1}, null, ' '));`, 32 | ], 33 | 'prints the correct command in .save history' 34 | ); 35 | }); 36 | -------------------------------------------------------------------------------- /lib/commands/goto.js: -------------------------------------------------------------------------------- 1 | const normalized = require('normalize-url'); 2 | exports.name = 'goto'; 3 | exports.command = { 4 | help: 'page.goto with a normalized url', 5 | action(url) { 6 | const href = normalized(url, { forceHttps: true }); 7 | const line = `await page.goto("${href}");`; 8 | this.eval(line, this.context, '', () => { 9 | this.lines.push(line); 10 | }); 11 | this.displayPrompt(); 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /lib/commands/goto.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const goto = require('./goto'); 3 | const Scriptwriter = require('../scriptwriter'); 4 | 5 | test.serial('uses the correct interface', (t) => { 6 | t.truthy(goto.name); 7 | t.truthy(goto.command.help); 8 | t.truthy(goto.command.action); 9 | }); 10 | 11 | test.serial('action', (t) => { 12 | const scriptwriter = new Scriptwriter(); 13 | let displayPromptCalled = false; 14 | const mockReplServer = { 15 | context: { 16 | scriptwriter, 17 | }, 18 | displayPrompt() { 19 | displayPromptCalled = true; 20 | }, 21 | lines: [], 22 | eval(line, context, file, cb) { 23 | cb(); 24 | }, 25 | }; 26 | goto.command.action.call(mockReplServer, 'github.com'); 27 | t.true(displayPromptCalled); 28 | t.deepEqual( 29 | mockReplServer.lines, 30 | ['await page.goto("https://github.com");'], 31 | 'prints the correct command in .save history' 32 | ); 33 | }); 34 | -------------------------------------------------------------------------------- /lib/commands/playbill.js: -------------------------------------------------------------------------------- 1 | const link = require('terminal-link'); 2 | const PLAYBILL = 'playbill'; 3 | const command = { 4 | help: `List the scriptwriter's ${PLAYBILL}`, 5 | action() { 6 | const { scriptwriter } = this.context; 7 | const credits = Object.keys(scriptwriter.company).sort(); 8 | const maxLength = Math.max(...credits.map((c) => c.length)) + 2; 9 | scriptwriter.log( 10 | credits 11 | .map((c) => 12 | [ 13 | getLink(c), 14 | ' '.repeat(maxLength - c.length), 15 | getDescription(c), 16 | ].join('') 17 | ) 18 | .join('\n') 19 | ); 20 | this.displayPrompt(); 21 | }, 22 | }; 23 | exports.name = PLAYBILL; 24 | exports.command = command; 25 | 26 | function getLink(name) { 27 | const PLAYWRIGHT_API = 28 | 'https://github.com/microsoft/playwright/blob/v0.12.1/docs/api.md'; 29 | switch (name) { 30 | case 'page': 31 | case 'browser': 32 | return link(name, `${PLAYWRIGHT_API}#class-${name}`); 33 | case 'client': 34 | return link(name, 'https://chromedevtools.github.io/devtools-protocol/'); 35 | case 'context': 36 | return link(name, `${PLAYWRIGHT_API}#class-browser${name}`); 37 | case 'director': 38 | return link(name, 'https://nodejs.org/api/repl.html'); 39 | case 'playwright': 40 | return link(name, PLAYWRIGHT_API); 41 | case 'scriptwriter': 42 | return link(name, 'https://github.com/AutoSponge/scriptwriter#readme'); 43 | default: 44 | } 45 | } 46 | 47 | function getDescription(name) { 48 | switch (name) { 49 | case 'page': 50 | return `Playwright's Page instance for the browser's context.`; 51 | case 'browser': 52 | return `Playwright's Browser instance.`; 53 | case 'client': 54 | return `Playwright's CDPSession instance (Chrome only).`; 55 | case 'context': 56 | return `Playwright's BrowserContext instance for the browser.`; 57 | case 'director': 58 | return `Node's repl instance.`; 59 | case 'playwright': 60 | return `Playwright object.`; 61 | case 'scriptwriter': 62 | return `The instance object that controls the director.`; 63 | default: 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /lib/events/browser.enter.js: -------------------------------------------------------------------------------- 1 | const dlv = require('dlv'); 2 | module.exports = async function (scriptwriter) { 3 | const { company, config } = scriptwriter; 4 | const { browser, playwright } = company; 5 | const browserConfig = { 6 | viewport: null, 7 | ...dlv(playwright.devices, [config.device], {}), 8 | ...dlv(config, 'context', {}), 9 | }; 10 | const pages = browser.pages ? await browser.pages() : []; 11 | let page, context; 12 | if (pages.length) { 13 | [page] = pages; 14 | context = browser; 15 | await scriptwriter.assign({ context }, true); 16 | } else { 17 | page = await browser.newPage(browserConfig); 18 | [context] = await browser.contexts(); 19 | await scriptwriter.assign({ context }); 20 | } 21 | /* istanbul ignore next */ 22 | if (context.newCDPSession) { 23 | const client = await context.newCDPSession(page); 24 | await scriptwriter.assign({ client }); 25 | } 26 | await scriptwriter.assign({ page }); 27 | }; 28 | -------------------------------------------------------------------------------- /lib/events/client.enter.js: -------------------------------------------------------------------------------- 1 | module.exports = async function (scriptwriter) { 2 | const { client } = scriptwriter.company; 3 | await Promise.all([ 4 | client.send('Accessibility.enable'), 5 | client.send('Runtime.enable'), 6 | client.send('DOM.enable'), 7 | ]); 8 | scriptwriter.emit('ready', 'client'); 9 | }; 10 | -------------------------------------------------------------------------------- /lib/events/director.enter.js: -------------------------------------------------------------------------------- 1 | const importGlobal = require('import-global'); 2 | const version = importGlobal('playwright/package.json').version; 3 | const link = require('terminal-link'); 4 | module.exports = async function (scriptwriter) { 5 | const { director } = scriptwriter.company; 6 | const { magenta, green } = scriptwriter.color; 7 | if (!director.commands.playbill) { 8 | const { eraseStartLine, cursorLeft } = scriptwriter.escapes; 9 | const start = `${eraseStartLine}${cursorLeft.repeat(2)}`; 10 | const vpath = `https://github.com/microsoft/playwright/releases/tag/v${version}`; 11 | scriptwriter.log( 12 | green(`${start}Playwright version ${link(version, vpath)} loaded.`) 13 | ); 14 | scriptwriter.log(magenta(`${start}.help for help. Tab twice for hints.`)); 15 | } 16 | await scriptwriter.defineCommands(); 17 | director.displayPrompt(); 18 | }; 19 | -------------------------------------------------------------------------------- /lib/events/director.exit.js: -------------------------------------------------------------------------------- 1 | module.exports = function (scriptwriter) { 2 | const { magenta } = scriptwriter.color; 3 | scriptwriter.log(magenta('repl session ended.')); 4 | /* istanbul ignore if */ 5 | if (process.env.NODE_ENV !== 'test') process.exit(); 6 | }; 7 | -------------------------------------------------------------------------------- /lib/events/director.reset.js: -------------------------------------------------------------------------------- 1 | const handler = async function (scriptwriter) { 2 | const { magenta } = scriptwriter.color; 3 | const { browser, director } = scriptwriter.company; 4 | await browser.close(); 5 | await scriptwriter.init(); 6 | scriptwriter.log(magenta('repl session reset.')); 7 | director.setPrompt('> '); 8 | director.displayPrompt(); 9 | }; 10 | handler.once = true; 11 | 12 | module.exports = handler; 13 | -------------------------------------------------------------------------------- /lib/events/page.load.js: -------------------------------------------------------------------------------- 1 | module.exports = async function (scriptwriter) { 2 | const { green, yellow } = scriptwriter.color; 3 | const { page, director } = scriptwriter.company; 4 | const url = await page.url(); 5 | const { host } = new URL(url); 6 | const prompt = `${green(host)} ${yellow('~>')} `; 7 | director.setPrompt(prompt); 8 | director.displayPrompt(); 9 | }; 10 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./scriptwriter'); 2 | -------------------------------------------------------------------------------- /lib/scriptwriter.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events'); 2 | const repl = require('repl'); 3 | const assert = require('assert'); 4 | const dlv = require('dlv'); 5 | const color = require('kleur'); 6 | const { resolve } = require('path'); 7 | const { readdir } = require('fs').promises; 8 | const importGlobal = require('import-global'); 9 | const playwright = importGlobal('playwright'); 10 | const PrettyError = require('pretty-error'); 11 | const ansiEscapes = require('ansi-escapes'); 12 | const prettier = require('prettier'); 13 | 14 | const EVENTS_PATH = resolve(__dirname, 'events'); 15 | const COMMANDS_PATH = resolve(__dirname, 'commands'); 16 | const EVENT_ASSIGN = 'assign'; 17 | const EVENT_ENTER = 'enter'; 18 | 19 | const config = new Map(); 20 | const company = new Map(); 21 | const completions = new Set(); 22 | const prettyError = new PrettyError(); 23 | 24 | process.on('unhandledRejection', (error) => { 25 | console.log(prettyError.render(error)); 26 | }); 27 | 28 | /** 29 | * @typedef {Object} config 30 | * @property {string} browserType 31 | * @property {string} device 32 | * @property {Object} launch 33 | * @property {boolean} launch.headless 34 | * @property {string[]} launch.args 35 | * @property {Object} context 36 | * @property {boolean} context.bypassCSP 37 | * @property {boolean} context.javaScriptEnabled 38 | */ 39 | const defaultConfig = { browserType: 'chromium', launch: {} }; 40 | 41 | /** 42 | * @extends EventEmitter 43 | */ 44 | module.exports = class Scriptwriter extends EventEmitter { 45 | /** 46 | * @param {config} config 47 | */ 48 | constructor(initialConfig = defaultConfig) { 49 | super(); 50 | console.log(); 51 | const { device } = initialConfig; 52 | device && assert(playwright.devices[device], `unknown device "${device}".`); 53 | Object.entries(initialConfig).map(([k, v]) => config.set(k, v)); 54 | this.log = this.log.bind(this); 55 | this.error = this.error.bind(this); 56 | this.on(EVENT_ASSIGN, this.register.bind(this)); 57 | this.replServer = null; 58 | this.color = color; 59 | this.escapes = ansiEscapes; 60 | this.importGlobal = importGlobal; 61 | } 62 | /** 63 | * @return {Object} company 64 | */ 65 | get company() { 66 | return Object.fromEntries(company); 67 | } 68 | /** 69 | * @return {string[]} completions 70 | */ 71 | get completions() { 72 | return Array.from(completions).sort(); 73 | } 74 | /** 75 | * Adds a completion to the internal Set. 76 | * @param {string} completion 77 | */ 78 | set completion(completion) { 79 | completions.add(completion); 80 | return completion; 81 | } 82 | /** 83 | * @return {config} config 84 | */ 85 | get config() { 86 | return Object.fromEntries(config); 87 | } 88 | /** 89 | * Assigns the properties of the parameter object 90 | * to the replServer.context (and the "playbill"). 91 | * @param {Object} obj 92 | * @param {boolean} silent will not emit events 93 | */ 94 | assign(obj, silent) { 95 | Object.entries(obj).forEach(([name, value]) => { 96 | company.set(name, value); 97 | if (!silent) { 98 | this.emit(EVENT_ASSIGN, name); 99 | } 100 | const director = company.get('director'); 101 | if (director) { 102 | director.context[name] = value; 103 | } 104 | }); 105 | } 106 | /** 107 | * Subscribes relevant event handlers from ./events. 108 | * @param {string} assignment 109 | * @fires EventEmitter#enter 110 | */ 111 | async register(assignment) { 112 | const assigned = company.get(assignment); 113 | assert.ok(assigned, `${assigned} not loaded.`); 114 | this.completion = assignment; 115 | if (!assigned.emit) return; 116 | const listings = await readdir(EVENTS_PATH); 117 | for (const list of listings) { 118 | /* istanbul ignore if */ 119 | if (list.endsWith('.test.js')) continue; 120 | const [role, event] = list.replace(/\.js$/, '').split('.'); 121 | if (assignment !== role) continue; 122 | const file = resolve(EVENTS_PATH, list); 123 | delete require.cache[file]; 124 | const handler = require(file); 125 | const handle = handler.bind(null, this); 126 | assigned[handler.once ? 'once' : 'on'](event, handle); 127 | } 128 | assigned.emit(EVENT_ENTER); 129 | } 130 | /** 131 | * loads commands from folder 132 | */ 133 | async defineCommands() { 134 | const listings = await readdir(COMMANDS_PATH); 135 | for (const list of listings) { 136 | /* istanbul ignore if */ 137 | if (list.endsWith('.test.js')) continue; 138 | const file = resolve(COMMANDS_PATH, list); 139 | delete require.cache[file]; 140 | const { command, name } = require(file); 141 | this.completion = `.${name}`; 142 | this.replServer.defineCommand(name, command); 143 | } 144 | } 145 | /** 146 | * Resets company and completions. 147 | * Recycles the replServer. 148 | * Assigns the director (replServer), playwright, and browser 149 | */ 150 | async init() { 151 | company.clear(); 152 | completions.clear(); 153 | repl._builtinLibs.forEach(completions.add, completions); 154 | const completer = this.completer.bind(this); 155 | if (!this.replServer) { 156 | this.replServer = repl.start({ 157 | prompt: '> ', 158 | useColors: true, 159 | preview: true, 160 | completer, 161 | }); 162 | } 163 | Object.keys(repl.repl.commands).forEach((key) => 164 | completions.add(`.${key}`) 165 | ); 166 | const director = this.replServer; 167 | await this.assign({ director }); 168 | await this.assign({ scriptwriter: this }); 169 | const { browserType, launch, userDataDir } = this.config; 170 | let browser; 171 | if (!!userDataDir) { 172 | browser = await playwright[browserType].launchPersistentContext( 173 | userDataDir, 174 | launch 175 | ); 176 | } else { 177 | browser = await playwright[browserType].launch(launch); 178 | } 179 | await this.assign({ playwright }); 180 | await this.assign({ browser }); 181 | director.displayPrompt(); 182 | } 183 | /** 184 | * Parses the incoming repl line for expandable namespaces 185 | * known to the company. 186 | * @param {string} line 187 | */ 188 | completer(line) { 189 | let completions = this.completions; 190 | // line has a space or . in it 191 | complex: if (line.substring(1).match(/[\s\.]/)) { 192 | completions = []; 193 | const jsonPath = dlv(line.match(/\S+$/), [0]); 194 | // the last chunk of syntax started an invocation 195 | if (!jsonPath || jsonPath.match(/\(/)) break complex; 196 | const chunks = jsonPath.split('.'); 197 | const last = chunks.pop(); 198 | // there's only one token 199 | if (!chunks.length) { 200 | completions = this.completions.flatMap((c) => { 201 | return c.startsWith(last) ? `${line}${c.substring(last.length)}` : []; 202 | }); 203 | break complex; 204 | } 205 | const obj = dlv(this.replServer.context, chunks); 206 | completions = []; 207 | // not an object in scope 208 | if (!obj) break complex; 209 | completions = Reflect.ownKeys(obj) 210 | .concat(Reflect.ownKeys(Reflect.getPrototypeOf(obj) || {})) 211 | .flatMap((c) => { 212 | if (c === 'constructor') return []; 213 | if (typeof c !== 'string') return []; 214 | return [`${line.replace(/\.[^\.]*$/, '')}.${c}`]; 215 | }) 216 | .sort(); 217 | } 218 | const hits = completions.filter((c) => c.startsWith(line)); 219 | if (hits.length === 1 && hits[0] === line) return [[], line]; 220 | return [hits.length ? hits : completions, line]; 221 | } 222 | /** 223 | * @param {string} str 224 | * @param {Object} options 225 | * @returns {string} 226 | */ 227 | code(str, options = { parser: 'babel' }) { 228 | return prettier.format(str, options); 229 | } 230 | /** 231 | * @param {...any} args 232 | */ 233 | log(...args) { 234 | this.emit('log', args); 235 | if (process.env.NODE_ENV === 'test') return; 236 | /* istanbul ignore next */ 237 | console.log(...args); 238 | } 239 | /** 240 | * @param {...any} args 241 | */ 242 | error(...args) { 243 | this.emit('error', args); 244 | if (process.env.NODE_ENV === 'test') return; 245 | console.log(prettyError.render(...args)); 246 | } 247 | }; 248 | -------------------------------------------------------------------------------- /lib/scriptwriter.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const Scriptwriter = require('./scriptwriter'); 3 | const ansiEscapes = require('ansi-escapes'); 4 | const importGlobal = require('import-global'); 5 | const kleur = require('kleur'); 6 | const tempfile = require('tempfile'); 7 | 8 | test('Scriptwriter is a constructor', (t) => { 9 | t.plan(1); 10 | const scriptwriter = new Scriptwriter(); 11 | t.truthy(scriptwriter); 12 | }); 13 | 14 | test('exposes color, code, escapes', (t) => { 15 | const scriptwriter = new Scriptwriter(); 16 | t.is(scriptwriter.escapes, ansiEscapes); 17 | t.is(scriptwriter.color, kleur); 18 | t.is(scriptwriter.importGlobal, importGlobal); 19 | t.is(scriptwriter.code('var x=1').trim(), 'var x = 1;'); 20 | }); 21 | 22 | /***************************************** 23 | * all async tests must be run in serial * 24 | *****************************************/ 25 | test.serial('scriptwriter persists the replServer as director', async (t) => { 26 | const scriptwriter = new Scriptwriter(); 27 | t.is(scriptwriter.replServer, null); 28 | let allReady = waitFor(scriptwriter, 'ready', ['client']); 29 | await scriptwriter.init(); 30 | await allReady; 31 | const replServer = scriptwriter.company.director; 32 | allReady = waitFor(scriptwriter, 'ready', ['client']); 33 | replServer.resetContext(); 34 | await allReady; 35 | t.is(replServer, scriptwriter.company.director); 36 | scriptwriter.company.director.close(); 37 | }); 38 | 39 | test.serial('page.load updates the repl prompt', async (t) => { 40 | const scriptwriter = new Scriptwriter(); 41 | await scriptwriter.init(); 42 | let allReady = waitFor(scriptwriter, 'ready', ['client']); 43 | const initalPrompt = scriptwriter.company.director._prompt; 44 | t.is(initalPrompt, '> '); 45 | await allReady; 46 | const { page } = scriptwriter.company; 47 | const loaded = waitFor(page, 'load'); 48 | page.emit('load'); 49 | await loaded; 50 | t.not(scriptwriter.company.director._prompt, initalPrompt); 51 | scriptwriter.company.director.close(); 52 | }); 53 | 54 | test.serial('scriptwriter creates client for chromium', async (t) => { 55 | const scriptwriter = new Scriptwriter(); 56 | let allReady = waitFor(scriptwriter, 'ready', ['client']); 57 | await scriptwriter.init(); 58 | await allReady; 59 | t.truthy(scriptwriter.company.client); 60 | scriptwriter.company.director.close(); 61 | }); 62 | 63 | test.serial('scriptwriter creates the .playbill action', async (t) => { 64 | const scriptwriter = new Scriptwriter(); 65 | let allReady = waitFor(scriptwriter, 'ready', ['client']); 66 | await scriptwriter.init(); 67 | await allReady; 68 | const { commands } = scriptwriter.company.director; 69 | t.truthy(commands.playbill); 70 | const { director } = scriptwriter.company; 71 | const log = waitFor(scriptwriter, 'log'); 72 | director.commands.playbill.action.call(director); 73 | const [args] = await log; 74 | const [msg] = args; 75 | t.truthy( 76 | [ 77 | 'browser', 78 | 'client', 79 | 'director', 80 | 'page', 81 | 'playwright', 82 | 'scriptwriter', 83 | ].every((name) => msg.includes(name)) 84 | ); 85 | director.close(); 86 | }); 87 | 88 | test.serial('completer ', async (t) => { 89 | const scriptwriter = new Scriptwriter(); 90 | let allReady = waitFor(scriptwriter, 'ready', ['client']); 91 | await scriptwriter.init(); 92 | await allReady; 93 | t.deepEqual( 94 | scriptwriter.completer('client.o'), 95 | [['client.off', 'client.on', 'client.once'], 'client.o'], 96 | 'returns company objects' 97 | ); 98 | t.deepEqual( 99 | scriptwriter.completer('fs.writev'), 100 | [['fs.writev', 'fs.writevSync'], 'fs.writev'], 101 | 'returns native objects' 102 | ); 103 | t.deepEqual( 104 | scriptwriter.completer('await '), 105 | [[], 'await '], 106 | 'ignores general syntax' 107 | ); 108 | t.deepEqual( 109 | scriptwriter.completer('fs'), 110 | [[], 'fs'], 111 | 'ignores non-getter syntax' 112 | ); 113 | t.deepEqual( 114 | scriptwriter.completer('foo.'), 115 | [[], 'foo.'], 116 | 'ignores unknown objects' 117 | ); 118 | t.deepEqual( 119 | scriptwriter.completer('await page'), 120 | [[], 'await page'], 121 | 'can handle the last token' 122 | ); 123 | t.deepEqual( 124 | scriptwriter.completer('await page.accessibility.s'), 125 | [['await page.accessibility.snapshot'], 'await page.accessibility.s'], 126 | 'can handle nested and within a line of syntax' 127 | ); 128 | t.deepEqual( 129 | scriptwriter.completer('director.commands.play'), 130 | [['director.commands.playbill'], 'director.commands.play'], 131 | 'handles null prototype objects' 132 | ); 133 | }); 134 | 135 | test.serial('can launch a persistant context', async (t) => { 136 | const scriptwriter = new Scriptwriter({ userDataDir: tempfile() }); 137 | let allReady = waitFor(scriptwriter, 'ready', ['client']); 138 | await scriptwriter.init(); 139 | await allReady; 140 | t.truthy(scriptwriter.company.client); 141 | scriptwriter.company.director.close(); 142 | }); 143 | 144 | function waitFor(emitter, event, only) { 145 | return new Promise((resolve) => { 146 | emitter.on(event, (...args) => { 147 | if (only && JSON.stringify(only) !== JSON.stringify(args)) return; 148 | resolve(args); 149 | }); 150 | }); 151 | } 152 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scriptwriter", 3 | "version": "0.1.1", 4 | "description": "✍️ An authoring tool for playwrights.", 5 | "keywords": [ 6 | "playwright", 7 | "repl", 8 | "e2e", 9 | "testing", 10 | "Chromium", 11 | "Puppeteer" 12 | ], 13 | "engines": { 14 | "node": ">=10.15.0" 15 | }, 16 | "main": "./index.js", 17 | "bin": { 18 | "scriptwriter": "./bin/cli.js" 19 | }, 20 | "scripts": { 21 | "docs": "documentation build lib/** -f html -o docs", 22 | "lint:docs": "documentation lint ./lib/**", 23 | "lint:code": "prettier-eslint --write --single-quote --use-tabs \"**/*.{js,json,md}\"", 24 | "lint": "run-p lint:*", 25 | "start": "$npm_package_bin_scriptwriter", 26 | "test:dev": "cross-env NODE_ENV=test ava", 27 | "test:ci": "cross-env NODE_ENV=test nyc ava -t", 28 | "cover:html": "nyc report --reporter=html", 29 | "test": "run-s lint test:ci", 30 | "version": "run-s lint docs", 31 | "release": "np" 32 | }, 33 | "directories": { 34 | "bin": "./bin", 35 | "lib": "./lib" 36 | }, 37 | "husky": { 38 | "hooks": { 39 | "pre-commit": "lint-staged", 40 | "commit-msg": "commitlint -e $GIT_PARAMS" 41 | } 42 | }, 43 | "lint-staged": { 44 | "**/*.{js}": "npm run lint:docs", 45 | "**/*.{js,json,md}": "npm run lint:code" 46 | }, 47 | "repository": { 48 | "type": "git", 49 | "url": "git+https://github.com/AutoSponge/scriptwriter.git" 50 | }, 51 | "author": { 52 | "name": "Paul Grenier", 53 | "email": "pgrenier@gmail.com" 54 | }, 55 | "license": "MIT", 56 | "bugs": { 57 | "url": "https://github.com/AutoSponge/scriptwriter/issues" 58 | }, 59 | "homepage": "https://github.com/AutoSponge/scriptwriter#readme", 60 | "peerDependencies": { 61 | "playwright": ">=0.12.0" 62 | }, 63 | "dependencies": { 64 | "ansi-escapes": "^4.3.1", 65 | "dlv": "^1.1.3", 66 | "import-global": "^0.1.0", 67 | "kleur": "^4.0.0", 68 | "meow": "^8.0.0", 69 | "normalize-url": "^5.0.0", 70 | "prettier": "^2.0.2", 71 | "pretty-error": "^2.1.1", 72 | "terminal-link": "^2.1.1" 73 | }, 74 | "devDependencies": { 75 | "@commitlint/cli": "^11.0.0", 76 | "@commitlint/config-conventional": "^11.0.0", 77 | "ava": "^3.5.1", 78 | "cross-env": "^7.0.2", 79 | "husky": "^4.2.3", 80 | "lint-staged": "^10.1.1", 81 | "np": "*", 82 | "npm-run-all": "^4.1.5", 83 | "nyc": "^15.0.0", 84 | "prettier-eslint": "^11.0.0", 85 | "prettier-eslint-cli": "^5.0.0", 86 | "tempfile": "^3.0.0" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # ✍️ Scriptwriter [![npm](https://img.shields.io/npm/v/scriptwriter?color=success)](https://www.npmjs.com/package/scriptwriter) [![GitHub last commit](https://img.shields.io/github/last-commit/autosponge/scriptwriter)](https://github.com/AutoSponge/scriptwriter) 2 | 3 | Learn what [Playwright](https://github.com/microsoft/playwright) can do in each of the various browsers it supports. By default, Scriptwriter loads Chromium and creates a [Chrome Devtools Protocol (CDP) client](https://chromedevtools.github.io/devtools-protocol/) for more [repl](https://nodejs.org/api/repl.html) fun! 4 | 5 | ## Installation 6 | 7 | ![node](https://img.shields.io/node/v/scriptwriter?color=important) 8 | [![npm peer dependency version](https://img.shields.io/npm/dependency-version/scriptwriter/peer/playwright)](https://github.com/microsoft/playwright/) 9 | 10 | 1. Ensure you have node 10.15.0 or higher. 11 | 1. Install Playwright globally: `npm i -g playwright` (tested with [1.5.2](https://github.com/microsoft/playwright/releases/tag/v1.5.2)). 12 | 1. Install Scriptwriter: `npm i -g scriptwriter`. 13 | 1. (some tools that manage multiple versions of node may require you to `npm link` playwright for scriptwriter's global-import to work). 14 | 1. Or clone this repo, install, and use `npm link` or `npm start`. 15 | 16 | ## Get Started 17 | 18 | 1. `scriptwriter --no-headless` will launch the repl and Chromium. 19 | 1. Use `await` right away: `await page.goto('https://github.com')` 20 | 1. The prompt will change on load: `github.com ~>` 21 | 1. `.help` lists the global commands. 22 | 1. Pressing `Tab` twice will display autocomplete help. 23 | 1. Save and load your repl sessions! 24 | 25 | ## Config 26 | 27 | You can use cli flags to set the config `scriptwriter --help`: 28 | 29 | ``` 30 | Usage 31 | $ scriptwriter [--no-headless] [--device ] [--config ] 32 | [--browser ] [--no-js] [--no-csp] 33 | Options 34 | --no-headless, --no-h Run as headless=false 35 | --device, -d Load a device profile from Playwright 36 | --config, -c Pass a config file to Scriptwriter 37 | --browser, -b Change browsers (default: chromium) 38 | --no-js Disable JavaScript 39 | --no-csp Bypass CSP 40 | --aom, -a Launch with Accessibility Object Model (AOM) enabled 41 | --user, -u Launch with a Persistent Context 42 | Examples 43 | $ scriptwriter 44 | $ scriptwriter --no-headless 45 | $ scriptwriter --device 'iPhone X' 46 | $ scriptwriter --config ./config.js 47 | $ scriptwriter -c ./config.json --no-h 48 | $ scriptwriter --no-js --b firefox 49 | ``` 50 | 51 | ### Config File 52 | 53 | You can also load a config from a file. 54 | 55 | ```json 56 | // iphonex.json 57 | { 58 | "launch": { 59 | "headless": true, 60 | "args": ["--some-blink-specific-tag-name"] 61 | }, 62 | "context": {}, 63 | "device": "iPhone X" 64 | } 65 | ``` 66 | 67 | `scriptwriter --config iphonex.json` 68 | 69 | ### Custom Commands 70 | 71 | You can load your own commands. Scriptwriter exposes some helpful utility functions. 72 | 73 | - director = [node repl](https://nodejs.org/api/repl.html) instance 74 | - scriptwriter.code = [prettier](https://prettier.io/).format 75 | - scriptwriter.color = [kleur](https://www.npmjs.com/package/kleur) 76 | - scriptwriter.error = [pretty-error](https://www.npmjs.com/package/pretty-error) 77 | - scriptwriter.escapes = [ansi-escapes](https://www.npmjs.com/package/ansi-escapes) 78 | - scriptwriter.importGlobal = [import-global](https://www.npmjs.com/package/import-global) 79 | 80 | Example: 81 | 82 | ```js 83 | // my-command.js 84 | scriptwriter.completion = '.louder'; 85 | director.defineCommand('louder', { 86 | help: `make something louder`, 87 | async action(str) { 88 | const { log, color } = scriptwriter; 89 | log(color.red(`${str.toUpperCase()}!!`)); 90 | director.displayPrompt(); 91 | }, 92 | }); 93 | ``` 94 | 95 | ```js 96 | // in the scriptwriter repl 97 | > .load my-command.js 98 | > .louder test 99 | TEST!! 100 | ``` 101 | 102 | ### Mac Firewall 103 | 104 | On a mac, you may get the firewall popup. 105 | 106 | 1. Open keychain access. 107 | 1. In the top menu, choose `Keychain Access > Certificate Assistant > Create a Certificate`. 108 | 1. Name it `Playwright`. 109 | 1. Change the `Certificate Type` to `Code Signing`. 110 | 1. Click `create`. 111 | 1. Right click your new certificate and choose `Get Info`. 112 | 1. Open `Trust` disclosure. 113 | 1. Change `When using this certificate:` to `Always Trust`. 114 | 1. Start Scriptwriter. 115 | 1. When Chromium starts, right click the icon in the menu bar, choose `Options > Show in Finder`. 116 | 1. Right click Chromium and select `New Terminal Here`. 117 | 1. In the terminal type `pwd` and copy the path. 118 | 1. Use the following to assign the certificate: `sudo codesign -s Playwright -f --deep`. 119 | 120 | ## Similar Projects 121 | 122 | - [🐺 QA Wolf](https://www.qawolf.com/) 123 | - [Try Playwright](https://try.playwright.tech/) 124 | --------------------------------------------------------------------------------