├── .editorconfig ├── .gitignore ├── .mocharc.json ├── .prettierrc.js ├── .vscode ├── launch.json └── settings.json ├── LICENSE.md ├── README.md ├── bin └── hackium ├── client └── hackium.js ├── extensions └── theme │ └── manifest.json ├── package-lock.json ├── package.json ├── pages └── homepage │ ├── hackium-logo-full-black.png │ ├── hackium-logo-full-white.png │ ├── hackium-logo.png │ ├── index.html │ └── main.css ├── src ├── arguments.ts ├── cli.ts ├── cmds │ ├── init.ts │ └── init │ │ ├── config.ts │ │ ├── injection.ts │ │ ├── interceptor.ts │ │ ├── script.ts │ │ ├── templates │ │ ├── inject.js │ │ ├── interceptor-prettify.js │ │ ├── interceptor-refactor.js │ │ ├── interceptor.js │ │ └── script.js │ │ └── util.ts ├── events.ts ├── hackium │ ├── hackium-browser-context.ts │ ├── hackium-browser.ts │ ├── hackium-input.ts │ ├── hackium-page.ts │ ├── hackium-target.ts │ └── hackium.ts ├── index.ts ├── plugins │ └── extensionbridge.ts ├── puppeteer.ts ├── strings.ts └── util │ ├── SafeMap.ts │ ├── file.ts │ ├── logger.ts │ ├── mixin.ts │ ├── movement.ts │ ├── object.ts │ ├── prettify.ts │ ├── promises.ts │ ├── random.ts │ ├── template.ts │ └── types.ts ├── test ├── _fixtures │ ├── global-var.js │ ├── injection.js │ ├── input-viewer │ │ ├── index.html │ │ ├── main.js │ │ └── serve.sh │ ├── interceptor.js │ ├── module.js │ └── script.js ├── _server_root │ ├── console.js │ ├── dynamic.js │ ├── form.html │ ├── idle.html │ ├── index.html │ └── two.html ├── cli.test.ts ├── hackium │ ├── hackium-browser-context.test.ts │ ├── hackium-browser.test.ts │ ├── hackium-input.test.ts │ ├── hackium-page.test.ts │ └── hackium.test.ts ├── headless.test.ts ├── helper.ts ├── upstream.test.ts └── util │ ├── file.test.ts │ ├── logger.test.ts │ ├── movement.test.ts │ ├── object.test.ts │ └── promises.test.ts └── tsconfig.json /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist/ 4 | extensions/theme/Cached\ Theme.pak 5 | .cache 6 | .repl_history 7 | test/_fixtures/interceptorTemp.js 8 | scratch/ -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extension": ["ts"], 3 | "spec": "test/**/*.test.ts", 4 | "require": "ts-node/register", 5 | "timeout": 6000 6 | } 7 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'all', 3 | tabWidth: 2, 4 | semi: true, 5 | singleQuote: true, 6 | printWidth: 140, 7 | }; 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "skipFiles": ["/**"], 12 | "program": "${workspaceFolder}/src/cli.ts", 13 | "outFiles": ["${workspaceFolder}/**/*.js"] 14 | }, 15 | { 16 | "name": "Debug Mocha Tests", 17 | "type": "node", 18 | "request": "attach", 19 | "port": 9229, 20 | "protocol": "inspector", 21 | "skipFiles": ["/**/*.js"], 22 | "timeout": 5000, 23 | "stopOnEntry": false 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "mochaExplorer.env": { 3 | "DEBUG": "hackium*", 4 | "MOCHA_EXPLORER_VSCODE": "true" 5 | }, 6 | "mochaExplorer.exit": true, 7 | "mochaExplorer.files": "test/**/*.test.ts", 8 | "mochaExplorer.require": "ts-node/register", 9 | "mochaExplorer.debuggerConfig": "Debug Mocha Tests", 10 | "debug.javascript.usePreview": false, 11 | "workbench.colorCustomizations": { 12 | "activityBar.activeBackground": "#510909", 13 | "activityBar.activeBorder": "#0d730d", 14 | "activityBar.background": "#510909", 15 | "activityBar.foreground": "#e7e7e7", 16 | "activityBar.inactiveForeground": "#e7e7e799", 17 | "activityBarBadge.background": "#0d730d", 18 | "activityBarBadge.foreground": "#e7e7e7", 19 | "statusBar.background": "#230404", 20 | "statusBar.foreground": "#e7e7e7", 21 | "statusBarItem.hoverBackground": "#510909", 22 | "titleBar.activeBackground": "#230404", 23 | "titleBar.activeForeground": "#e7e7e7", 24 | "titleBar.inactiveBackground": "#23040499", 25 | "titleBar.inactiveForeground": "#e7e7e799" 26 | }, 27 | "peacock.color": "#230404" 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | ISC License (ISC) 3 | 4 | Copyright 2020 Jarrod Overson 5 | 6 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 9 | 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hackium 2 | 3 | Hackium is a CLI tool, a browser, and a platform for analyzing and manipulating web sites. 4 | 5 | ## Hackium vs Puppeteer? 6 | 7 | [Puppeteer][1] is an automation framework aimed at developers who need to automate and test the web headlessly. Hackium exposes Puppeteer's automation framework to an interactive version of Chromium and extends it with features aimed at web power users. Features that Puppeteer will not adopt for compatibility or maintenance reasons may find a home in Hackium. 8 | 9 | Hackium started as Puppeteer scripts and will continue to directly rely on Puppeteer unless both project's focus diverge passed the point of code sharability. 10 | 11 | ### Core differences 12 | 13 | - Hackium exposes access to [Chrome's Extension API](https://developer.chrome.com/extensions/api_index) via [`puppeteer-extensionbridge`](https://github.com/jsoverson/puppeteer-extensionbridge). 14 | - Hackium simulates human behavior for mouse movement and keyboard events, vs moving in straight lines or typing rapidly all at once. 15 | - Hackium prioritizes intercepting and transforming responses. 16 | - Hackium includes a plugin framework to hook into the hackium lifecycle. 17 | - Hackium injects an in-page client to extend functionality to pages and the console. 18 | - Hackium is not meant to run headlessly. It can, but Chromium loses functionality when headless. 19 | - Hackium includes a REPL to test and automate a live browser session. 20 | - Puppeteer scripts can be used with Hackium with few to no changes, Hackium scripts can not be used by Puppeteer. 21 | - and a lot more. 22 | 23 | ## Status 24 | 25 | Experimental. 26 | 27 | Hackium combines many disparate – sometimes experimental – APIs into one and as such, breaking changes can come from anywhere. Rather than limit public APIs to make it easier to stay backwards compatible, Hackium exposes as much as it can. You're in control. Backwards compatbility is a priority, but please consider the reality of depending on Hackium before building anything mission-critical. 28 | 29 | ## Installation 30 | 31 | Install hackium globally with: 32 | 33 | ```bash 34 | $ npm install -g hackium 35 | ``` 36 | 37 | > NOTE: NodeJS version 12.x or higher is required in order for you to be able to use Hackium. 38 | > 39 | > Using NodeJS version 10.x you'll be able to use the `hackium init ...` functionality of the CLI, 40 | > but you won't able to run Hackium and its REPL. 41 | 42 | You can install Hackium locally but every install downloads an additional Chromium installation so local installs should be avoided unless necessary. 43 | 44 | ## Using Hackium from node 45 | 46 | Hackium can be used like [Puppeteer][1] from standard node.js scripts, e.g. 47 | 48 | ```js 49 | const { Hackium } = require('hackium'); 50 | 51 | (async function main() { 52 | const hackium = new Hackium(); 53 | const browser = await hackium.launch(); 54 | //... 55 | })(); 56 | ``` 57 | 58 | ## API 59 | 60 | Hackium extends and overrides [Puppeteer] behavior regularly and a passing understanding of how to use Puppeteer is important for developer with Hackium. If you're only wiring together plugins or running a pre-configured project, you can skip the Puppeteer docs. 61 | 62 | ### Core dependencies 63 | 64 | These projects or protocols provide valuable documentation that will help you get more out of Hackium 65 | 66 | - [puppeteer][1] - provides browser automation API 67 | - [puppeteer-extensionbridge] - provides access to the [Chrome Extension API](https://developer.chrome.com/extensions/api_index) 68 | - [puppeteer-interceptor](https://github.com/jsoverson/puppeteer-interceptor) - interception API 69 | - [Chrome Devtools API](https://chromedevtools.github.io/devtools-protocol/) 70 | 71 | ### Hackium Plugins 72 | 73 | - [hackium-plugin-preserve-native](https://github.com/jsoverson/hackium-plugin-preserve-native) - preserves native browser API objects and methods before they can be overridden. 74 | - [hackium-plugin-visiblecursor](https://github.com/jsoverson/hackium-plugin-visiblecursor) - fakes a cursor so automated mouse movements are visible. 75 | 76 | ### Related projects 77 | 78 | These are projects built with Hackium and JavaScript interception in mind, though are separate on their own. 79 | 80 | - [shift-refactor](https://github.com/jsoverson/shift-refactor) - JavaScript transformation library 81 | - [refactor-plugin-common](https://github.com/jsoverson/refactor-plugin-common) - common transformation/deobfuscation methods 82 | - [refactor-plugin-unsafe](https://github.com/jsoverson/refactor-plugin-unsafe) - experimental transformation methods 83 | - [shift-interceptor](https://github.com/jsoverson/shift-interpreter) - experimental JavaScript meta-interpreter 84 | 85 | ### `Hackium` 86 | 87 | Import the `Hackium` class and instantiate a `hackium` instance with the core options. 88 | 89 | ```js 90 | const { Hackium } = require('hackium'); 91 | 92 | const hackium = new Hackium({ 93 | plugins: [ 94 | /* ... */ 95 | ], 96 | }); 97 | ``` 98 | 99 | #### `.launch()` 100 | 101 | Like [Puppeteer], `hackium.launch()` launches a Chrome instance and returns a `HackiumBrowser` instance. Refer to Puppeteer's [Browser] section for documentation. 102 | 103 | #### `.cliBehavior()` 104 | 105 | `hackium.cliBehavior()` runs through the Hackium configuration as if it was called via the command line. This is useful when migrating from a simple `hackium.config.js` to a node.js project. 106 | 107 | Returns a browser instance. Refer to Puppeteer's [Browser] section for further documentation. 108 | 109 | Example: 110 | 111 | ```js 112 | const { Hackium } = require('hackium'); 113 | 114 | const hackium = new Hackium({ 115 | plugins: [ 116 | /* ... */ 117 | ], 118 | }); 119 | 120 | async function main() { 121 | const browser = await hackium.cliBehavior(); 122 | } 123 | ``` 124 | 125 | #### `.pause(options)` 126 | 127 | Initializes a REPL and returns a promise that resolves only when `hackium.unpause()` (or `unpause()` in the REPL) is called. Use `pause()` with async/await to troubleshoot a script or to inject manual work into an automated process. 128 | 129 | Options: 130 | 131 | - `repl` : pass `false` to disable the REPL or an object to use as the REPL context. 132 | 133 | Example: 134 | 135 | ```js 136 | const { Hackium } = require('hackium'); 137 | 138 | const hackium = new Hackium(); 139 | 140 | async function main() { 141 | const browser = await hackium.launch(); 142 | const [page] = await browser.pages(); 143 | await hackium.pause({ repl: { page } }); 144 | } 145 | ``` 146 | 147 | #### `.startRepl(context)` 148 | 149 | Initializes a REPL, adding the passed context to the REPL context. Will close an existing REPL if it is open. 150 | 151 | Example: 152 | 153 | ```js 154 | const { Hackium } = require('hackium'); 155 | 156 | const hackium = new Hackium(); 157 | 158 | async function main() { 159 | const browser = await hackium.launch(); 160 | const [page] = await browser.pages(); 161 | await hackium.startRepl({ page }); 162 | } 163 | ``` 164 | 165 | #### `.closeRepl()` 166 | 167 | Closes a manually opened REPL. 168 | 169 | ### HackiumBrowser 170 | 171 | HackiumBrowser extends Puppeteer's [Browser] and manages instrumentation of the browser. 172 | 173 | #### `.extension` 174 | 175 | Hackium Browser comes pre-configured with [puppeteer-extensionbridge] available via `browser.extension`. See [puppeteer-extensionbridge] for documentation. 176 | 177 | #### `.clearSiteData(origin)` 178 | 179 | Clears everything associated with the passed origin. 180 | 181 | ```js 182 | const { Hackium } = require('hackium'); 183 | 184 | const hackium = new Hackium(); 185 | 186 | async function main() { 187 | const browser = await hackium.launch(); 188 | await browser.clearSiteData('google.com'); 189 | } 190 | ``` 191 | 192 | ### HackiumPage 193 | 194 | HackiumPage extends Puppeteer's [Page] and manages instrumentation of each created page. 195 | 196 | #### `.connection` 197 | 198 | Each page has a Chrome DevTools Protocol session instantiated and accessible via `.connection`. See [Chrome Devtools Protocol](https://chromedevtools.github.io/devtools-protocol/) for documentation. 199 | 200 | #### `.forceCacheEnabled(boolean)` 201 | 202 | This bypasses Puppeteer's intelligent cache settings and sends a direct request to `Network.setCacheDisabled` with your argument. 203 | 204 | #### `.mouse` 205 | 206 | Hackium's mouse class overrides Puppeteer's mouse behavior to simulate human movement. This is transparent in usage but means that movement actions are not instantaneous. Refer to Puppeteer's [Mouse] section for further documentation. 207 | 208 | #### `.mouse.idle()` 209 | 210 | Generates idle mouse movement behavior like scrolling, moving, and clicking. 211 | 212 | #### `.keyboard` 213 | 214 | Like `mouse`, the Hackium keyboard class simulates human behavior by typing at a casual speed with varying intervals. Refer to Puppeteer's [Keyboard] section for documentation. 215 | 216 | ## Command line usage 217 | 218 | Open hackium with the `hackium` command and Hackium will start the bundled Chromium. 219 | 220 | ```bash 221 | $ hackium 222 | ``` 223 | 224 | ### Interceptor modules 225 | 226 | An interceptor module needs to expose two properties, `intercept` and `interceptor`. `intercept` is a list of request patterns to intercept and `interceptor` is a JavaScript function that is called on every request interception. 227 | 228 | More information on request patterns can be found at [puppeteer-interceptor] and [Chrome Devtools Protocol#Fetch.RequestPattern](https://chromedevtools.github.io/devtools-protocol/tot/Fetch/#type-RequestPattern) 229 | 230 | ### `hackium init` 231 | 232 | use `hackium init` to initialize a configuration file or common boilerplate scripts. 233 | 234 | `hackium init config` will generate a configuration file and, optionally through the wizard, boilerplate scripts. 235 | 236 | `hackium init interceptor` will provide a list of interceptor templates to generate 237 | 238 | `hackium init injection` will generate a template script to inject in the page. 239 | 240 | `hackium init script` will generate a sample Hackium script that can be executed via `hackium -e script.js` 241 | 242 | ### Default commmand options 243 | 244 | ``` 245 | Options: 246 | --version Show version number [boolean] 247 | --help Show help [boolean] 248 | --headless start hackium in headless mode [boolean] [default: false] 249 | --pwd root directory to look for support modules 250 | [default: "/Users/jsoverson/development/src/hackium"] 251 | --adblock turn on ad blocker [default: false] 252 | --url, -u starting URL 253 | --env environment variable name/value pairs (e.g. --env 254 | MYVAR=value) [array] [default: []] 255 | --inject, -E script file to inject first on every page 256 | [array] [default: []] 257 | --execute, -e hackium script to execute [array] [default: []] 258 | --interceptor, -i interceptor module that will handle intercepted responses 259 | [array] [default: []] 260 | --userDataDir, -U Chromium user data directory 261 | [string] [default: "/Users/jsoverson/.hackium/chromium"] 262 | --devtools, -d open devtools automatically on every tab 263 | [boolean] [default: true] 264 | --watch, -w watch for configuration changes [boolean] [default: false] 265 | --plugin, -p include plugin [array] [default: []] 266 | --timeout, -t set timeout for Puppeteer [default: 30000] 267 | --chromeOutput print Chrome stderr & stdout logging 268 | [boolean] [default: false] 269 | --config, -c [string] [default: ""] 270 | ``` 271 | 272 | ### Debugging 273 | 274 | Set the DEBUG environment variable with a wildcard to print debug logs to the console, e.g. 275 | 276 | ```bash 277 | $ DEBUG=hackium* hackium 278 | ``` 279 | 280 | ## Configuration 281 | 282 | Hackium looks for `hackium.json` or `hackium.config.js` files in the current directory for configuration. Hackium merges or overrides configuration from the command line arguments. See the [Arguments definition](https://github.com/jsoverson/hackium/blob/master/src/arguments.ts#L25-L42) for valid configuration options. 283 | 284 | ## REPL 285 | 286 | Hackium's REPL exposes the browser, page, and protocol instances for rapid prototyping. 287 | 288 | ### Additional REPL context: 289 | 290 | - page: active page 291 | - browser: browser instance 292 | - cdp: chrome devtools protocol connection 293 | - extension: chrome extension API bridge 294 | 295 | ## Interceptors 296 | 297 | Interceptor modules define two things, a pattern that matches against URLs and an interceptor which is passed both the request and the response and can optionally return a modified response to send to the browser. 298 | 299 | Use `hackium init interceptor` to see examples of different interceptors 300 | 301 | ## Injecting JavaScript 302 | 303 | Injecting JavaScript before any other code loads is the only way to guarantee a pristine, unadulterated environment. Injected JavaScript can take any form and will run at the start of every page load. 304 | 305 | Use `hackium init injection` to see an example of injected JavaScript that augments the in-page `hackium` client. 306 | 307 | ## Hackium Scripts 308 | 309 | Hackium scripts are normal JavaScript scripts surrounded by an async wrapper function and a context primed with variables to reduce boilerplate. Hackium launches a browser and sets the `hackium`, `browser`, and `page` values automatically so you can rapidly get running. 310 | 311 | Use `hackium init script` to generate a sample script. 312 | 313 | ## Plugin API 314 | 315 | Plugins are plain JavaScript objects with properties named after Hackium lifecycle events. See [hackium-plugin-preserve-native](https://github.com/jsoverson/hackium-plugin-preserve-native) for an example of a plugin that injects JavaScript into the page to preserve native functions. 316 | 317 | ### Lifecycle methods 318 | 319 | - `preInit` : called before a Hackium instance is initialized. Receives the `hackium` instance and the passed options. 320 | - `postInit` : called after a Hackium instance is initialized. Receives the `hackium` instance and the final options. 321 | - `preLaunch` : called before the browser is launched. Receives the `hackium` instance and the launch options. 322 | - `postLaunch` : called after the browser is launched. Receives the `hackium` instance, `browser` instance, and final launch options. 323 | - `postBrowserInit` : called after running browser initializing and instrumentation logic. Receives the `hackium` instance, `browser` instance, and final launch options. 324 | - `prePageCreate` : called before a `Page` instance is created. Receives a `browser` instance. 325 | - `postPageCreate` : called after a `Page` instance is created. Receives a `browser` instance and a `page` instance. 326 | 327 | ### Boilerplate plugin 328 | 329 | ```js 330 | let plugin: Plugin = { 331 | preInit: function (hackium, options) {}, 332 | postInit: function (hackium, options) {}, 333 | preLaunch: function (hackium, launchOptions) {}, 334 | postLaunch: function (hackium, browser, finalLaunchOptions) {}, 335 | postBrowserInit: function (hackium, browser, finalLaunchOptions) {}, 336 | prePageCreate: function (browser) {}, 337 | postPageCreate: function (browser, page) {}, 338 | }; 339 | 340 | hackium = new Hackium({ 341 | plugins: [plugin], 342 | }); 343 | ``` 344 | 345 | [1]: https://github.com/puppeteer/puppeteer/blob/v5.2.1/docs/api.md 346 | [browser]: https://github.com/puppeteer/puppeteer/blob/v5.2.1/docs/api.md#class-browser 347 | [mouse]: https://github.com/puppeteer/puppeteer/blob/v5.2.1/docs/api.md#class-mouse 348 | [keyboard]: https://github.com/puppeteer/puppeteer/blob/v5.2.1/docs/api.md#class-keyboard 349 | [page]: https://github.com/puppeteer/puppeteer/blob/v5.2.1/docs/api.md#class-Page 350 | [puppeteer-extensionbridge]: https://github.com/jsoverson/puppeteer-extensionbridge 351 | -------------------------------------------------------------------------------- /bin/hackium: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('../dist/src/cli').default(); 4 | -------------------------------------------------------------------------------- /client/hackium.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | (function (global, clientId) { 3 | function log(...args) { 4 | console.log(...args); 5 | } 6 | 7 | const eventHandlerName = '%%%clienteventhandler%%%'; 8 | 9 | const client = { 10 | version: '%%%HACKIUM_VERSION%%%', 11 | log, 12 | postMessage(name, data) { 13 | window.postMessage({ owner: clientId, name, data }); 14 | }, 15 | eventBridge: { 16 | send: (name, data) => { 17 | const handler = global[eventHandlerName]; 18 | if (!handler || typeof handler !== 'function') throw new Error(`${name} client event handler not a function`); 19 | handler({ 20 | owner: clientId, 21 | name, 22 | data, 23 | }); 24 | }, 25 | }, 26 | init() { 27 | client.postMessage('clientLoaded'); 28 | log(`loaded ${clientId} client`); 29 | }, 30 | }; 31 | 32 | if (typeof global[clientId] === 'object') { 33 | log(`merging ${clientId} client with existing configuration`); 34 | global[clientId] = Object.assign(client, global[clientId]); 35 | } else { 36 | global[clientId] = client; 37 | } 38 | 39 | window.addEventListener('message', (evt) => { 40 | if (evt.data.owner !== 'hackium') return; 41 | const handler = window[eventHandlerName]; 42 | if (handler && typeof handler === 'function') handler(evt.data); 43 | }); 44 | 45 | if (window === window.parent) client.init(); 46 | })(window, '%%%clientid%%%'); 47 | -------------------------------------------------------------------------------- /extensions/theme/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "update_url": "https://clients2.google.com/service/update2/crx", 3 | 4 | "manifest_version": 2, 5 | "version": "2", 6 | "name": "Hackium Theme", 7 | "description": "Hackium theme", 8 | 9 | "theme": { 10 | "colors": { 11 | "frame": [ 21,11,11 ], 12 | "toolbar": [ 61,31,31], 13 | "toolbar_button_icon":[0,0,0], 14 | 15 | "omnibox_text":[0,0,0], 16 | "omnibox_background": [200,200,200], 17 | 18 | "ntp_background": [ 21,11,11 ], 19 | "ntp_text": [193,153,153], 20 | 21 | "tab_text": [255,255,255], 22 | "bookmark_text": [255,255,255], 23 | 24 | "background_tab": [21,11,11], 25 | "background_tab_inactive": [0,0,255], 26 | "button_background": [0,255,0], 27 | 28 | "tab_background_text": [255,255,255], 29 | "tab_background_text_inactive": [ 255,255,255], 30 | "tab_background_text_incognito": [ 255,255,255 ], 31 | "tab_background_text_incognito_inactive": [ 255,255,255] 32 | }, 33 | 34 | "tints" : { 35 | "buttons": [ 0, 1, 0.8 ], 36 | "frame_inactive": [ -1, -1, -1], 37 | 38 | 39 | "frame_incognito": [ 0.1, 0, 0.3], 40 | "frame_incognito_inactive": [ 0.1, 0, 0.3] 41 | } 42 | } 43 | } 44 | 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hackium", 3 | "version": "2.0.1", 4 | "description": "Hackium is a browser, a CLI tool, and a framework for web power users.", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/jsoverson/hackium" 8 | }, 9 | "main": "dist/src/index.js", 10 | "types": "dist/src/index.d.ts", 11 | "scripts": { 12 | "compile": "tsc --declaration", 13 | "clean": "rimraf dist", 14 | "copyfiles": "copyfiles src/**/*.js dist", 15 | "precompile": "npm run clean && mkdir dist && npm run copyfiles", 16 | "prewatch": "npm run compile", 17 | "prepublishOnly": "npm run compile", 18 | "format": "prettier --write 'src/**/*.ts' 'test/**/*.ts'", 19 | "watch": "tsc -w", 20 | "test": "mocha -b --exit" 21 | }, 22 | "bin": { 23 | "hackium": "./bin/hackium" 24 | }, 25 | "files": [ 26 | "dist", 27 | "bin", 28 | "client", 29 | "extensions", 30 | "pages", 31 | "src", 32 | "README.md", 33 | "tsconfig.json", 34 | "LICENSE.md" 35 | ], 36 | "directories": { 37 | "dist": "./dist", 38 | "src": "./src" 39 | }, 40 | "keywords": [ 41 | "hackium", 42 | "puppeteer", 43 | "browser", 44 | "automation", 45 | "chromium", 46 | "chrome", 47 | "web pages", 48 | "shift-refactor", 49 | "reverse engineering", 50 | "hacking", 51 | "pentesting" 52 | ], 53 | "author": "Jarrod Overson ", 54 | "license": "ISC", 55 | "dependencies": { 56 | "@types/debug": "4.1.5", 57 | "@types/seedrandom": "2.4.28", 58 | "chalk": "4.0.0", 59 | "chokidar": "3.4.0", 60 | "d3-ease": "1.0.6", 61 | "debug": "4.1.1", 62 | "devtools-protocol": "0.0.777489", 63 | "find-root": "1.1.0", 64 | "import-fresh": "3.2.1", 65 | "inquirer": "7.2.0", 66 | "puppeteer": "5.2.1", 67 | "puppeteer-extensionbridge": "1.1.0", 68 | "puppeteer-interceptor": "2.1.0", 69 | "seedrandom": "3.0.5", 70 | "shift-parser": "7.0.3", 71 | "shift-printer": "1.0.1", 72 | "supports-color": "7.1.0", 73 | "yargs": "15.3.1" 74 | }, 75 | "devDependencies": { 76 | "@jsoverson/test-server": "^1.3.3", 77 | "@types/chai": "4.2.11", 78 | "@types/chai-spies": "^1.0.1", 79 | "@types/chokidar": "2.1.3", 80 | "@types/d3-ease": "1.0.9", 81 | "@types/find-root": "1.1.1", 82 | "@types/inquirer": "6.5.0", 83 | "@types/mocha": "7.0.2", 84 | "@types/node": "13.13.2", 85 | "@types/rimraf": "3.0.0", 86 | "@types/serve-handler": "6.1.0", 87 | "@types/tween.js": "18.5.1", 88 | "@types/yargs": "15.0.4", 89 | "chai": "4.2.0", 90 | "chai-spies": "^1.0.0", 91 | "copyfiles": "2.3.0", 92 | "mocha": "7.1.1", 93 | "mock-stdin": "1.0.0", 94 | "prettier": "2.0.5", 95 | "rimraf": "3.0.2", 96 | "ts-node": "8.9.0", 97 | "typescript": "3.8.3" 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /pages/homepage/hackium-logo-full-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsoverson/hackium/3b73ad7b5ed1add017fdc673119e3582e49a58ec/pages/homepage/hackium-logo-full-black.png -------------------------------------------------------------------------------- /pages/homepage/hackium-logo-full-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsoverson/hackium/3b73ad7b5ed1add017fdc673119e3582e49a58ec/pages/homepage/hackium-logo-full-white.png -------------------------------------------------------------------------------- /pages/homepage/hackium-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsoverson/hackium/3b73ad7b5ed1add017fdc673119e3582e49a58ec/pages/homepage/hackium-logo.png -------------------------------------------------------------------------------- /pages/homepage/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Hackium 8 | 26 | 27 | 28 | 29 | 30 |
31 | Hackium 32 | 40 |
41 | 42 |
43 |
44 |
45 | » REPL context:
46 | 
47 | page: active page
48 | browser: browser instance
49 | cdp: chrome devtools protocol connection
50 | extension: chrome extension API bridge
51 | 
52 | » hackium command line usage
53 | 
54 | Default command: start hackium browser & REPL
55 | 
56 | Commands:
57 |   hackium init [file]  Create boilerplate configuration/scripts
58 |   hackium              Default command: start hackium browser & REPL   [default]
59 | 
60 | Default Command Options:
61 |   --version          Show version number                                              [boolean]
62 |   --help             Show help                                                        [boolean]
63 |   --headless         start hackium in headless mode                                   [boolean] [default: false]
64 |   --pwd              root directory to look for support modules                       [default: pwd]
65 |   --adblock          turn on ad blocker                                               [default: false]
66 |   --url, -u          starting URL
67 |   --env              environment variable name/value pairs (e.g. --env MYVAR=value)   [array] [default: []]
68 |   --inject, -E       script file to inject first on every page                        [array] [default: []]
69 |   --execute, -e      hackium script to execute                                        [array] [default: []]
70 |   --interceptor, -i  interceptor module that will handle intercepted responses        [array] [default: []]
71 |   --userDataDir, -U  Chromium user data directory                                     [string] [default: "~/.hackium/chromium"]
72 |   --devtools, -d     open devtools automatically on every tab                         [boolean] [default: true]
73 |   --watch, -w        watch for configuration changes                                  [boolean] [default: false]
74 |   --timeout, -t      set timeout for Puppeteer                                        [default: 30000]
75 |   --chromeOutput     print Chrome stderr & stdout logging                             [boolean] [default: false]
76 |   --config, -c                                                                        [string] [default: ""]
77 | 
78 |   Init Options:
79 |   
80 |   hackium init                initialize a hackium config json
81 |   hackium init injection      create a sample injection file
82 |   hackium init interceptor    create a sample interception module
83 |   hackium init script         create a sample hackium script
84 | 
85 |   Debug output:
86 | 
87 |   Set the DEBUG environment variable with a wildcard to print debug logs to the console, e.g.
88 | 
89 |   $ DEBUG=hackium* hackium
90 |   
91 |         
92 |
93 |
94 | 95 | 96 | -------------------------------------------------------------------------------- /pages/homepage/main.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: Arial, sans-serif; 5 | height: 100%; 6 | color: #ddd; 7 | } 8 | 9 | body { 10 | display: flex; 11 | flex-direction: column; 12 | } 13 | 14 | header { 15 | background-color: #3D1F1F; 16 | display: flex; 17 | justify-content: space-between; 18 | align-items: center; 19 | padding: 10px; 20 | border-bottom: 1px solid #2B1616; 21 | } 22 | 23 | h1 { 24 | margin: 0; 25 | padding: 0; 26 | font-weight: normal; 27 | font-size: 1em; 28 | } 29 | 30 | header>nav>ul { 31 | list-style-type: none; 32 | margin: 0; 33 | padding: 0; 34 | } 35 | 36 | header>nav>ul>li { 37 | display: inline-block; 38 | margin: 0 2px 39 | } 40 | 41 | a { 42 | color: #ddd; 43 | text-decoration: dotted; 44 | padding: 5px; 45 | font-size: smaller; 46 | } 47 | 48 | a:hover { 49 | color: #fff; 50 | background-color: #150B0B; 51 | } 52 | 53 | main { 54 | background-color: #463838; 55 | height: 100%; 56 | display: flex; 57 | flex-direction: column; 58 | justify-content: center; 59 | align-items: center; 60 | } 61 | 62 | #output { 63 | background-color: #150B0B; 64 | height: 90%; 65 | width: 90%; 66 | border: 1px solid #605858; 67 | padding: 5px; 68 | border-radius: 6px; 69 | font-family: "Lucida Console", Monaco, monospace; 70 | overflow: auto; 71 | } -------------------------------------------------------------------------------- /src/arguments.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { Plugin } from './util/types'; 3 | 4 | export const defaultSignal = ''; 5 | 6 | export class Arguments { 7 | url?: string; 8 | adblock?: boolean; 9 | env?: string[]; 10 | config?: string; 11 | inject?: string[]; 12 | interceptor?: string[]; 13 | pwd?: string; 14 | headless?: boolean; 15 | userDataDir?: string; 16 | devtools?: boolean; 17 | watch?: boolean; 18 | execute?: string[]; 19 | plugins?: Plugin[]; 20 | chromeOutput?: boolean; 21 | timeout?: number | string; 22 | _?: any[]; 23 | } 24 | 25 | export class ArgumentsWithDefaults extends Arguments { 26 | url?: string = undefined; 27 | adblock = false; 28 | env: string[] = []; 29 | config = ''; 30 | inject: string[] = []; 31 | interceptor: string[] = []; 32 | pwd = process.cwd(); 33 | headless = false; 34 | userDataDir = path.join(process.env.HOME || process.cwd(), '.hackium', 'chromium'); 35 | timeout = 30000; 36 | devtools = false; 37 | watch = false; 38 | execute: string[] = []; 39 | plugins: Plugin[] = []; 40 | chromeOutput = false; 41 | _: string[] = []; 42 | } 43 | 44 | export function cliArgsDefinition() { 45 | const defaultArguments = new ArgumentsWithDefaults(); 46 | 47 | return { 48 | headless: { 49 | describe: 'start hackium in headless mode', 50 | boolean: true, 51 | default: defaultArguments.headless, 52 | }, 53 | pwd: { 54 | describe: 'root directory to look for support modules', 55 | default: defaultArguments.pwd, 56 | }, 57 | adblock: { 58 | describe: 'turn on ad blocker', 59 | default: defaultArguments.adblock, 60 | // demandOption: false, 61 | }, 62 | url: { 63 | alias: 'u', 64 | describe: 'starting URL', 65 | default: defaultArguments.url, 66 | // demandOption: true, 67 | }, 68 | env: { 69 | array: true, 70 | describe: 'environment variable name/value pairs (e.g. --env MYVAR=value)', 71 | default: defaultArguments.env, 72 | }, 73 | inject: { 74 | alias: 'I', 75 | array: true, 76 | describe: 'script file to inject first on every page', 77 | default: defaultArguments.inject, 78 | }, 79 | execute: { 80 | alias: 'e', 81 | array: true, 82 | describe: 'hackium script to execute', 83 | default: defaultArguments.execute, 84 | }, 85 | interceptor: { 86 | alias: 'i', 87 | array: true, 88 | describe: 'interceptor module that will handle intercepted responses', 89 | default: defaultArguments.interceptor, 90 | }, 91 | userDataDir: { 92 | alias: 'U', 93 | describe: 'Chromium user data directory', 94 | string: true, 95 | default: defaultArguments.userDataDir, 96 | }, 97 | devtools: { 98 | alias: 'd', 99 | describe: 'open devtools automatically on every tab', 100 | boolean: true, 101 | default: defaultArguments.devtools, 102 | }, 103 | watch: { 104 | alias: 'w', 105 | describe: 'watch for configuration changes', 106 | boolean: true, 107 | default: defaultArguments.watch, 108 | }, 109 | plugin: { 110 | alias: 'p', 111 | describe: 'include plugin', 112 | array: true, 113 | default: defaultArguments.plugins, 114 | }, 115 | timeout: { 116 | alias: 't', 117 | describe: 'set timeout for Puppeteer', 118 | default: defaultArguments.timeout, 119 | }, 120 | chromeOutput: { 121 | describe: 'print Chrome stderr & stdout logging', 122 | boolean: true, 123 | default: defaultArguments.chromeOutput, 124 | }, 125 | }; 126 | } 127 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import origFs from 'fs'; 2 | import path from 'path'; 3 | import { Page } from 'puppeteer/lib/cjs/puppeteer/common/Page'; 4 | import repl from 'repl'; 5 | import { Readable, Writable } from 'stream'; 6 | import { promisify } from 'util'; 7 | import { Hackium } from './'; 8 | import { Arguments, cliArgsDefinition } from './arguments'; 9 | import { HackiumBrowserEmittedEvents } from './hackium/hackium-browser'; 10 | import { resolve } from './util/file'; 11 | import Logger from './util/logger'; 12 | import { merge } from './util/object'; 13 | 14 | import yargs = require('yargs'); 15 | 16 | const log = new Logger('hackium:cli'); 17 | const exists = promisify(origFs.exists); 18 | 19 | const DEFAULT_CONFIG_NAMES = ['hackium.json', 'hackium.config.js']; 20 | 21 | export default function runCli() { 22 | const argParser = yargs 23 | .commandDir('cmds') 24 | .command( 25 | '$0', 26 | 'Default command: start hackium browser & REPL', 27 | (yargs) => { 28 | yargs.options(cliArgsDefinition()).option('config', { 29 | alias: 'c', 30 | default: '', 31 | type: 'string', 32 | }); 33 | }, 34 | (argv) => { 35 | if (argv.plugin) { 36 | argv.plugins = (argv.plugin as string[]).map((pluginPath) => { 37 | const plugin = require(path.resolve(pluginPath)); 38 | return plugin && plugin.default ? plugin.default : plugin; 39 | }); 40 | } 41 | _runCli(argv); 42 | }, 43 | ) 44 | .help(); 45 | 46 | const args = argParser.argv; 47 | log.debug('parsed command line args into : %O', args); 48 | } 49 | 50 | export interface ReplOptions { 51 | stdout?: Writable; 52 | stdin?: Readable; 53 | } 54 | 55 | export async function _runCli(cliArgs: Arguments, replOptions: ReplOptions = {}) { 56 | const configFilesToCheck = [...DEFAULT_CONFIG_NAMES]; 57 | 58 | if (cliArgs.config) configFilesToCheck.unshift(cliArgs.config); 59 | 60 | let config = undefined; 61 | 62 | for (let i = 0; i < configFilesToCheck.length; i++) { 63 | const fileName = configFilesToCheck[i]; 64 | const location = path.join(process.env.PWD || '', fileName); 65 | if (!(await exists(location))) { 66 | log.debug(`no config found at ${location}`); 67 | continue; 68 | } 69 | 70 | try { 71 | const configFromFile = require(location); 72 | log.info(`using config found at %o`, location); 73 | log.debug(configFromFile); 74 | configFromFile.pwd = path.dirname(location); 75 | log.debug(`setting pwd to config dir: ${path.dirname(location)}`); 76 | config = configFromFile; 77 | log.debug(`merged with command line arguments`); 78 | } catch (e) { 79 | log.error(`error importing configuration:`); 80 | console.log(e); 81 | } 82 | } 83 | if (!config) config = cliArgs; 84 | else config = merge(config, cliArgs); 85 | 86 | const hackium = new Hackium(config); 87 | 88 | return hackium 89 | .cliBehavior() 90 | .then(() => { 91 | log.info('Hackium launched'); 92 | }) 93 | .catch((e) => { 94 | log.error('Hackium failed during bootup and may be in an unstable state.'); 95 | log.error(e); 96 | }) 97 | .then(async () => { 98 | log.debug('starting repl'); 99 | const browser = await hackium.getBrowser(); 100 | const replInstance = repl.start({ 101 | prompt: '> ', 102 | output: replOptions.stdout || process.stdout, 103 | input: replOptions.stdin || process.stdin, 104 | }); 105 | log.debug('repl started'); 106 | if (cliArgs.pwd) { 107 | const setupHistory = promisify(replInstance.setupHistory.bind(replInstance)); 108 | const replHistoryPath = resolve(['.repl_history'], cliArgs.pwd); 109 | log.debug('saving repl history at %o', replHistoryPath); 110 | await setupHistory(replHistoryPath); 111 | } else { 112 | log.debug('pwd not set, repl history can not be saved'); 113 | } 114 | 115 | replInstance.context.hackium = hackium; 116 | replInstance.context.browser = browser; 117 | replInstance.context.extension = browser.extension; 118 | const page = (replInstance.context.page = browser.activePage); 119 | if (page) { 120 | replInstance.context.cdp = await page.target().createCDPSession(); 121 | } 122 | browser.on(HackiumBrowserEmittedEvents.ActivePageChanged, (page: Page) => { 123 | log.debug('active page changed'); 124 | replInstance.context.page = page; 125 | }); 126 | replInstance.on('exit', () => { 127 | log.debug('repl exited, closing browser'); 128 | browser.close(); 129 | }); 130 | hackium.getBrowser().on('disconnected', () => { 131 | log.debug('browser disconnected, closing repl'); 132 | replInstance.close(); 133 | }); 134 | log.debug('repl setup complete'); 135 | return { 136 | repl: replInstance, 137 | }; 138 | }); 139 | } 140 | -------------------------------------------------------------------------------- /src/cmds/init.ts: -------------------------------------------------------------------------------- 1 | // const promzard = require('promzard' 2 | import { promises as fs } from 'fs'; 3 | import init_config from './init/config'; 4 | import init_interceptor, { templates } from './init/interceptor'; 5 | import init_injection from './init/injection'; 6 | import init_script from './init/script'; 7 | import { defaultSignal } from '../arguments'; 8 | import { copyTemplate, readTemplate } from './init/util'; 9 | import { prettify } from '../util/prettify'; 10 | 11 | // These exports are necessary for yargs 12 | export const command = 'init [file]'; 13 | export const desc = 'Create boilerplate configuration/scripts'; 14 | export const builder = { 15 | file: { 16 | default: 'config', 17 | description: 'file to create', 18 | choices: ['config', 'interceptor', 'injection', 'script'], 19 | }, 20 | }; 21 | 22 | function filterDefaults(obj: object) { 23 | return Object.fromEntries(Object.entries(obj).map(([key, val]) => [key, val === defaultSignal ? undefined : val])); 24 | } 25 | 26 | function toAlmostJSON(obj: any) { 27 | if (obj === undefined) throw new Error('Can not serialize undefined'); 28 | if (obj === null) throw new Error('Can not serialize null'); 29 | const entries = Object.entries(obj) 30 | .map(([key, val]) => { 31 | return ` ${key}: ${JSON.stringify(val)},`; 32 | }) 33 | .join('\n'); 34 | return `{\n${entries}\n}`; 35 | } 36 | 37 | export const handler = async function (argv: any) { 38 | console.log(`Initializing ${argv.file}`); 39 | switch (argv.file) { 40 | case 'config': { 41 | const rawConfig = await init_config(); 42 | if (!rawConfig) throw new Error('Can not initialize with undefined configuration'); 43 | const filteredConfig = filterDefaults(rawConfig); 44 | console.log(`Writing to ./hackium.config.js`); 45 | const config = `module.exports = ${toAlmostJSON(filteredConfig)};\n`; 46 | await fs.writeFile('hackium.config.js', config, 'utf-8'); 47 | break; 48 | } 49 | case 'interceptor': { 50 | const rawConfig = await init_interceptor(); 51 | if (!rawConfig) throw new Error('Can not initialize with undefined configuration'); 52 | try { 53 | await copyTemplate(templates.get(rawConfig.type), rawConfig.name); 54 | } catch (e) { 55 | console.log('\nError: ' + e.message); 56 | } 57 | break; 58 | } 59 | case 'injection': 60 | { 61 | const rawConfig = await init_injection(); 62 | if (!rawConfig) throw new Error('Can not initialize with undefined configuration'); 63 | try { 64 | await copyTemplate('inject.js', rawConfig.name); 65 | } catch (e) { 66 | console.log('\nError: ' + e.message); 67 | } 68 | } 69 | break; 70 | case 'script': 71 | { 72 | const rawConfig = await init_script(); 73 | if (!rawConfig) throw new Error('Can not initialize with undefined configuration'); 74 | try { 75 | await copyTemplate('script.js', rawConfig.name); 76 | } catch (e) { 77 | console.log('\nError: ' + e.message); 78 | } 79 | } 80 | break; 81 | default: 82 | console.log('Error: bad init specified'); 83 | break; 84 | } 85 | }; 86 | -------------------------------------------------------------------------------- /src/cmds/init/config.ts: -------------------------------------------------------------------------------- 1 | import inquirer from 'inquirer'; 2 | import { defaultSignal } from '../../arguments'; 3 | import { copyTemplate } from './util'; 4 | 5 | export default async function initialize() { 6 | return inquirer 7 | .prompt([ 8 | { 9 | name: 'url', 10 | message: 'What URL do you want to load by default?', 11 | default: defaultSignal, 12 | }, 13 | // { 14 | // name: 'adblock', 15 | // message: "Do you want to block ads?", 16 | // default: true, 17 | // type: 'confirm' 18 | // }, 19 | { 20 | name: 'devtools', 21 | message: 'Do you want to open devtools automatically?', 22 | default: true, 23 | type: 'confirm', 24 | }, 25 | { 26 | name: 'inject', 27 | message: 'Do you want to create a blank JavaScript injection?', 28 | default: false, 29 | type: 'confirm', 30 | }, 31 | { 32 | name: 'interceptor', 33 | message: 'Do you want to add a boilerplate interceptor?', 34 | default: false, 35 | type: 'confirm', 36 | }, 37 | { 38 | name: 'execute', 39 | message: 'Do you want to add a boilerplate Hackium script?', 40 | default: false, 41 | type: 'confirm', 42 | }, 43 | { 44 | name: 'headless', 45 | message: 'Do you want to run headless?', 46 | default: false, 47 | type: 'confirm', 48 | }, 49 | ]) 50 | .then(async (answers) => { 51 | if (answers.inject) answers.inject = [await copyTemplate('inject.js')]; 52 | else answers.inject = []; 53 | if (answers.interceptor) answers.interceptor = [await copyTemplate('interceptor.js')]; 54 | else answers.interceptor = []; 55 | if (answers.execute) answers.execute = [await copyTemplate('script.js')]; 56 | else answers.execute = []; 57 | return answers; 58 | }) 59 | .catch((error) => { 60 | console.log('Init error'); 61 | console.log(error); 62 | }); 63 | } 64 | -------------------------------------------------------------------------------- /src/cmds/init/injection.ts: -------------------------------------------------------------------------------- 1 | import inquirer from 'inquirer'; 2 | 3 | export default async function initialize() { 4 | return inquirer 5 | .prompt([ 6 | { 7 | name: 'name', 8 | message: 'Filename:', 9 | default: 'injection.js', 10 | type: 'string', 11 | }, 12 | ]) 13 | .catch((error) => { 14 | console.log('Init error'); 15 | console.log(error); 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /src/cmds/init/interceptor.ts: -------------------------------------------------------------------------------- 1 | import inquirer from 'inquirer'; 2 | import { SafeMap } from '../../util/SafeMap'; 3 | 4 | export const templates = new SafeMap([ 5 | ['Basic interceptor template', 'interceptor.js'], 6 | ['Pretty printer', 'interceptor-prettify.js'], 7 | ['JavaScript transformer using shift-refactor', 'interceptor-refactor.js'], 8 | ]); 9 | 10 | export default async function initialize() { 11 | return inquirer 12 | .prompt([ 13 | { 14 | name: 'name', 15 | message: 'Filename:', 16 | default: 'interceptor.js', 17 | type: 'string', 18 | }, 19 | { 20 | name: 'type', 21 | message: 'Which template would you like to use?', 22 | default: 0, 23 | choices: ['Basic interceptor template', 'Pretty printer', 'JavaScript transformer using shift-refactor'], 24 | type: 'list', 25 | }, 26 | ]) 27 | .catch((error) => { 28 | console.log('Init error'); 29 | console.log(error); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /src/cmds/init/script.ts: -------------------------------------------------------------------------------- 1 | import inquirer from 'inquirer'; 2 | 3 | export default async function initialize() { 4 | return inquirer 5 | .prompt([ 6 | { 7 | name: 'name', 8 | message: 'Filename:', 9 | default: 'script.js', 10 | type: 'string', 11 | }, 12 | ]) 13 | .catch((error) => { 14 | console.log('Init error'); 15 | console.log(error); 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /src/cmds/init/templates/inject.js: -------------------------------------------------------------------------------- 1 | (function (global, clientName) { 2 | const hackium = clientName in global ? global[clientName] : {}; 3 | 4 | hackium.myInjection = () => { 5 | console.log('Hello world'); 6 | }; 7 | 8 | console.log('loaded my injection'); 9 | })(window, 'hackium'); 10 | -------------------------------------------------------------------------------- /src/cmds/init/templates/interceptor-prettify.js: -------------------------------------------------------------------------------- 1 | 2 | const prettier = require('prettier'); 3 | 4 | function prettify(src) { 5 | return prettier.format(src, { parser: "babel" }); 6 | } 7 | 8 | exports.intercept = [ 9 | { 10 | urlPattern: '*', 11 | resourceType: 'Script', 12 | requestStage: 'Response' 13 | } 14 | ]; 15 | 16 | exports.interceptor = async function (browser, interception, debug) { 17 | const { request, response } = interception; 18 | debug(`Prettifying ${request.url}`); 19 | response.body = prettify(response.body); 20 | return response; 21 | } 22 | -------------------------------------------------------------------------------- /src/cmds/init/templates/interceptor-refactor.js: -------------------------------------------------------------------------------- 1 | const { refactor } = require('shift-refactor'); 2 | 3 | exports.intercept = [ 4 | { 5 | urlPattern: '*', 6 | resourceType: 'Script', 7 | requestStage: 'Response', 8 | }, 9 | ]; 10 | 11 | exports.interceptor = async function (browser, interception, debug) { 12 | const { request, response } = interception; 13 | debug(`Intercepted: ${request.url}`); 14 | 15 | const $script = refactor(response.body); 16 | 17 | // replace all console.log(...) expressions with alert(...) calls 18 | // see https://jsoverson.github.io/shift-query-demo/ for an interactive query sandbox 19 | // and https://github.com/jsoverson/shift-refactor for refactor API 20 | $script(`CallExpression[callee.object.name='console'][callee.property='log']`).replace((node) => { 21 | return new Shift.CallExpression({ 22 | callee: new Shift.IdentifierExpression({ name: 'alert' }), 23 | arguments: node.arguments, 24 | }); 25 | }); 26 | 27 | response.body = $script.print(); 28 | return response; 29 | }; 30 | -------------------------------------------------------------------------------- /src/cmds/init/templates/interceptor.js: -------------------------------------------------------------------------------- 1 | 2 | exports.intercept = [ 3 | { 4 | urlPattern: '*', 5 | resourceType: 'Script', 6 | requestStage: 'Response' 7 | } 8 | ]; 9 | 10 | exports.interceptor = async function (browser, interception, debug) { 11 | const { request, response } = interception; 12 | debug(`Intercepted: ${request.url}`); 13 | response.body += `\n;console.log('(Hackium v${await browser.version()}): intercepted and modified ${request.url}');\n` 14 | return response; 15 | } 16 | -------------------------------------------------------------------------------- /src/cmds/init/templates/script.js: -------------------------------------------------------------------------------- 1 | await page.goto('https://example.com'); 2 | 3 | const newPage = await browser.newPage(); 4 | 5 | await newPage.goto('https://google.com'); 6 | 7 | await newPage.type('[aria-label=Search]', 'hackium\n'); 8 | -------------------------------------------------------------------------------- /src/cmds/init/util.ts: -------------------------------------------------------------------------------- 1 | import { promisify } from 'util'; 2 | import origFs, { promises as fs } from 'fs'; 3 | import path from 'path'; 4 | import findRoot from 'find-root'; 5 | 6 | const exists = promisify(origFs.exists); 7 | 8 | export async function safelyWrite(file: string, contents: string) { 9 | if (await exists(file)) throw new Error(`Refusing to overwrite ${file}`); 10 | return fs.writeFile(file, contents, 'utf-8'); 11 | } 12 | 13 | export function readTemplate(name: string) { 14 | return fs.readFile(path.join(findRoot(__dirname), 'src', 'cmds', 'init', 'templates', name), 'utf-8'); 15 | } 16 | 17 | export async function copyTemplate(name: string, to?: string) { 18 | await safelyWrite(to || name, await readTemplate(name)); 19 | return name; 20 | } 21 | -------------------------------------------------------------------------------- /src/events.ts: -------------------------------------------------------------------------------- 1 | export enum HACKIUM_EVENTS { 2 | BEFORE_LAUNCH = 'beforeLaunch', 3 | LAUNCH = 'launch', 4 | } 5 | 6 | export class HackiumClientEvent { 7 | name: string; 8 | payload: any; 9 | constructor(name: string, payload: any) { 10 | this.name = name; 11 | this.payload = payload; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/hackium/hackium-browser-context.ts: -------------------------------------------------------------------------------- 1 | import { BrowserContext } from 'puppeteer/lib/cjs/puppeteer/common/Browser'; 2 | import { Connection } from 'puppeteer/lib/cjs/puppeteer/common/Connection'; 3 | import Logger from '../util/logger'; 4 | import { HackiumBrowser } from './hackium-browser'; 5 | import { HackiumPage } from './hackium-page'; 6 | 7 | export class HackiumBrowserContext extends BrowserContext { 8 | log = new Logger('hackium:browser-context'); 9 | __id?: string; 10 | __browser: HackiumBrowser; 11 | constructor(connection: Connection, browser: HackiumBrowser, contextId?: string) { 12 | super(connection, browser, contextId); 13 | this.__id = contextId; 14 | this.__browser = browser; 15 | } 16 | get id() { 17 | return this.__id; 18 | } 19 | browser(): HackiumBrowser { 20 | return super.browser() as HackiumBrowser; 21 | } 22 | newPage(): Promise { 23 | return this.__browser._createPageInContext(this.id) as Promise; 24 | } 25 | async pages(): Promise { 26 | const pages = await Promise.all( 27 | this.targets() 28 | .filter((target) => target.type() === 'page') 29 | .map((target) => target.page() as Promise), 30 | ); 31 | return pages.filter((page) => !!page); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/hackium/hackium-browser.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { ChildProcess } from 'child_process'; 3 | import Protocol from 'devtools-protocol'; 4 | import findRoot from 'find-root'; 5 | import path from 'path'; 6 | import { decorateBrowser, ExtensionBridge, NullExtensionBridge } from 'puppeteer-extensionbridge'; 7 | import { Browser } from 'puppeteer/lib/cjs/puppeteer/common/Browser'; 8 | import { Connection } from 'puppeteer/lib/cjs/puppeteer/common/Connection'; 9 | import { Events } from 'puppeteer/lib/cjs/puppeteer/common/Events'; 10 | import { Viewport } from 'puppeteer/lib/cjs/puppeteer/common/PuppeteerViewport'; 11 | import { Target } from 'puppeteer/lib/cjs/puppeteer/common/Target'; 12 | import Logger from '../util/logger'; 13 | import { HackiumBrowserContext } from './hackium-browser-context'; 14 | import { HackiumPage } from './hackium-page'; 15 | import { HackiumTarget, TargetEmittedEvents } from './hackium-target'; 16 | 17 | const newTabTimeout = 500; 18 | 19 | export enum HackiumBrowserEmittedEvents { 20 | ActivePageChanged = 'activePageChanged', 21 | } 22 | 23 | export type BrowserCloseCallback = () => Promise | void; 24 | 25 | export class HackiumBrowser extends Browser { 26 | log: Logger = new Logger('hackium:browser'); 27 | activePage?: HackiumPage; 28 | connection: Connection; 29 | extension: ExtensionBridge = new NullExtensionBridge(); 30 | _targets: Map = new Map(); 31 | __defaultContext: HackiumBrowserContext; 32 | __contexts: Map = new Map(); 33 | __ignoreHTTPSErrors: boolean; 34 | __defaultViewport?: Viewport; 35 | newtab = `file://${path.join(findRoot(__dirname), 'pages', 'homepage', 'index.html')}`; 36 | 37 | constructor( 38 | connection: Connection, 39 | contextIds: string[], 40 | ignoreHTTPSErrors: boolean, 41 | defaultViewport?: Viewport, 42 | process?: ChildProcess, 43 | closeCallback?: BrowserCloseCallback, 44 | ) { 45 | super(connection, contextIds, ignoreHTTPSErrors, defaultViewport, process, closeCallback); 46 | this.connection = connection; 47 | this.__ignoreHTTPSErrors = ignoreHTTPSErrors; 48 | this.__defaultViewport = defaultViewport; 49 | this.__defaultContext = new HackiumBrowserContext(this.connection, this); 50 | this.__contexts = new Map(); 51 | for (const contextId of contextIds) this.__contexts.set(contextId, new HackiumBrowserContext(this.connection, this, contextId)); 52 | const listenerCount = this.connection.listenerCount('Target.targetCreated'); 53 | 54 | if (listenerCount === 1) { 55 | this.connection.removeAllListeners('Target.targetCreated'); 56 | this.connection.on('Target.targetCreated', this.__targetCreated.bind(this)); 57 | } else { 58 | throw new Error('Need to reimplement how to intercept target creation. Submit a PR with a reproducible test case.'); 59 | } 60 | this.log.debug('Hackium browser created'); 61 | } 62 | 63 | async initialize() { 64 | await this.waitForTarget((target: Target) => target.type() === 'page'); 65 | const [page] = await this.pages(); 66 | this.setActivePage(page); 67 | } 68 | 69 | async pages(): Promise { 70 | const contextPages = await Promise.all(this.browserContexts().map((context) => context.pages())); 71 | return contextPages.reduce((acc, x) => acc.concat(x), []); 72 | } 73 | 74 | async newPage(): Promise { 75 | return this.__defaultContext.newPage(); 76 | } 77 | 78 | browserContexts(): HackiumBrowserContext[] { 79 | return [this.__defaultContext, ...Array.from(this.__contexts.values())]; 80 | } 81 | 82 | async createIncognitoBrowserContext(): Promise { 83 | const { browserContextId } = await this.connection.send('Target.createBrowserContext'); 84 | const context = new HackiumBrowserContext(this.connection, this, browserContextId); 85 | this.__contexts.set(browserContextId, context); 86 | return context; 87 | } 88 | 89 | async _disposeContext(contextId?: string): Promise { 90 | if (contextId) { 91 | await this.connection.send('Target.disposeBrowserContext', { 92 | browserContextId: contextId, 93 | }); 94 | this.__contexts.delete(contextId); 95 | } 96 | } 97 | 98 | defaultBrowserContext(): HackiumBrowserContext { 99 | return this.__defaultContext; 100 | } 101 | 102 | async __targetCreated(event: Protocol.Target.TargetCreatedEvent): Promise { 103 | const targetInfo = event.targetInfo; 104 | const { browserContextId } = targetInfo; 105 | 106 | const context = 107 | browserContextId && this.__contexts.has(browserContextId) ? this.__contexts.get(browserContextId) : this.__defaultContext; 108 | 109 | if (!context) throw new Error('Brower context should not be null or undefined'); 110 | this.log.debug('Creating new target %o', targetInfo); 111 | const target = new HackiumTarget( 112 | targetInfo, 113 | context, 114 | () => this.connection.createSession(targetInfo), 115 | this.__ignoreHTTPSErrors, 116 | this.__defaultViewport || null, 117 | ); 118 | 119 | assert(!this._targets.has(event.targetInfo.targetId), 'Target should not exist before targetCreated'); 120 | this._targets.set(event.targetInfo.targetId, target); 121 | 122 | if (targetInfo.url === 'chrome://newtab/') { 123 | this.log.debug('New tab opened, waiting for it to navigate to custom newtab'); 124 | await new Promise((resolve, reject) => { 125 | let done = false; 126 | const changedHandler = (targetInfo: Protocol.Target.TargetInfo) => { 127 | this.log.debug('New tab target info changed %o', targetInfo); 128 | if (targetInfo.url === this.newtab) { 129 | this.log.debug('New tab navigation complete, continuing'); 130 | resolve(); 131 | target.off(TargetEmittedEvents.TargetInfoChanged, changedHandler); 132 | } 133 | }; 134 | target.on(TargetEmittedEvents.TargetInfoChanged, changedHandler); 135 | setTimeout(() => { 136 | this.log.debug(`New tab navigation timed out.`); 137 | if (!done) reject(`Timeout of ${newTabTimeout} exceeded`); 138 | target.off(TargetEmittedEvents.TargetInfoChanged, changedHandler); 139 | }, newTabTimeout); 140 | }); 141 | } 142 | 143 | if (targetInfo.type === 'page') { 144 | // page objects are lazily created, so merely accessing this will instrument the page properly. 145 | const page = await target.page(); 146 | } 147 | 148 | if (await target._initializedPromise) { 149 | this.emit(Events.Browser.TargetCreated, target); 150 | context.emit(Events.BrowserContext.TargetCreated, target); 151 | } 152 | } 153 | 154 | async maximize() { 155 | // hacky way of maximizing. --start-maximized and windowState:maximized don't work on macs. Check later. 156 | const [page] = await this.pages(); 157 | const [width, height] = (await page.evaluate('[screen.availWidth, screen.availHeight];')) as [number, number]; 158 | return this.setWindowBounds(width, height); 159 | } 160 | 161 | async setWindowBounds(width: number, height: number) { 162 | const window = (await this.connection.send('Browser.getWindowForTarget', { 163 | // @ts-ignore 164 | targetId: page._targetId, 165 | })) as { windowId: number }; 166 | return this.connection.send('Browser.setWindowBounds', { 167 | windowId: window.windowId, 168 | bounds: { top: 0, left: 0, width, height }, 169 | }); 170 | } 171 | 172 | async clearSiteData(origin: string) { 173 | await this.connection.send('Storage.clearDataForOrigin', { 174 | origin, 175 | storageTypes: 'all', 176 | }); 177 | } 178 | 179 | async setProxy(host: string, port: number) { 180 | try { 181 | if (typeof port !== 'number') throw new Error('port is not a number'); 182 | let config = { 183 | mode: 'fixed_servers', 184 | rules: { 185 | singleProxy: { 186 | scheme: 'http', 187 | host: host, 188 | port: port, 189 | }, 190 | bypassList: [], 191 | }, 192 | }; 193 | const msg = { value: config, scope: 'regular' }; 194 | this.log.debug(`sending request to change proxy`); 195 | return this.extension.send(`chrome.proxy.settings.set`, msg); 196 | } catch (err) { 197 | const setProxyError = `HackiumBrowser.setProxy: ${err.message}`; 198 | this.log.error(setProxyError); 199 | throw new Error(setProxyError); 200 | } 201 | } 202 | 203 | async clearProxy() { 204 | this.log.debug(`sending request to clear proxy`); 205 | return this.extension.send(`chrome.proxy.settings.clear`, { 206 | scope: 'regular', 207 | }); 208 | } 209 | 210 | setActivePage(page: HackiumPage) { 211 | if (!page) { 212 | this.log.debug(`tried to set active page to invalid page object.`); 213 | return; 214 | } 215 | this.log.debug(`setting active page with URL %o`, page.url()); 216 | this.activePage = page; 217 | this.emit(HackiumBrowserEmittedEvents.ActivePageChanged, page); 218 | } 219 | 220 | getActivePage() { 221 | if (!this.activePage) throw new Error('no active page in browser instance'); 222 | return this.activePage; 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/hackium/hackium-input.ts: -------------------------------------------------------------------------------- 1 | import { Keyboard, Mouse, MouseButton, MouseWheelOptions } from 'puppeteer/lib/cjs/puppeteer/common/Input'; 2 | import { ElementHandle } from 'puppeteer/lib/cjs/puppeteer/common/JSHandle'; 3 | import { Viewport } from 'puppeteer/lib/cjs/puppeteer/common/PuppeteerViewport'; 4 | import { keyDefinitions, KeyInput } from 'puppeteer/lib/cjs/puppeteer/common/USKeyboardLayout.js'; 5 | import Logger from '../util/logger'; 6 | import { SimulatedMovement, Vector } from '../util/movement'; 7 | import { waterfallMap } from '../util/promises'; 8 | import { Random } from '../util/random'; 9 | import { HackiumPage } from './hackium-page'; 10 | import { CDPSession } from 'puppeteer/lib/cjs/puppeteer/common/Connection'; 11 | 12 | export interface Point { 13 | x: number; 14 | y: number; 15 | } 16 | 17 | interface MouseOptions { 18 | button?: MouseButton; 19 | clickCount?: number; 20 | } 21 | 22 | export enum IdleMouseBehavior { 23 | MOVE, 24 | PAUSE, 25 | } 26 | 27 | export class HackiumMouse extends Mouse { 28 | log = new Logger('hackium:page:mouse'); 29 | page: HackiumPage; 30 | rng = new Random(); 31 | 32 | minDelay = 75; 33 | maxDelay = 500; 34 | minPause = 500; 35 | maxPause = 2000; 36 | 37 | __x = this.rng.int(1, 600); 38 | __y = this.rng.int(1, 600); 39 | __client: CDPSession; 40 | __button: MouseButton | 'none' = 'none'; 41 | __keyboard: Keyboard; 42 | 43 | constructor(client: CDPSession, keyboard: Keyboard, page: HackiumPage) { 44 | super(client, keyboard); 45 | this.__x = this.rng.int(1, 600); 46 | this.__y = this.rng.int(1, 600); 47 | this.page = page; 48 | this.__client = client; 49 | this.__keyboard = keyboard; 50 | } 51 | 52 | get x() { 53 | return this.__x; 54 | } 55 | 56 | get y() { 57 | return this.__y; 58 | } 59 | 60 | async moveTo(selector: string | ElementHandle) { 61 | const elementHandle = typeof selector === 'string' ? await this.page.$(selector) : selector; 62 | if (!elementHandle) throw new Error(`Can not find bounding box of ${selector}`); 63 | const box = await elementHandle.boundingBox(); 64 | if (!box) throw new Error(`${selector} has no bounding box to move to`); 65 | const x = this.rng.int(box.x, box.x + box.width); 66 | const y = this.rng.int(box.y, box.y + box.height); 67 | return this.move(x, y); 68 | } 69 | 70 | async click(x: number = this.x, y: number = this.y, options: MouseOptions & { delay?: number } = {}): Promise { 71 | if (typeof x !== 'number' || typeof y !== 'number') { 72 | this.log.error('Mouse.click: x and y must be numbers'); 73 | throw new Error('x & y must be numbers'); 74 | } 75 | if (!options.delay) options.delay = this.rng.int(this.minDelay, this.maxDelay); 76 | await this.move(x, y); 77 | return super.click(x, y, options); 78 | } 79 | 80 | async idle(pattern: IdleMouseBehavior[] = [0, 1, 0, 0, 1]) { 81 | const viewport: Viewport = this.page.viewport() || { width: 800, height: 600 }; 82 | return waterfallMap(pattern, async (movement) => { 83 | switch (movement) { 84 | case IdleMouseBehavior.MOVE: 85 | await this.move(this.rng.int(1, viewport.width), this.rng.int(1, viewport.height)); 86 | break; 87 | case IdleMouseBehavior.PAUSE: 88 | await new Promise((f) => setTimeout(f, this.rng.int(this.minPause, this.maxPause))); 89 | break; 90 | default: 91 | throw new Error(`Invalid IdleMouseMovement value ${movement}`); 92 | } 93 | }); 94 | } 95 | 96 | async down(options: MouseOptions = {}): Promise { 97 | const { button = 'left', clickCount = 1 } = options; 98 | this.__button = button; 99 | await this.__client.send('Input.dispatchMouseEvent', { 100 | type: 'mousePressed', 101 | button, 102 | x: this.__x, 103 | y: this.__y, 104 | modifiers: this.__keyboard._modifiers, 105 | clickCount, 106 | }); 107 | } 108 | 109 | async up(options: MouseOptions = {}): Promise { 110 | const { button = 'left', clickCount = 1 } = options; 111 | this.__button = 'none'; 112 | await this.__client.send('Input.dispatchMouseEvent', { 113 | type: 'mouseReleased', 114 | button, 115 | x: this.__x, 116 | y: this.__y, 117 | modifiers: this.__keyboard._modifiers, 118 | clickCount, 119 | }); 120 | } 121 | 122 | async wheel(options: MouseWheelOptions = {}): Promise { 123 | const { deltaX = 0, deltaY = 0 } = options; 124 | await this.__client.send('Input.dispatchMouseEvent', { 125 | type: 'mouseWheel', 126 | x: this.__x, 127 | y: this.__y, 128 | deltaX, 129 | deltaY, 130 | modifiers: this.__keyboard._modifiers, 131 | pointerType: 'mouse', 132 | }); 133 | } 134 | 135 | async move(x: number, y: number, options: { steps?: number; duration?: number } = {}): Promise { 136 | // steps are ignored and included for typing, duration is what matters to us. 137 | const { duration = Math.random() * 2000 } = options; 138 | 139 | const points = new SimulatedMovement(4, 2, 5).generatePath(new Vector(this.__x, this.__y), new Vector(x, y)); 140 | 141 | const moves = waterfallMap(points, ([x, y]) => 142 | this.__client 143 | .send('Input.dispatchMouseEvent', { 144 | type: 'mouseMoved', 145 | button: this.__button, 146 | x, 147 | y, 148 | modifiers: this.__keyboard._modifiers, 149 | }) 150 | .then(() => { 151 | this.__x = x; 152 | this.__y = y; 153 | }), 154 | ); 155 | 156 | await moves; 157 | } 158 | } 159 | 160 | function charIsKey(char: string): char is KeyInput { 161 | //@ts-ignore 162 | return !!keyDefinitions[char]; 163 | } 164 | 165 | export enum IdleKeyboardBehavior { 166 | PERUSE, 167 | } 168 | 169 | export class HackiumKeyboard extends Keyboard { 170 | minTypingDelay = 20; 171 | maxTypingDelay = 200; 172 | rng = new Random(); 173 | 174 | async type(text: string, options: { delay?: number } = {}): Promise { 175 | const delay = options.delay || this.maxTypingDelay; 176 | const randomDelay = () => this.rng.int(this.minTypingDelay, delay); 177 | for (const char of text) { 178 | if (charIsKey(char)) { 179 | await this.press(char, { delay: randomDelay() }); 180 | } else { 181 | if (delay) await new Promise((f) => setTimeout(f, randomDelay())); 182 | await this.sendCharacter(char); 183 | } 184 | } 185 | } 186 | 187 | async idle(behaviors: IdleKeyboardBehavior[] = Array(10).fill(IdleKeyboardBehavior.PERUSE)) { 188 | const randomDelay = () => this.rng.int(this.minTypingDelay, this.maxTypingDelay); 189 | return waterfallMap(behaviors, async (behavior) => { 190 | switch (behavior) { 191 | case IdleKeyboardBehavior.PERUSE: 192 | await new Promise((f) => setTimeout(f, randomDelay())); 193 | switch (this.rng.int(0, 6)) { 194 | case 0: 195 | await this.press('ArrowUp', { delay: randomDelay() }); 196 | break; 197 | case 1: 198 | await this.press('ArrowDown', { delay: randomDelay() }); 199 | break; 200 | case 2: 201 | await this.press('ArrowRight', { delay: randomDelay() }); 202 | break; 203 | case 3: 204 | await this.press('ArrowLeft', { delay: randomDelay() }); 205 | break; 206 | case 4: 207 | await this.press('PageUp', { delay: randomDelay() }); 208 | break; 209 | case 5: 210 | await this.press('PageDown', { delay: randomDelay() }); 211 | break; 212 | } 213 | break; 214 | default: 215 | throw new Error(`Invalid IdleKeyboardBehavior value ${behavior}`); 216 | } 217 | }); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/hackium/hackium-page.ts: -------------------------------------------------------------------------------- 1 | import DEBUG, { Debugger } from 'debug'; 2 | import Protocol from 'devtools-protocol'; 3 | import findRoot from 'find-root'; 4 | import importFresh from 'import-fresh'; 5 | import path from 'path'; 6 | import { intercept, InterceptionHandler, Interceptor } from 'puppeteer-interceptor'; 7 | import { CDPSession } from 'puppeteer/lib/cjs/puppeteer/common/Connection'; 8 | import { HTTPResponse } from 'puppeteer/lib/cjs/puppeteer/common/HTTPResponse'; 9 | import { PuppeteerLifeCycleEvent } from 'puppeteer/lib/cjs/puppeteer/common/LifecycleWatcher'; 10 | import { Page } from 'puppeteer/lib/cjs/puppeteer/common/Page'; 11 | import { Viewport } from 'puppeteer/lib/cjs/puppeteer/common/PuppeteerViewport'; 12 | import { Target } from 'puppeteer/lib/cjs/puppeteer/common/Target'; 13 | import { HackiumClientEvent } from '../events'; 14 | import { strings } from '../strings'; 15 | import { read, resolve, watch } from '../util/file'; 16 | import Logger from '../util/logger'; 17 | import { onlySettled, waterfallMap } from '../util/promises'; 18 | import { renderTemplate } from '../util/template'; 19 | import { HackiumBrowser } from './hackium-browser'; 20 | import { HackiumBrowserContext } from './hackium-browser-context'; 21 | import { HackiumKeyboard, HackiumMouse } from './hackium-input'; 22 | import { EvaluateFn, SerializableOrJSHandle } from 'puppeteer/lib/cjs/puppeteer/common/EvalTypes'; 23 | import { Plugin } from '../util/types'; 24 | import { HackiumTarget } from './hackium-target'; 25 | 26 | interface WaitForOptions { 27 | timeout?: number; 28 | waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; 29 | } 30 | 31 | export interface PageInstrumentationConfig { 32 | injectionFiles: string[]; 33 | interceptorFiles: string[]; 34 | watch: boolean; 35 | pwd: string; 36 | } 37 | 38 | export interface Interceptor { 39 | intercept: Protocol.Fetch.RequestPattern[]; 40 | interceptor: InterceptorSignature; 41 | handler?: InterceptionHandler; 42 | } 43 | 44 | type InterceptorSignature = (hackium: HackiumBrowser, evt: Interceptor.OnResponseReceivedEvent, debug: Debugger) => any; 45 | 46 | export class HackiumPage extends Page { 47 | log = new Logger('hackium:page'); 48 | connection!: CDPSession; 49 | 50 | clientLoaded = false; 51 | queuedActions: (() => void | Promise)[] = []; 52 | instrumentationConfig: PageInstrumentationConfig = { 53 | injectionFiles: [], 54 | interceptorFiles: [], 55 | watch: false, 56 | pwd: process.env.PWD || '/tmp', 57 | }; 58 | 59 | _hmouse: HackiumMouse; 60 | _hkeyboard: HackiumKeyboard; 61 | private cachedInterceptors: Interceptor[] = []; 62 | private cachedInjections: string[] = []; 63 | private defaultInjections = [path.join(findRoot(__dirname), 'client', 'hackium.js')]; 64 | 65 | constructor(client: CDPSession, target: Target, ignoreHTTPSErrors: boolean) { 66 | super(client, target, ignoreHTTPSErrors); 67 | this._hkeyboard = new HackiumKeyboard(client); 68 | this._hmouse = new HackiumMouse(client, this._hkeyboard, this); 69 | } 70 | 71 | static hijackCreate = function (config: PageInstrumentationConfig, plugins: Plugin[] = []) { 72 | Page.create = async function ( 73 | client: CDPSession, 74 | target: HackiumTarget, 75 | ignoreHTTPSErrors: boolean, 76 | defaultViewport: Viewport | null, 77 | ): Promise { 78 | const tempLogger = new Logger('hackium:page'); 79 | tempLogger.debug('running prePageCreate on %o plugins', plugins.length); 80 | plugins.forEach((plugin) => plugin.prePageCreate && plugin.prePageCreate(target.browser() as HackiumBrowser)); 81 | 82 | const page = new HackiumPage(client, target, ignoreHTTPSErrors); 83 | 84 | page.log.debug('running postPageCreate on %o plugins', plugins.length); 85 | plugins.forEach((plugin) => plugin.postPageCreate && plugin.postPageCreate(target.browser() as HackiumBrowser, page)); 86 | 87 | page.log.debug('Created page new page for target %o', target._targetId); 88 | page.instrumentationConfig = config; 89 | await page.__initialize(config); 90 | return page; 91 | }; 92 | }; 93 | 94 | private async __initialize(config: PageInstrumentationConfig) { 95 | //@ts-ignore #private-fields 96 | await super._initialize(); 97 | if (this.cachedInterceptors.length === 0) this.loadInterceptors(); 98 | try { 99 | await this.instrumentSelf(config); 100 | } catch (e) { 101 | if (e.message && e.message.match(/Protocol error.*Target closed/)) { 102 | this.log.debug( 103 | 'Error: Page instrumentation failed: communication could not be estalished due to a protocol error.\n' + 104 | '-- This is likely because the page has already been closed. It is probably safe to ignore this error if you do not observe any problems in casual usage.', 105 | ); 106 | } else { 107 | throw e; 108 | } 109 | } 110 | } 111 | 112 | executeOrQueue(action: () => void | Promise) { 113 | if (this.clientLoaded) { 114 | return action; 115 | } else { 116 | this.queuedActions.push(action); 117 | } 118 | } 119 | 120 | evaluateNowAndOnNewDocument(fn: EvaluateFn | string, ...args: SerializableOrJSHandle[]): Promise { 121 | return Promise.all([this.evaluate(fn, ...args), this.evaluateOnNewDocument(fn, ...args)]) 122 | .catch((e) => { 123 | this.log.debug(e); 124 | }) 125 | .then((_) => {}); 126 | } 127 | 128 | browser(): HackiumBrowser { 129 | return super.browser() as HackiumBrowser; 130 | } 131 | 132 | browserContext(): HackiumBrowserContext { 133 | return super.browserContext() as HackiumBrowserContext; 134 | } 135 | 136 | get mouse(): HackiumMouse { 137 | return this._hmouse; 138 | } 139 | 140 | get keyboard(): HackiumKeyboard { 141 | return this._hkeyboard; 142 | } 143 | 144 | async forceCacheEnabled(enabled = true) { 145 | //@ts-ignore #private-fields 146 | await this._frameManager.networkManager()._client.send('Network.setCacheDisabled', { 147 | cacheDisabled: !enabled, 148 | }); 149 | } 150 | 151 | private async instrumentSelf(config: PageInstrumentationConfig = this.instrumentationConfig) { 152 | this.instrumentationConfig = config; 153 | this.log.debug(`instrumenting page %o with config %o`, this.url(), config); 154 | 155 | this.connection = await this.target().createCDPSession(); 156 | 157 | await this.exposeFunction(strings.get('clienteventhandler'), (data: any) => { 158 | const name = data.name; 159 | this.log.debug(`Received event '%o' from client with data %o`, name, data); 160 | this.emit(`hackiumclient:${name}`, new HackiumClientEvent(name, data)); 161 | }); 162 | 163 | this.on('hackiumclient:onClientLoaded', (e: HackiumClientEvent) => { 164 | this.clientLoaded = true; 165 | this.log.debug(`client loaded, running %o queued actions`, this.queuedActions.length); 166 | waterfallMap(this.queuedActions, async (action: () => void | Promise, i: number) => { 167 | return await action(); 168 | }); 169 | }); 170 | 171 | this.on('hackiumclient:pageActivated', (e: HackiumClientEvent) => { 172 | this.browser().setActivePage(this); 173 | }); 174 | 175 | if (this.instrumentationConfig.injectionFiles) { 176 | await this.loadInjections(); 177 | } 178 | 179 | this.log.debug(`adding %o scripts to evaluate on every load`, this.cachedInjections.length); 180 | for (let i = 0; i < this.cachedInjections.length; i++) { 181 | await this.evaluateNowAndOnNewDocument(this.cachedInjections[i]); 182 | } 183 | 184 | this.registerInterceptionRequests(this.cachedInterceptors); 185 | } 186 | 187 | private registerInterceptionRequests(interceptors: Interceptor[]) { 188 | const browser = this.browserContext().browser(); 189 | interceptors.forEach(async (interceptor) => { 190 | if (interceptor.handler) { 191 | this.log.debug('skipped re-registering interception handler for %o', interceptor.intercept); 192 | return; 193 | } 194 | this.log.debug(`Registering interceptor for pattern %o`, interceptor.intercept); 195 | try { 196 | const handler = await intercept(this, interceptor.intercept, { 197 | onResponseReceived: (evt: Interceptor.OnResponseReceivedEvent) => { 198 | this.log.debug(`Intercepted response for URL %o`, evt.request.url); 199 | let response = evt.response; 200 | if (response) evt.response = response; 201 | return interceptor.interceptor(browser, evt, DEBUG('hackium:interceptor')); 202 | }, 203 | }); 204 | interceptor.handler = handler; 205 | } catch (e) { 206 | this.log.debug('could not register interceptor for pattern(s) %o', interceptor.intercept); 207 | this.log.warn('Interceptor failed to initialize for target. This may be fixable by trying in a new tab.'); 208 | this.log.warn(e); 209 | } 210 | }); 211 | } 212 | 213 | private loadInterceptors() { 214 | this.cachedInterceptors = []; 215 | this.log.debug(`loading: %o interceptor modules`, this.instrumentationConfig.interceptorFiles.length); 216 | this.instrumentationConfig.interceptorFiles.forEach((modulePath) => { 217 | try { 218 | const interceptorPath = resolve([modulePath], this.instrumentationConfig.pwd); 219 | const interceptor = importFresh(interceptorPath) as Interceptor; 220 | if (this.instrumentationConfig.watch) { 221 | watch(interceptorPath, (file: string) => { 222 | this.log.debug('interceptor modified, disabling '); 223 | if (interceptor.handler) interceptor.handler.disable(); 224 | const reloadedInterceptor = importFresh(interceptorPath) as Interceptor; 225 | this.addInterceptor(reloadedInterceptor); 226 | }); 227 | } 228 | this.log.debug(`Reading interceptor module from %o`, interceptorPath); 229 | this.cachedInterceptors.push(interceptor); 230 | } catch (e) { 231 | this.log.warn(`Could not load interceptor: %o`, e.message); 232 | } 233 | }); 234 | } 235 | 236 | private async loadInjections() { 237 | this.cachedInjections = []; 238 | const files = this.defaultInjections.concat(this.instrumentationConfig.injectionFiles); 239 | this.log.debug( 240 | `loading: %o modules to inject before page load (%o default, %o user) `, 241 | files.length, 242 | this.defaultInjections.length, 243 | this.instrumentationConfig.injectionFiles.length, 244 | ); 245 | const injections = await onlySettled( 246 | files.map((f) => { 247 | const location = resolve([f], this.instrumentationConfig.pwd); 248 | this.log.debug(`reading %o (originally %o)`, location, f); 249 | return read(location).then(renderTemplate); 250 | }), 251 | ); 252 | this.log.debug(`successfully read %o files`, injections.length); 253 | this.cachedInjections = injections; 254 | return injections; 255 | } 256 | 257 | addInterceptor(interceptor: Interceptor) { 258 | this.log.debug('adding interceptor for pattern %o', interceptor.intercept); 259 | this.cachedInterceptors.push(interceptor); 260 | this.registerInterceptionRequests([interceptor]); 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /src/hackium/hackium-target.ts: -------------------------------------------------------------------------------- 1 | import Protocol from 'devtools-protocol'; 2 | import { CDPSession } from 'puppeteer/lib/cjs/puppeteer/common/Connection'; 3 | import { EventEmitter } from 'puppeteer/lib/cjs/puppeteer/common/EventEmitter'; 4 | import { Viewport } from 'puppeteer/lib/cjs/puppeteer/common/PuppeteerViewport'; 5 | import { Target } from 'puppeteer/lib/cjs/puppeteer/common/Target'; 6 | import Logger from '../util/logger'; 7 | import { mixin } from '../util/mixin'; 8 | import { HackiumBrowserContext } from './hackium-browser-context'; 9 | import { HackiumPage } from './hackium-page'; 10 | import { HackiumBrowser } from './hackium-browser'; 11 | 12 | export interface HackiumTarget extends Target, EventEmitter {} 13 | 14 | export const enum TargetEmittedEvents { 15 | TargetInfoChanged = 'targetInfoChanged', 16 | } 17 | 18 | export class HackiumTarget extends Target { 19 | log = new Logger('hackium:target'); 20 | 21 | constructor( 22 | targetInfo: Protocol.Target.TargetInfo, 23 | browserContext: HackiumBrowserContext, 24 | sessionFactory: () => Promise, 25 | ignoreHTTPSErrors: boolean, 26 | defaultViewport: Viewport | null, 27 | ) { 28 | super(targetInfo, browserContext, sessionFactory, ignoreHTTPSErrors, defaultViewport); 29 | mixin(this, new EventEmitter()); 30 | this.log.debug('Constructed new target'); 31 | } 32 | 33 | page(): Promise { 34 | return super.page() as Promise; 35 | } 36 | 37 | _targetInfoChanged(targetInfo: Protocol.Target.TargetInfo): void { 38 | super._targetInfoChanged(targetInfo); 39 | this.emit(TargetEmittedEvents.TargetInfoChanged, targetInfo); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/hackium/hackium.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcess } from 'child_process'; 2 | import { EventEmitter } from 'events'; 3 | import findRoot from 'find-root'; 4 | import { createRequire } from 'module'; 5 | import path from 'path'; 6 | import { hackiumExtensionBridge } from '../plugins/extensionbridge'; 7 | import { Browser } from 'puppeteer/lib/cjs/puppeteer/common/Browser'; 8 | import { Connection } from 'puppeteer/lib/cjs/puppeteer/common/Connection'; 9 | import { Viewport } from 'puppeteer/lib/cjs/puppeteer/common/PuppeteerViewport'; 10 | import { LaunchOptions } from 'puppeteer/lib/cjs/puppeteer/node/LaunchOptions'; 11 | import vm from 'vm'; 12 | import { Arguments, ArgumentsWithDefaults, cliArgsDefinition } from '../arguments'; 13 | import puppeteer from '../puppeteer'; 14 | import { read, resolve } from '../util/file'; 15 | import Logger from '../util/logger'; 16 | import { waterfallMap } from '../util/promises'; 17 | import { PuppeteerLaunchOptions } from '../util/types'; 18 | import { BrowserCloseCallback, HackiumBrowser } from './hackium-browser'; 19 | import { HackiumPage } from './hackium-page'; 20 | import repl, { REPLServer } from 'repl'; 21 | import { Readable, Writable } from 'stream'; 22 | import { merge } from '../util/object'; 23 | 24 | const ENVIRONMENT = ['GOOGLE_API_KEY=no', 'GOOGLE_DEFAULT_CLIENT_ID=no', 'GOOGLE_DEFAULT_CLIENT_SECRET=no']; 25 | 26 | function setEnv(env: string[] = []) { 27 | env.forEach((e) => { 28 | const [key, val] = e.split('='); 29 | process.env[key] = val; 30 | }); 31 | } 32 | 33 | Browser.create = async function ( 34 | connection: Connection, 35 | contextIds: string[], 36 | ignoreHTTPSErrors: boolean, 37 | defaultViewport?: Viewport, 38 | process?: ChildProcess, 39 | closeCallback?: BrowserCloseCallback, 40 | ): Promise { 41 | const browser = new HackiumBrowser(connection, contextIds, ignoreHTTPSErrors, defaultViewport, process, closeCallback); 42 | await connection.send('Target.setDiscoverTargets', { discover: true }); 43 | return browser; 44 | }; 45 | 46 | export class Hackium extends EventEmitter { 47 | browser?: HackiumBrowser; 48 | log = new Logger('hackium'); 49 | version = require(path.join(findRoot(__dirname), 'package.json')).version; 50 | repl?: REPLServer; 51 | 52 | config: ArgumentsWithDefaults = new ArgumentsWithDefaults(); 53 | 54 | private unpauseCallback?: Function; 55 | private defaultChromiumArgs: string[] = [ 56 | '--disable-infobars', 57 | '--no-default-browser-check', 58 | `--homepage=file://${path.join(findRoot(__dirname), 'pages', 'homepage', 'index.html')}`, 59 | `file://${path.join(findRoot(__dirname), 'pages', 'homepage', 'index.html')}`, 60 | ]; 61 | 62 | private launchOptions: PuppeteerLaunchOptions = { 63 | ignoreDefaultArgs: ['--enable-automation'], 64 | }; 65 | 66 | constructor(config?: Arguments) { 67 | super(); 68 | this.log.debug('contructing Hackium instance'); 69 | 70 | if (config) this.config = Object.assign({}, this.config, config); 71 | this.log.debug('Using config: %o', this.config); 72 | 73 | this.log.debug('running preInit on %o plugins', this.config.plugins.length); 74 | this.config.plugins.forEach((plugin) => plugin.preInit && plugin.preInit(this, this.config)); 75 | 76 | HackiumPage.hijackCreate( 77 | { 78 | interceptorFiles: this.config.interceptor, 79 | injectionFiles: this.config.inject, 80 | pwd: this.config.pwd, 81 | watch: this.config.watch, 82 | }, 83 | this.config.plugins, 84 | ); 85 | 86 | if ('devtools' in this.config) { 87 | this.launchOptions.devtools = this.config.devtools; 88 | } 89 | if ('timeout' in this.config) { 90 | this.launchOptions.timeout = typeof this.config.timeout === 'string' ? parseInt(this.config.timeout) : this.config.timeout; 91 | } 92 | 93 | setEnv(this.config.env); 94 | setEnv(ENVIRONMENT); 95 | 96 | if (this.config.headless) { 97 | this.log.debug('NOTE: headless mode disables devtools, the extension bridge, and other plugins.'); 98 | this.launchOptions.headless = true; 99 | this.config.devtools = false; 100 | } else { 101 | this.launchOptions.headless = false; 102 | this.defaultChromiumArgs.push(`--load-extension=${path.join(findRoot(__dirname), 'extensions', 'theme')}`); 103 | if (Array.isArray(this.launchOptions.ignoreDefaultArgs)) { 104 | this.launchOptions.ignoreDefaultArgs.push('--disable-extensions'); 105 | } else if (!this.launchOptions.ignoreDefaultArgs) { 106 | this.launchOptions.ignoreDefaultArgs = ['--disable-extensions']; 107 | } 108 | this.config.plugins.push(hackiumExtensionBridge); 109 | } 110 | 111 | if (this.config.userDataDir) { 112 | this.launchOptions.userDataDir = this.config.userDataDir; 113 | } 114 | this.launchOptions.args = this.defaultChromiumArgs; 115 | if (this.config.chromeOutput) { 116 | this.launchOptions.dumpio = true; 117 | this.defaultChromiumArgs.push('--enable-logging=stderr', '--v=1'); 118 | } 119 | 120 | this.log.debug('running postInit on %o plugins', this.config.plugins.length); 121 | this.config.plugins.forEach((plugin) => plugin.postInit && plugin.postInit(this, this.config)); 122 | } 123 | 124 | getBrowser(): HackiumBrowser { 125 | if (!this.browser) throw new Error('Attempt to capture browser before initialized'); 126 | return this.browser; 127 | } 128 | 129 | async launch(options: PuppeteerLaunchOptions = {}) { 130 | let launchOptions = merge(this.launchOptions, options); 131 | this.log.debug('running preLaunch on %o plugins', this.config.plugins.length); 132 | await waterfallMap(this.config.plugins, (plugin) => plugin.preLaunch && plugin.preLaunch(this, launchOptions)); 133 | 134 | const browser = ((await puppeteer.launch(launchOptions)) as unknown) as HackiumBrowser; 135 | 136 | this.log.debug('running postLaunch on %o plugins', this.config.plugins.length); 137 | await waterfallMap(this.config.plugins, (plugin) => plugin.postLaunch && plugin.postLaunch(this, browser, launchOptions)); 138 | 139 | await browser.initialize(); 140 | 141 | this.log.debug('running postBrowserInit on %o plugins', this.config.plugins.length); 142 | await waterfallMap(this.config.plugins, (plugin) => plugin.postBrowserInit && plugin.postBrowserInit(this, browser, launchOptions)); 143 | 144 | return (this.browser = browser); 145 | } 146 | 147 | startRepl(context: Record = {}) { 148 | return new Promise((resolve) => { 149 | if (this.repl) { 150 | this.log.debug('closing old repl'); 151 | this.repl.close(); 152 | this.repl.on('exit', () => { 153 | this.repl = undefined; 154 | resolve(this.startRepl()); 155 | }); 156 | } else { 157 | this.log.debug('starting repl'); 158 | this.repl = repl.start({ 159 | prompt: '> ', 160 | output: process.stdout, 161 | input: process.stdin, 162 | }); 163 | Object.assign(this.repl.context, { hackium: this, unpause: this.unpause.bind(this) }, context); 164 | resolve(); 165 | } 166 | }); 167 | } 168 | 169 | closeRepl() { 170 | if (this.repl) { 171 | this.log.debug('closing repl'); 172 | this.repl.close(); 173 | this.repl = undefined; 174 | } 175 | } 176 | 177 | async pause(options: { repl: false | Record } = { repl: {} }) { 178 | if (this.unpauseCallback) { 179 | this.log.warn('pause called but Hackium thinks it is already paused. Maybe you forgot to add an "await"?'); 180 | this.unpauseCallback(); 181 | this.unpauseCallback = undefined; 182 | } 183 | if (options.repl) { 184 | this.log.info('starting REPL. Pass { repl: false } to pause() to skip the repl in the future.'); 185 | await this.startRepl(options.repl); 186 | } 187 | this.log.debug('pausing'); 188 | return new Promise((resolve) => { 189 | this.unpauseCallback = resolve; 190 | }); 191 | } 192 | 193 | unpause() { 194 | if (this.unpauseCallback) { 195 | this.log.debug('unpausing'); 196 | this.unpauseCallback(); 197 | this.unpauseCallback = undefined; 198 | } else { 199 | this.log.warn( 200 | `unpause called but Hackium doesn't think it's paused. If this is a bug in Hackium, please submit an issue here: https://github.com/jsoverson/hackium.`, 201 | ); 202 | } 203 | } 204 | 205 | async cliBehavior() { 206 | const cliBehaviorLog = this.log.debug.extend('cli-behavior'); 207 | cliBehaviorLog('running default cli behavior'); 208 | cliBehaviorLog('launching browser'); 209 | const browser = await this.launch(); 210 | cliBehaviorLog('launched browser'); 211 | const [page] = await browser.pages(); 212 | if (this.config.url) { 213 | cliBehaviorLog(`navigating to ${this.config.url}`); 214 | await page.goto(this.config.url); 215 | } 216 | cliBehaviorLog(`running %o hackium scripts`, this.config.execute.length); 217 | await waterfallMap(this.config.execute, (file) => { 218 | return this.runScript(file).then((result) => console.log(result)); 219 | }); 220 | cliBehaviorLog(`core cli behavior complete`); 221 | return browser; 222 | } 223 | 224 | async runScript(file: string, args: any[] = [], src?: string) { 225 | const truePath = resolve([file], this.config.pwd); 226 | if (!src) { 227 | this.log.debug('reading in %o to run as a hackium script', truePath); 228 | src = await read([truePath]); 229 | } 230 | 231 | const browser = await this.getBrowser(); 232 | const pages = await browser.pages(); 233 | const [page] = pages; 234 | 235 | const context = { 236 | hackium: this, 237 | console, 238 | page, 239 | pages, 240 | browser, 241 | module, 242 | require: createRequire(truePath), 243 | __dirname: path.dirname(truePath), 244 | __filename: truePath, 245 | args: this.config._, 246 | __rootResult: null, 247 | }; 248 | vm.createContext(context); 249 | 250 | const wrappedSrc = ` 251 | __rootResult = (async function hackiumScript(){${src}}()) 252 | `; 253 | this.log.debug('running script %O', wrappedSrc); 254 | 255 | try { 256 | vm.runInContext(wrappedSrc, context); 257 | const result = await context.__rootResult; 258 | return result; 259 | } catch (e) { 260 | console.log('Error in hackium script'); 261 | console.log(e); 262 | } 263 | } 264 | 265 | async close() { 266 | this.log.debug('closing browser'); 267 | if (this.browser) { 268 | await this.browser.close(); 269 | this.log.debug('closed browser'); 270 | } 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { PuppeteerLaunchOptions, Plugin } from './util/types'; 2 | 3 | export { Hackium } from './hackium/hackium'; 4 | -------------------------------------------------------------------------------- /src/plugins/extensionbridge.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from '../util/types'; 2 | import { mergeLaunchOptions, decorateBrowser } from 'puppeteer-extensionbridge'; 3 | 4 | export const hackiumExtensionBridge: Plugin = { 5 | preLaunch(hackium, launchOptions) { 6 | mergeLaunchOptions(launchOptions); 7 | }, 8 | async postLaunch(hackium, browser, finalLaunchOptions) { 9 | await decorateBrowser(browser, { newtab: browser.newtab }); 10 | browser.log.debug('initializing and decorating browser instance'); 11 | let lastActive = { tabId: -1, windowId: -1 }; 12 | await browser.extension.addListener('chrome.tabs.onActivated', async ({ tabId, windowId }) => { 13 | lastActive = { tabId, windowId }; 14 | const code = ` 15 | window.postMessage({owner:'hackium', name:'pageActivated', data:{tabId:${tabId}, windowId:${windowId}}}); 16 | `; 17 | browser.log.debug(`chrome.tabs.onActivated triggered. Calling %o`, code); 18 | await browser.extension.send('chrome.tabs.executeScript', tabId, { code }); 19 | }); 20 | await browser.extension.addListener('chrome.tabs.onUpdated', async (tabId) => { 21 | if (tabId === lastActive.tabId) { 22 | const code = ` 23 | window.postMessage({owner:'hackium', name:'pageActivated', data:{tabId:${tabId}}}); 24 | `; 25 | browser.log.debug(`Active page updated. Calling %o`, code); 26 | await browser.extension.send('chrome.tabs.executeScript', tabId, { code }); 27 | } 28 | }); 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /src/puppeteer.ts: -------------------------------------------------------------------------------- 1 | import { Puppeteer } from 'puppeteer/lib/cjs/puppeteer/common/Puppeteer'; 2 | import { initializePuppeteer } from 'puppeteer/lib/cjs/puppeteer/initialize'; 3 | 4 | const puppeteer = initializePuppeteer('puppeteer'); 5 | 6 | export default puppeteer as Puppeteer; 7 | -------------------------------------------------------------------------------- /src/strings.ts: -------------------------------------------------------------------------------- 1 | import { SafeMap } from './util/SafeMap'; 2 | 3 | export const strings = new SafeMap([ 4 | ['clientid', 'hackium'], 5 | ['clienteventhandler', '__hackium_internal_onEvent'], 6 | ['extensionid', require('puppeteer-extensionbridge/extension/manifest.json').key], 7 | ]); 8 | -------------------------------------------------------------------------------- /src/util/SafeMap.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | 3 | export class SafeMap extends Map { 4 | get(key: K): V { 5 | assert(this.has(key), `SafeMap key ${key} not found in ${this}`); 6 | const value = super.get(key); 7 | if (value === undefined || value === null) throw new Error(`SafeMap value for key ${key} is null or undefined.`); 8 | return value; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/util/file.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { promises as fs } from 'fs'; 3 | import chokidar from 'chokidar'; 4 | import DEBUG from 'debug'; 5 | import os from 'os'; 6 | import { handler } from '../cmds/init'; 7 | 8 | const debug = DEBUG('hackium:file'); 9 | 10 | class Watcher { 11 | _watcher = chokidar.watch([], {}); 12 | handlers = new Map(); 13 | constructor() { 14 | this._watcher.on('change', (file) => { 15 | debug('file %o changed', file); 16 | const handlers = this.handlers.get(file); 17 | if (!handlers) { 18 | debug('no change handlers set for %o', file); 19 | return; 20 | } 21 | handlers.forEach((handler) => handler(file)); 22 | }); 23 | } 24 | add(file: string, callback: Function) { 25 | this._watcher.add(file); 26 | const existingHandlers = this.handlers.get(file); 27 | if (existingHandlers) { 28 | existingHandlers.push(callback); 29 | } else { 30 | this.handlers.set(file, [callback]); 31 | } 32 | } 33 | } 34 | 35 | const watcher = new Watcher(); 36 | 37 | export function resolve(parts: string[], pwd = ''): string { 38 | const joinedPath = path.join(...parts); 39 | const parsed = path.parse(joinedPath); 40 | if (!parsed.root) { 41 | if (pwd) { 42 | return path.resolve(path.join(pwd, ...parts)); 43 | } else { 44 | throw new Error(`Path ${joinedPath} has no root and no pwd passed.`); 45 | } 46 | } else { 47 | return path.resolve(joinedPath); 48 | } 49 | } 50 | 51 | export function watch(file: string, callback: Function) { 52 | debug('watching %o', file); 53 | watcher.add(file, callback); 54 | } 55 | 56 | export function read(parts: string | string[], pwd = '') { 57 | const file = Array.isArray(parts) ? resolve(parts, pwd) : parts; 58 | debug('reading %o', file); 59 | return fs.readFile(file, 'utf-8'); 60 | } 61 | 62 | export function write(parts: string | string[], contents: string, pwd = '') { 63 | const file = Array.isArray(parts) ? resolve(parts, pwd) : parts; 64 | debug('writing %o bytes to %o', contents.length, file); 65 | return fs.writeFile(file, contents); 66 | } 67 | 68 | export function remove(parts: string | string[], pwd = '') { 69 | const file = Array.isArray(parts) ? resolve(parts, pwd) : parts; 70 | debug('deleting %o', file); 71 | return fs.unlink(file); 72 | } 73 | 74 | export async function getRandomDir(prefix = 'hackium -') { 75 | const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'prefix')); 76 | debug('created random directory %o', dir); 77 | return dir; 78 | } 79 | -------------------------------------------------------------------------------- /src/util/logger.ts: -------------------------------------------------------------------------------- 1 | import DEBUG, { Debugger } from 'debug'; 2 | import chalk from 'chalk'; 3 | 4 | export default class Logger { 5 | debug: Debugger; 6 | 7 | constructor(name: string) { 8 | this.debug = DEBUG(name); 9 | } 10 | format(...args: any) { 11 | let index = 0; 12 | if (!args[0]) return ''; 13 | args[0] = args[0].toString().replace(/%([a-zA-Z%])/g, (match: string, format: string) => { 14 | if (match === '%%') return '%'; 15 | index++; 16 | const formatter = DEBUG.formatters[format]; 17 | if (typeof formatter === 'function') { 18 | const val = args[index]; 19 | match = formatter.call(this.debug, val); 20 | args.splice(index, 1); 21 | index--; 22 | } 23 | return match; 24 | }); 25 | 26 | return args[0] || ''; 27 | } 28 | print(...args: any) { 29 | console.log(...args); 30 | } 31 | info(...args: any) { 32 | console.log(chalk.cyan('Info: ') + this.format(...args)); 33 | } 34 | warn(...args: any) { 35 | console.log(chalk.yellow('Warning: ') + this.format(...args)); 36 | } 37 | error(...args: any) { 38 | console.log(chalk.red('Error: ') + this.format(...args)); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/util/mixin.ts: -------------------------------------------------------------------------------- 1 | export function mixin(baseObj: BaseType, mixinObject: PseudoClass): BaseType & PseudoClass { 2 | const typedObj = Object.assign(baseObj, mixinObject) as PseudoClass & BaseType; 3 | 4 | const prototype = Object.getPrototypeOf(mixinObject); 5 | const props = Object.getOwnPropertyDescriptors(prototype); 6 | Object.entries(props) 7 | .filter(([prop]) => prop !== 'constructor') 8 | .forEach((entry) => { 9 | const prop = entry[0] as keyof BaseType; 10 | const descriptor = entry[1] as any; 11 | if (!(prop in baseObj)) { 12 | Object.defineProperty(baseObj, prop, descriptor); 13 | } 14 | }); 15 | 16 | return typedObj; 17 | } 18 | -------------------------------------------------------------------------------- /src/util/movement.ts: -------------------------------------------------------------------------------- 1 | import { Random } from './random'; 2 | 3 | export class Vector { 4 | x: number; 5 | y: number; 6 | constructor(x: number, y: number) { 7 | this.x = x; 8 | this.y = y; 9 | } 10 | distanceTo(b: Vector) { 11 | return this.subtract(b).magnitude(); 12 | } 13 | multiply(b: Vector | number) { 14 | return typeof b === 'number' ? new Vector(this.x * b, this.y * b) : new Vector(this.x * b.x, this.y * b.y); 15 | } 16 | divide(b: Vector | number) { 17 | return typeof b === 'number' ? new Vector(this.x / b, this.y / b) : new Vector(this.x / b.x, this.y / b.y); 18 | } 19 | subtract(b: Vector | number) { 20 | return typeof b === 'number' ? new Vector(this.x - b, this.y - b) : new Vector(this.x - b.x, this.y - b.y); 21 | } 22 | add(b: Vector | number) { 23 | return typeof b === 'number' ? new Vector(this.x + b, this.y + b) : new Vector(this.x + b.x, this.y + b.y); 24 | } 25 | magnitude() { 26 | return Math.sqrt(Math.pow(this.x, 2) + Math.pow(this.y, 2)); 27 | } 28 | unit() { 29 | return this.divide(this.magnitude()); 30 | } 31 | } 32 | 33 | export class SimulatedMovement { 34 | drag: number; 35 | impulse: number; 36 | targetRadius: number; 37 | constructor(drag = 4, impulse = 2, targetRadius = 20) { 38 | this.drag = drag; 39 | this.impulse = impulse; 40 | this.targetRadius = targetRadius; 41 | } 42 | generatePath(start: Vector, end: Vector) { 43 | let velocity = new Vector(0, 0), 44 | force = new Vector(0, 0); 45 | 46 | const sqrt3 = Math.sqrt(3); 47 | const sqrt5 = Math.sqrt(5); 48 | 49 | const totalDistance = start.distanceTo(end); 50 | let hiccupDistance = this.impulse / 2; 51 | 52 | const points = [[start.x, start.y]]; 53 | let done = false; 54 | while (!done) { 55 | let remainingDistance = Math.max(start.distanceTo(end), 1); 56 | this.impulse = Math.min(this.impulse, remainingDistance); 57 | 58 | let hiccup = Random.rng.oneIn(6); 59 | 60 | // if we're further than the target area then go full speed, otherwise slow down. 61 | if (remainingDistance >= this.targetRadius) { 62 | force = force.divide(sqrt3).add(Random.rng.float(this.impulse, this.impulse * 2 + 1) / sqrt5); 63 | } else { 64 | force = force.divide(Math.SQRT2); 65 | } 66 | velocity = velocity.add(force).add(end.subtract(start).multiply(this.drag).divide(remainingDistance)); 67 | 68 | let maxStep = Math.min(remainingDistance, hiccup ? hiccupDistance : remainingDistance); 69 | 70 | if (velocity.magnitude() > maxStep) { 71 | let randomDist = maxStep / 2 + Random.rng.float(0, maxStep / 2); 72 | velocity = velocity.unit().multiply(randomDist); 73 | } 74 | 75 | start = start.add(velocity); 76 | 77 | points.push([Math.round(start.x), Math.round(start.y)]); 78 | done = start.distanceTo(end) < 5; 79 | } 80 | 81 | // make sure our destination is our final point; 82 | points.push([end.x, end.y]); 83 | return points; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/util/object.ts: -------------------------------------------------------------------------------- 1 | export function merge(dest: any, ...others: any) { 2 | if (dest === undefined || dest === null) return dest; 3 | const newObject: any = {}; 4 | for (let other of others) { 5 | const allKeys = Object.keys(dest).concat(Object.keys(other)); 6 | for (let key of allKeys) { 7 | if (key in dest) { 8 | if (Array.isArray(dest[key])) { 9 | newObject[key] = [...dest[key]]; 10 | if (key in other) { 11 | newObject[key].push(...other[key]); 12 | } 13 | } else if (typeof dest[key] === 'object') { 14 | if (key in other) newObject[key] = merge(dest[key], other[key]); 15 | } else { 16 | if (key in dest && dest[key] !== undefined) newObject[key] = dest[key]; 17 | if (key in other && other[key] !== undefined) newObject[key] = other[key]; 18 | } 19 | } else if (key in other) { 20 | if (Array.isArray(other[key])) { 21 | newObject[key] = [...other[key]]; 22 | } else if (typeof dest[key] === 'object') { 23 | newObject[key] = other[key]; 24 | } else { 25 | newObject[key] = other[key]; 26 | } 27 | } 28 | } 29 | } 30 | return newObject; 31 | } 32 | -------------------------------------------------------------------------------- /src/util/prettify.ts: -------------------------------------------------------------------------------- 1 | const { parseScript } = require('shift-parser'); 2 | import { prettyPrint } from 'shift-printer'; 3 | 4 | export function prettify(src: string) { 5 | return prettyPrint(parseScript(src)); 6 | } 7 | -------------------------------------------------------------------------------- /src/util/promises.ts: -------------------------------------------------------------------------------- 1 | import { Func } from 'mocha'; 2 | import { time } from 'console'; 3 | 4 | export function isPromise(a: T | Promise): a is Promise { 5 | return typeof a === 'object' && 'then' in a && typeof a.then === 'function'; 6 | } 7 | 8 | export function waterfallMap(array: J[], iterator: (el: J, i: number) => T | Promise): Promise { 9 | const reducer = (accumulator: Promise, next: J, i: number): Promise => { 10 | return accumulator.then((result) => { 11 | const iteratorReturnValue = iterator(next, i); 12 | if (isPromise(iteratorReturnValue)) return iteratorReturnValue.then((newNode) => result.concat(newNode)); 13 | else return Promise.resolve(result.concat(iteratorReturnValue)); 14 | }); 15 | }; 16 | 17 | return array.reduce(reducer, Promise.resolve([])); 18 | } 19 | 20 | export function onlySettled(promises: Promise[]): Promise { 21 | return Promise.allSettled(promises).then((results) => 22 | results 23 | .filter(<(K: PromiseSettledResult) => K is PromiseFulfilledResult>((result) => result.status === 'fulfilled')) 24 | .map((result: PromiseFulfilledResult) => result.value), 25 | ); 26 | } 27 | 28 | export function defer(fn: any, timeout: number = 0) { 29 | return new Promise((resolve, reject) => { 30 | setTimeout(() => { 31 | resolve(typeof fn === 'function' ? fn() : fn); 32 | }, timeout); 33 | }); 34 | } 35 | 36 | export function delay(timeout = 0) { 37 | const start = Date.now(); 38 | return new Promise((resolve) => setTimeout(() => resolve(Date.now() - start), timeout)); 39 | } 40 | -------------------------------------------------------------------------------- /src/util/random.ts: -------------------------------------------------------------------------------- 1 | import seedrandom from 'seedrandom'; 2 | 3 | export class Random { 4 | seed: number; 5 | rng: seedrandom.prng; 6 | 7 | static rng = new Random(0); 8 | 9 | static seedSingleton(seed: number) { 10 | Random.rng = new Random(seed); 11 | } 12 | 13 | constructor(seed?: number) { 14 | if (!seed) seed = seedrandom().int32(); 15 | this.seed = seed; 16 | this.rng = seedrandom(seed.toString()); 17 | } 18 | 19 | int(min = 0, max = Number.MAX_SAFE_INTEGER) { 20 | return Math.floor(this.rng() * (max - min)) + min; 21 | } 22 | 23 | oddInt(min = 0, max = Number.MAX_SAFE_INTEGER) { 24 | min = min % 2 === 0 ? min + 1 : min; 25 | max = max % 2 === 0 ? max - 1 : max; 26 | const delta = max - min; 27 | const rand = this.int(0, delta / 2); 28 | return min + rand * 2; 29 | } 30 | 31 | float(min = 0, max = 1) { 32 | return this.rng() * max - min; 33 | } 34 | 35 | decision(probability: number, decision: () => void) { 36 | if (this.float() < probability) decision(); 37 | } 38 | 39 | listItem(list: T[]): T { 40 | return list[this.int(0, list.length)]; 41 | } 42 | 43 | oneIn(num: number) { 44 | return this.float() < 1 / num; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/util/template.ts: -------------------------------------------------------------------------------- 1 | import findRoot from 'find-root'; 2 | import path from 'path'; 3 | import { strings } from '../strings'; 4 | 5 | const metadata = require(path.join(findRoot(__dirname), 'package.json')); 6 | 7 | export function renderTemplate(src: string) { 8 | return src.replace('%%%HACKIUM_VERSION%%%', metadata.version).replace(/%%%(.+?)%%%/g, (m, $1) => { 9 | return strings.get($1); 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /src/util/types.ts: -------------------------------------------------------------------------------- 1 | import { Hackium } from '..'; 2 | import { Arguments, ArgumentsWithDefaults } from '../arguments'; 3 | import { HackiumBrowser } from '../hackium/hackium-browser'; 4 | import { LaunchOptions, ChromeArgOptions, BrowserOptions } from 'puppeteer/lib/cjs/puppeteer/node/LaunchOptions'; 5 | import { HackiumPage } from '../hackium/hackium-page'; 6 | 7 | export interface Constructor { 8 | new (...args: any): T; 9 | } 10 | 11 | export interface Plugin { 12 | preInit?: (hackium: Hackium, options: ArgumentsWithDefaults) => any; 13 | postInit?: (hackium: Hackium, finalOptions: ArgumentsWithDefaults) => any; 14 | preLaunch?: (hackium: Hackium, launchOptions: PuppeteerLaunchOptions) => any; 15 | postLaunch?: (hackium: Hackium, browser: HackiumBrowser, finalLaunchOptions: PuppeteerLaunchOptions) => any; 16 | postBrowserInit?: (hackium: Hackium, browser: HackiumBrowser, finalLaunchOptions: PuppeteerLaunchOptions) => any; 17 | prePageCreate?: (browser: HackiumBrowser) => any; 18 | postPageCreate?: (browser: HackiumBrowser, page: HackiumPage) => any; 19 | } 20 | 21 | export type PuppeteerLaunchOptions = LaunchOptions & ChromeArgOptions & BrowserOptions; 22 | -------------------------------------------------------------------------------- /test/_fixtures/global-var.js: -------------------------------------------------------------------------------- 1 | window.globalVar = 'globalVar'; -------------------------------------------------------------------------------- /test/_fixtures/injection.js: -------------------------------------------------------------------------------- 1 | const hackiumExists = !!hackium; -------------------------------------------------------------------------------- /test/_fixtures/input-viewer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /test/_fixtures/input-viewer/main.js: -------------------------------------------------------------------------------- 1 | import { SimulatedMovement, Vector } from '../../../dist/src/util/movement'; 2 | 3 | const canvas = document.getElementById('canvas'); 4 | 5 | function getContext(canvas) { 6 | if (!canvas) throw new Error('no canvas'); 7 | canvas.width = canvas.clientWidth; 8 | canvas.height = canvas.clientHeight; 9 | const ctx = canvas.getContext('2d'); 10 | if (!ctx) throw new Error('no context'); 11 | return ctx; 12 | } 13 | 14 | const ctx = getContext(canvas); 15 | 16 | function pctY(pct) { 17 | return canvas.height * pct; 18 | } 19 | function pctX(pct) { 20 | return canvas.width * pct; 21 | } 22 | 23 | const duration = 1000; 24 | const bX = pctX(0.8); 25 | const bY = pctY(0.4); 26 | const cX = pctX(0.8); 27 | const cY = pctY(0.4); 28 | 29 | const eachPoints = [ 30 | [pctX(0.1), pctY(0.1), pctX(0.7), pctY(0.7)], 31 | [pctX(0.1), pctY(0.1), pctX(0.7), pctY(0.7)], 32 | [pctX(0.1), pctY(0.1), pctX(0.7), pctY(0.7)], 33 | [pctX(0.1), pctY(0.1), pctX(0.7), pctY(0.7)], 34 | 35 | [pctX(0.8), pctY(0.9), pctX(0.1), pctY(0.2)], 36 | [pctX(0.8), pctY(0.9), pctX(0.1), pctY(0.2)], 37 | [pctX(0.8), pctY(0.9), pctX(0.1), pctY(0.2)], 38 | [pctX(0.8), pctY(0.9), pctX(0.1), pctY(0.2)], 39 | 40 | [pctX(0.9), pctY(0.1), pctX(0.1), pctY(0.8)], 41 | [pctX(0.9), pctY(0.1), pctX(0.1), pctY(0.8)], 42 | [pctX(0.9), pctY(0.1), pctX(0.1), pctY(0.8)], 43 | [pctX(0.9), pctY(0.1), pctX(0.1), pctY(0.8)], 44 | 45 | [pctX(0.05), pctY(0.95), pctX(0.95), pctY(0.05)], 46 | [pctX(0.05), pctY(0.95), pctX(0.95), pctY(0.05)], 47 | [pctX(0.05), pctY(0.95), pctX(0.95), pctY(0.05)], 48 | [pctX(0.05), pctY(0.95), pctX(0.95), pctY(0.05)], 49 | ].map(([fromX, fromY, toX, toY]) => [new SimulatedMovement().generatePath(new Vector(fromX, fromY), new Vector(toX, toY))]); 50 | 51 | const colors = ['#808', '#880', '#088', '#800', '#080', '#008']; 52 | for (let i = 0; i < eachPoints.length; i++) { 53 | for (let j = 0; j < eachPoints[i].length; j++) { 54 | ctx.fillStyle = 'black'; 55 | const points = eachPoints[i][j]; 56 | const fromX = points[0][0]; 57 | const fromY = points[0][1]; 58 | const toX = points[points.length - 1][0]; 59 | const toY = points[points.length - 1][1]; 60 | ctx.fillRect(fromX - 1, fromY - 1, 3, 3); 61 | ctx.fillRect(toX - 1, toY - 1, 3, 3); 62 | 63 | draw(points, colors[j]); 64 | } 65 | } 66 | 67 | function draw(points, color, index = 0) { 68 | ctx.fillStyle = color; 69 | ctx.strokeStyle = color; 70 | const lastPoint = points[index - 1]; 71 | const point = points[index]; 72 | if (!point) return; 73 | if (lastPoint) { 74 | ctx.moveTo(lastPoint[0], lastPoint[1]); 75 | ctx.lineTo(point[0], point[1]); 76 | ctx.stroke(); 77 | } 78 | setTimeout(() => { 79 | draw(points, color, index + 1); 80 | }, 16); 81 | } 82 | -------------------------------------------------------------------------------- /test/_fixtures/input-viewer/serve.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | npx parcel ./index.html -------------------------------------------------------------------------------- /test/_fixtures/interceptor.js: -------------------------------------------------------------------------------- 1 | const { patterns } = require('puppeteer-interceptor'); 2 | 3 | exports.intercept = patterns.Script('*.js'); 4 | 5 | exports.interceptor = function (hackium, interception) { 6 | const response = interception.response; 7 | response.body += `;window.interceptedVal = 'interceptedValue';`; 8 | return response; 9 | }; 10 | -------------------------------------------------------------------------------- /test/_fixtures/module.js: -------------------------------------------------------------------------------- 1 | module.exports = 'exported string' -------------------------------------------------------------------------------- /test/_fixtures/script.js: -------------------------------------------------------------------------------- 1 | //@ts-nocheck 2 | await page.click('#button'); 3 | await page.click('#button'); 4 | 5 | page = await browser.newPage(); 6 | 7 | await page.goto(args[0]); 8 | 9 | const body = await page.$('body'); 10 | const string = require('./module.js'); 11 | await page.evaluate((body, string) => { body.innerHTML = string }, body, string); 12 | const result = await page.evaluate((body) => { body.innerHTML }, body); 13 | -------------------------------------------------------------------------------- /test/_server_root/console.js: -------------------------------------------------------------------------------- 1 | console.log('hi'); 2 | 3 | -------------------------------------------------------------------------------- /test/_server_root/dynamic.js: -------------------------------------------------------------------------------- 1 | 2 | document.querySelector('#dynamic').innerHTML = "Dynamic header"; -------------------------------------------------------------------------------- /test/_server_root/form.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Page with forms 4 | 5 | 6 | 7 |

Signup

8 |
9 |
10 |
11 |
12 |
13 | 14 |
15 |
16 |
17 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /test/_server_root/idle.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Page two 4 | 28 | 29 | 30 | 31 |

Idle for: 0ms

32 |

Total page life: 0ms

33 | 34 | 35 | -------------------------------------------------------------------------------- /test/_server_root/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Test page 5 | 6 | 7 | 8 | 9 |

Test header

10 |

Unmodified header

11 | 12 | 0 13 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /test/_server_root/two.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Page two 4 | 10 | 11 | 12 | 13 |

Page two

14 | 15 | 16 | -------------------------------------------------------------------------------- /test/cli.test.ts: -------------------------------------------------------------------------------- 1 | import { start, TestServer } from '@jsoverson/test-server'; 2 | import { expect } from 'chai'; 3 | import { promises as fs } from 'fs'; 4 | import path from 'path'; 5 | import { Hackium } from '../src'; 6 | import { Arguments } from '../src/arguments'; 7 | import { _runCli } from '../src/cli'; 8 | import { read, resolve, write, remove, getRandomDir } from '../src/util/file'; 9 | import { delay } from '../src/util/promises'; 10 | import { debug, getArgs } from './helper'; 11 | import rimraf from 'rimraf'; 12 | 13 | var stdin = require('mock-stdin').stdin(); 14 | 15 | describe('cli', function () { 16 | this.timeout(6000); 17 | let dir = '/nonexistant'; 18 | let baseUrlArgs = ''; 19 | let baseArgs = ''; 20 | let instance: Hackium | undefined; 21 | let server: TestServer; 22 | 23 | before(async () => { 24 | server = await start(__dirname, '_server_root'); 25 | }); 26 | 27 | after(async () => { 28 | await server.stop(); 29 | }); 30 | 31 | beforeEach(async () => { 32 | dir = await getRandomDir(); 33 | baseArgs = `--pwd="${__dirname}" --headless --userDataDir=${dir}`; 34 | baseUrlArgs = `--url="${server.url('index.html')}" ${baseArgs}`; 35 | }); 36 | 37 | afterEach((done) => { 38 | (instance ? instance.close() : Promise.resolve()).finally(() => { 39 | instance = undefined; 40 | rimraf(dir, (err) => { 41 | if (err) done(err); 42 | done(); 43 | }); 44 | }); 45 | }); 46 | 47 | it('Should go to a default URL', async () => { 48 | instance = new Hackium(getArgs(`${baseUrlArgs}`)); 49 | const browser = await instance.cliBehavior(); 50 | const [page] = await browser.pages(); 51 | const title = await page.title(); 52 | expect(title).to.equal('Test page'); 53 | }); 54 | 55 | it('Should allow for configurable timeouts', async () => { 56 | // set a timeout too low for Chrome to launch & check the error in the assertion 57 | instance = new Hackium(getArgs(`${baseArgs} -t 10`)); 58 | const error = await instance.cliBehavior().catch((e: any) => e); 59 | expect(error.message).to.match(/Timed out/i); 60 | }); 61 | 62 | it('Should inject evaluateOnNewDocument scripts', async () => { 63 | instance = new Hackium(getArgs(`${baseUrlArgs} --inject _fixtures/global-var.js`)); 64 | const browser = await instance.cliBehavior(); 65 | const [page] = await browser.pages(); 66 | const globalValue = await page.evaluate('window.globalVar'); 67 | expect(globalValue).to.equal('globalVar'); 68 | }); 69 | 70 | it('Should intercept scripts', async () => { 71 | instance = new Hackium(getArgs(`${baseUrlArgs} --i _fixtures/interceptor.js`)); 72 | const browser = await instance.cliBehavior(); 73 | const [page] = await browser.pages(); 74 | const value = await page.evaluate('window.interceptedVal'); 75 | expect(value).to.equal('interceptedValue'); 76 | }); 77 | 78 | it('Should create userDataDir', async () => { 79 | instance = new Hackium(getArgs(`${baseUrlArgs}`)); 80 | process.env.FOO = 'T'; 81 | await instance.cliBehavior(); 82 | const stat = await fs.stat(dir); 83 | expect(stat.isDirectory()).to.be.true; 84 | }); 85 | 86 | it('Should read local config', async () => { 87 | instance = new Hackium({ 88 | pwd: __dirname, 89 | headless: true, 90 | url: server.url('anything/'), 91 | } as Arguments); 92 | const browser = await instance.cliBehavior(); 93 | const [page] = await browser.pages(); 94 | const url = page.url(); 95 | expect(url).to.equal(server.url('anything/')); 96 | }); 97 | 98 | it('Should merge defaults with passed config', async () => { 99 | instance = new Hackium({ 100 | headless: true, 101 | userDataDir: dir, 102 | } as Arguments); 103 | expect(instance.config.pwd).equal(process.cwd()); 104 | }); 105 | 106 | it('Should watch for and apply changes to injections on a reload', async () => { 107 | const tempPath = resolve(['_fixtures', 'global-var-temp.js'], __dirname); 108 | const origSrc = await read(['_fixtures', 'global-var.js'], __dirname); 109 | 110 | await write(tempPath, origSrc); 111 | instance = new Hackium(getArgs(`${baseUrlArgs} --inject _fixtures/global-var-temp.js -w`)); 112 | const browser = await instance.cliBehavior(); 113 | 114 | let [page] = await browser.pages(); 115 | await page.setCacheEnabled(false); 116 | let globalValue = await page.evaluate('window.globalVar'); 117 | expect(globalValue).to.equal('globalVar'); 118 | 119 | await write(tempPath, origSrc.replace(/globalVar/g, 'hotloadVar')); 120 | const newPage = await instance.getBrowser().newPage(); 121 | await page.close(); 122 | debug('loading page in new tab'); 123 | await newPage.goto(server.url('index.html')); 124 | 125 | globalValue = await newPage.evaluate('window.hotloadVar'); 126 | expect(globalValue).to.equal('hotloadVar'); 127 | 128 | await remove(tempPath); 129 | }); 130 | 131 | it('Should watch for and apply changes to interceptors on a reload', async () => { 132 | const tempPath = resolve(['_fixtures', 'interceptorTemp.js'], __dirname); 133 | const origSrc = await read(['_fixtures', 'interceptor.js'], __dirname); 134 | 135 | await write(tempPath, origSrc.replace('interceptedValue', 'interceptedValTemp')); 136 | instance = new Hackium(getArgs(`${baseUrlArgs} --i _fixtures/interceptorTemp.js -w`)); 137 | const browser = await instance.cliBehavior(); 138 | 139 | let [page] = await browser.pages(); 140 | await page.setCacheEnabled(false); 141 | let value = await page.evaluate('window.interceptedVal'); 142 | expect(value).to.equal('interceptedValTemp'); 143 | 144 | await write(tempPath, origSrc.replace('interceptedValue', 'interceptedValHotload')); 145 | // this is a race but so is life 146 | await delay(100); 147 | debug('reloading'); 148 | await page.reload(); 149 | 150 | value = await page.evaluate('window.interceptedVal'); 151 | expect(value).to.equal('interceptedValHotload'); 152 | 153 | await remove(tempPath); 154 | }); 155 | 156 | it('Should watch for and apply changes to interceptors on a new tab', async () => { 157 | const tempPath = resolve(['_fixtures', 'interceptorTemp.js'], __dirname); 158 | const origSrc = await read(['_fixtures', 'interceptor.js'], __dirname); 159 | 160 | await write(tempPath, origSrc.replace('interceptedValue', 'interceptedValTemp')); 161 | instance = new Hackium(getArgs(`${baseUrlArgs} --i _fixtures/interceptorTemp.js -w`)); 162 | const browser = await instance.cliBehavior(); 163 | let [page] = await browser.pages(); 164 | 165 | await page.setCacheEnabled(false); 166 | 167 | let value = await page.evaluate('window.interceptedVal'); 168 | expect(value).to.equal('interceptedValTemp'); 169 | 170 | await write(tempPath, origSrc.replace('interceptedValue', 'interceptedValHotload')); 171 | const newPage = await instance.getBrowser().newPage(); 172 | await page.close(); 173 | debug('loading page in new tab'); 174 | await newPage.goto(server.url('index.html')); 175 | 176 | value = await newPage.evaluate('window.interceptedVal'); 177 | expect(value).to.equal('interceptedValHotload'); 178 | 179 | await remove(tempPath); 180 | }); 181 | 182 | it('Should run hackium scripts', async () => { 183 | const scriptPath = path.join('.', '_fixtures', 'script.js'); 184 | 185 | instance = new Hackium(getArgs(`${baseUrlArgs} -e ${scriptPath} -- ${server.url('two.html')}`)); 186 | const browser = await instance.cliBehavior(); 187 | const [pageOrig, pageNew] = await browser.pages(); 188 | 189 | const clicksEl = await pageOrig.$('#clicks'); 190 | const numClicks = await pageOrig.evaluate((clicksEl: any) => clicksEl.innerHTML, clicksEl); 191 | 192 | expect(numClicks).to.equal('2'); 193 | 194 | const url = pageNew.url(); 195 | expect(url).to.match(/two.html$/); 196 | 197 | const bodyEl = await pageNew.$('body'); 198 | const body = await pageNew.evaluate((bodyEl: any) => bodyEl.innerHTML, bodyEl); 199 | expect(body).to.equal(require('./_fixtures/module')); 200 | }); 201 | 202 | it('repl should be testable', async () => { 203 | const args = getArgs(`${baseUrlArgs}`); 204 | const { repl } = await _runCli(args); 205 | instance = repl.context.hackium; 206 | let didClose = false; 207 | repl.on('exit', () => { 208 | didClose = true; 209 | }); 210 | repl.write('.exit\n'); 211 | // yield; 212 | return new Promise((resolve, reject) => { 213 | setTimeout(() => { 214 | expect(didClose).to.be.true; 215 | resolve(); 216 | }, 100); 217 | }); 218 | }); 219 | // TODO: Fix this flaky test 220 | xit('.repl_history should be stored in config.pwd', async () => { 221 | if (process.env.MOCHA_EXPLORER_VSCODE) { 222 | // This is failing when it's run in VS Code and I can't spend any more time 223 | // figuring out why. This env var is set as part of the project settings so 224 | // this test is shortcircuited when run in VS Code. 225 | console.log('short circuiting'); 226 | return; 227 | } 228 | const userDataDir = path.join(dir, 'chrome'); 229 | const args = getArgs(`--pwd="${dir}" --userDataDir=${userDataDir}`); 230 | const { repl } = await _runCli(args, { stdin }); 231 | instance = repl.context.hackium; 232 | stdin.send('/*hello world*/'); 233 | stdin.send('\n'); 234 | await delay(200); 235 | console.log(resolve(['.repl_history'], dir)); 236 | console.log(dir); 237 | const replHistoryPath = resolve(['.repl_history'], dir); 238 | const history = await read(replHistoryPath); 239 | expect(history).to.equal(`/*hello world*/`); 240 | }); 241 | }); 242 | -------------------------------------------------------------------------------- /test/hackium/hackium-browser-context.test.ts: -------------------------------------------------------------------------------- 1 | import { start, TestServer } from '@jsoverson/test-server'; 2 | import { expect } from 'chai'; 3 | import fs from 'fs'; 4 | import { Hackium } from '../../src'; 5 | import { getRandomDir } from '../../src/util/file'; 6 | 7 | const fsp = fs.promises; 8 | 9 | describe('Browser context', function () { 10 | this.timeout(6000); 11 | let userDataDir = '/nonexistant'; 12 | let hackium: Hackium; 13 | let server: TestServer; 14 | 15 | before(async () => { 16 | userDataDir = await getRandomDir(); 17 | server = await start(__dirname, '..', '_server_root'); 18 | }); 19 | 20 | after(async () => { 21 | await fsp.rmdir(userDataDir, { recursive: true }); 22 | await server.stop(); 23 | }); 24 | 25 | beforeEach(async () => { 26 | hackium = new Hackium({ headless: true }); 27 | }); 28 | 29 | afterEach(async () => { 30 | if (hackium) await hackium.close(); 31 | }); 32 | 33 | it('Should expose hackium object & version on the page', async () => { 34 | const browser = await hackium.launch(); 35 | const context = await browser.createIncognitoBrowserContext(); 36 | const page = await context.newPage(); 37 | await page.goto(server.url('index.html')); 38 | const version = await page.evaluate('hackium.version'); 39 | expect(version).to.equal(require('../../package.json').version); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /test/hackium/hackium-browser.test.ts: -------------------------------------------------------------------------------- 1 | import { start, TestServer } from '@jsoverson/test-server'; 2 | import chai, { expect } from 'chai'; 3 | import fs from 'fs'; 4 | import { Hackium } from '../../src'; 5 | import { getRandomDir } from '../../src/util/file'; 6 | import { delay } from '../../src/util/promises'; 7 | import spies from 'chai-spies'; 8 | 9 | chai.use(spies); 10 | const fsp = fs.promises; 11 | 12 | describe('Browser', function () { 13 | this.timeout(6000); 14 | let userDataDir = '/nonexistant'; 15 | let hackium: Hackium; 16 | let server: TestServer; 17 | 18 | before(async () => { 19 | userDataDir = await getRandomDir(); 20 | server = await start(__dirname, '..', '_server_root'); 21 | }); 22 | 23 | after(async () => { 24 | await fsp.rmdir(userDataDir, { recursive: true }); 25 | await server.stop(); 26 | }); 27 | 28 | beforeEach(async () => { 29 | hackium = new Hackium({ headless: false }); 30 | }); 31 | 32 | afterEach(async () => { 33 | if (hackium) await hackium.close(); 34 | }); 35 | 36 | it('should update active page as tabs open', async () => { 37 | const browser = await hackium.launch(); 38 | const [page] = await browser.pages(); 39 | await page.goto(server.url('index.html')); 40 | expect(page).to.equal(browser.activePage); 41 | const page2 = await browser.newPage(); 42 | // delay because of a race condition where goto resolves before we get 43 | // to communicate that the active page updated. It's significant in automated scripts but not 44 | // perceptible in manual usage/repl where activePage is most used. 45 | await delay(1000); 46 | await page2.goto(server.url('two.html'), { waitUntil: 'networkidle2' }); 47 | expect(page2).to.equal(browser.activePage); 48 | }); 49 | 50 | it('Should return an error when port defined as string', async () => { 51 | const browser = await hackium.launch(); 52 | const logSpy = chai.spy(); 53 | browser.log.error = logSpy; 54 | let browserError: any; 55 | const callSetProxy = (stringPort: any) => browser.setProxy('127.0.0.1', stringPort); 56 | await callSetProxy('9999').catch((err: Error) => { 57 | browserError = err; 58 | }); 59 | expect(browserError).to.be.instanceOf(Error); 60 | expect(browserError.message).to.contain('port is not a number'); 61 | expect(logSpy).to.have.been.called.once.with('HackiumBrowser.setProxy: port is not a number'); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /test/hackium/hackium-input.test.ts: -------------------------------------------------------------------------------- 1 | import { start, TestServer } from '@jsoverson/test-server'; 2 | import chai, { expect } from 'chai'; 3 | import { Hackium } from '../../src'; 4 | import { HackiumBrowser } from '../../src/hackium/hackium-browser'; 5 | import spies from 'chai-spies'; 6 | 7 | chai.use(spies); 8 | describe('Input', function () { 9 | describe('Mouse', function () { 10 | this.timeout(10000); 11 | let hackium: Hackium, browser: HackiumBrowser; 12 | let server: TestServer; 13 | before(async () => { 14 | server = await start(__dirname, '..', '_server_root'); 15 | }); 16 | 17 | after(async () => { 18 | await server.stop(); 19 | }); 20 | afterEach(async () => { 21 | if (hackium) await hackium.close(); 22 | }); 23 | it('coordinates should be randomized at mouse instantiation', async () => { 24 | hackium = new Hackium({ headless: true }); 25 | const browser = await hackium.launch(); 26 | const [page] = await browser.pages(); 27 | expect(page.mouse.x).to.be.not.undefined; 28 | expect(page.mouse.x).to.be.greaterThan(0); 29 | expect(page.mouse.y).to.be.not.undefined; 30 | expect(page.mouse.y).to.be.greaterThan(0); 31 | }); 32 | 33 | it('click() should give a friendly error when passing incorrect types', async () => { 34 | hackium = new Hackium({ headless: true }); 35 | const browser = await hackium.launch(); 36 | const [page] = await browser.pages(); 37 | const logSpy = chai.spy(); 38 | page.mouse.log.error = logSpy; 39 | let error: any; 40 | await page.mouse.click('100' as any, '100' as any).catch((e) => (error = e)); 41 | expect(error).to.be.instanceOf(Error); 42 | expect(error.message).to.contain('x & y must be numbers'); 43 | expect(logSpy).to.have.been.called.once; 44 | }); 45 | 46 | it('.moveTo should move to an element & click should click where we are at', async () => { 47 | hackium = new Hackium({ headless: true }); 48 | const browser = await hackium.launch(); 49 | const [page] = await browser.pages(); 50 | await page.goto(server.url('form.html')); 51 | await page.mouse.moveTo('button'); 52 | await page.mouse.click(); 53 | await page.mouse.click(); 54 | await page.mouse.click(); 55 | const numClicks = await page.evaluate('buttonClicks'); 56 | expect(numClicks).to.equal(3); 57 | }); 58 | it('.idle should reset basic idle timer', async () => { 59 | hackium = new Hackium({ headless: true }); 60 | const browser = await hackium.launch(); 61 | const [page] = await browser.pages(); 62 | await page.goto(server.url('idle.html')); 63 | await page.mouse.idle(); 64 | const { idleTime, totalTime } = (await page.evaluate('({idleTime, totalTime})')) as { idleTime: number; totalTime: number }; 65 | expect(totalTime).to.be.greaterThan(0); 66 | expect(idleTime).to.be.greaterThan(0); 67 | expect(idleTime).to.be.lessThan(totalTime); 68 | }); 69 | }); 70 | describe('Keyboard', function () { 71 | this.timeout(10000); 72 | let hackium: Hackium, browser: HackiumBrowser; 73 | let server: TestServer; 74 | before(async () => { 75 | server = await start(__dirname, '..', '_server_root'); 76 | }); 77 | 78 | after(async () => { 79 | await server.stop(); 80 | }); 81 | afterEach(async () => { 82 | if (hackium) await hackium.close(); 83 | }); 84 | 85 | it('.idle should reset basic idle timer', async () => { 86 | hackium = new Hackium({ headless: true }); 87 | const browser = await hackium.launch(); 88 | const [page] = await browser.pages(); 89 | await page.goto(server.url('idle.html')); 90 | await page.keyboard.idle(); 91 | const { idleTime, totalTime } = (await page.evaluate('({idleTime, totalTime})')) as { idleTime: number; totalTime: number }; 92 | expect(totalTime).to.be.greaterThan(0); 93 | expect(idleTime).to.be.greaterThan(0); 94 | expect(idleTime).to.be.lessThan(totalTime); 95 | }); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /test/hackium/hackium-page.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Debugger } from 'debug'; 3 | import fs from 'fs'; 4 | import { Interceptor } from 'puppeteer-interceptor'; 5 | import Protocol from 'devtools-protocol'; 6 | import { Hackium } from '../../src'; 7 | import { HackiumBrowser } from '../../src/hackium/hackium-browser'; 8 | import { start, TestServer } from '@jsoverson/test-server'; 9 | import path from 'path'; 10 | import { getRandomDir } from '../../src/util/file'; 11 | import { delay } from '../../src/util/promises'; 12 | import { CDPSession } from 'puppeteer/lib/cjs/puppeteer/common/Connection'; 13 | 14 | const fsp = fs.promises; 15 | 16 | describe('Page', function () { 17 | this.timeout(6000); 18 | let userDataDir = '/nonexistant'; 19 | let hackium: Hackium; 20 | let server: TestServer; 21 | 22 | before(async () => { 23 | userDataDir = await getRandomDir(); 24 | server = await start(__dirname, '..', '_server_root'); 25 | }); 26 | 27 | after(async () => { 28 | await fsp.rmdir(userDataDir, { recursive: true }); 29 | await server.stop(); 30 | }); 31 | 32 | beforeEach(async () => { 33 | hackium = new Hackium({ headless: true }); 34 | }); 35 | 36 | afterEach(async () => { 37 | if (hackium) await hackium.close(); 38 | }); 39 | 40 | it('should expose a CDP session', async () => { 41 | const browser = await hackium.launch(); 42 | const [page] = await browser.pages(); 43 | expect(page.connection).to.be.instanceOf(CDPSession); 44 | }); 45 | 46 | it("should bypass puppeteer's smart caching if forceCacheEnabled(true)", async () => { 47 | const cachedUrl = server.url('cached'); 48 | const browser = await hackium.launch(); 49 | const [page] = await browser.pages(); 50 | const go = async () => page.goto(cachedUrl).then((resp) => page.content()); 51 | const resultA1 = await go(); 52 | const resultA2 = await go(); 53 | expect(resultA1).to.equal(resultA2); 54 | 55 | await page.setRequestInterception(true); 56 | let i = 0; 57 | page.on('request', (interceptedRequest) => ++i && interceptedRequest.continue()); 58 | 59 | const resultB1 = await go(); // should be cached, but cache was disabled by setRequestInterception() 60 | expect(resultB1).to.not.equal(resultA1); 61 | expect(i).to.equal(1); 62 | 63 | await page.forceCacheEnabled(true); 64 | const resultC1 = await go(); // need to visit page again to cache response 65 | expect(i).to.equal(2); 66 | const resultC2 = await go(); 67 | expect(i).to.equal(3); 68 | expect(resultC1).to.equal(resultC2); 69 | expect(resultC1).to.equal(resultB1); 70 | expect(resultC2).to.equal(resultB1); 71 | }); 72 | 73 | it('should expose hackium object & version on the page', async () => { 74 | const browser = await hackium.launch(); 75 | const [page] = await browser.pages(); 76 | await page.goto(server.url('index.html')); 77 | const version = await page.evaluate('hackium.version'); 78 | expect(version).to.equal(require('../../package.json').version); 79 | }); 80 | 81 | it('should always inject new scripts after hackium client', async () => { 82 | hackium = new Hackium({ 83 | headless: true, 84 | inject: [path.join(__dirname, '..', '_fixtures', 'injection.js')], 85 | }); 86 | const browser = await hackium.launch(); 87 | const [page] = await browser.pages(); 88 | await page.goto(server.url('index.html')); 89 | const bool = await page.evaluate('hackiumExists'); 90 | expect(bool).to.be.true; 91 | }); 92 | 93 | it('should allow configurable interception', async () => { 94 | const browser = await hackium.launch(); 95 | const [page] = await browser.pages(); 96 | let runs = 0; 97 | page.addInterceptor({ 98 | intercept: [ 99 | { 100 | urlPattern: '*console*', 101 | resourceType: 'Script', 102 | requestStage: 'Response', 103 | }, 104 | ], 105 | interceptor: function ( 106 | browser: HackiumBrowser, 107 | interception: { request: Protocol.Network.Request; response: Interceptor.InterceptedResponse }, 108 | debug: Debugger, 109 | ) { 110 | runs++; 111 | if (!interception.response.body) throw new Error('no body'); 112 | interception.response.body = 'var myNewValue = 12;'; 113 | return interception.response; 114 | }, 115 | }); 116 | await page.goto(server.url('index.html')); 117 | const value = await page.evaluate('myNewValue'); 118 | expect(runs).to.equal(1); 119 | expect(value).to.equal(12); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /test/hackium/hackium.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import findRoot from 'find-root'; 3 | import path from 'path'; 4 | import { Hackium } from '../../src'; 5 | import { HackiumBrowser } from '../../src/hackium/hackium-browser'; 6 | import { HackiumPage } from '../../src/hackium/hackium-page'; 7 | import { Plugin } from '../../src/util/types'; 8 | 9 | describe('Hackium', function () { 10 | let hackium: Hackium | null = null; 11 | afterEach(async () => { 12 | if (hackium) await hackium.close(); 13 | }); 14 | it('Should instantiate with no arguments', async () => { 15 | hackium = new Hackium(); 16 | expect(hackium).to.be.instanceOf(Hackium); 17 | }); 18 | 19 | it('Should use version from package.json', async () => { 20 | hackium = new Hackium(); 21 | const pkg = require(path.join(findRoot(__dirname), 'package.json')); 22 | expect(hackium.version).to.equal(pkg.version); 23 | }); 24 | 25 | it('Should execute plugins', async () => { 26 | let meta: any = {}; 27 | let browser: HackiumBrowser | null = null; 28 | let plugin: Plugin = { 29 | preInit: function (_hackium, options) { 30 | meta.pluginPreInit = true; 31 | expect(options.devtools).to.equal(false); 32 | expect(_hackium).to.be.instanceOf(Hackium); 33 | options.headless = true; 34 | }, 35 | postInit: function (_hackium, options) { 36 | meta.pluginPostInit = true; 37 | expect(options.headless).to.equal(true); 38 | expect(_hackium).to.be.instanceOf(Hackium); 39 | }, 40 | preLaunch: function (_hackium, launchOptions) { 41 | meta.pluginPreLaunch = true; 42 | expect(launchOptions.headless).to.equal(true); 43 | launchOptions.headless = false; 44 | expect(_hackium).to.equal(hackium); 45 | }, 46 | postLaunch: function (_hackium, _browser, finalLaunchOptions) { 47 | meta.pluginPostLaunch = true; 48 | expect(finalLaunchOptions.headless).to.equal(false); 49 | expect(_hackium).to.equal(hackium); 50 | expect(_browser).to.be.instanceOf(HackiumBrowser); 51 | }, 52 | prePageCreate: function (_browser) { 53 | meta.pluginPrePageCreate = true; 54 | expect(_browser).to.be.instanceOf(HackiumBrowser); 55 | }, 56 | postPageCreate: function (_browser, _page) { 57 | meta.pluginPostPageCreate = true; 58 | expect(_browser).to.be.instanceOf(HackiumBrowser); 59 | expect(_page).to.be.instanceOf(HackiumPage); 60 | //@ts-ignore 61 | _page.customProp = true; 62 | }, 63 | }; 64 | 65 | expect(meta.pluginPreInit).to.be.undefined; 66 | expect(meta.pluginPostInit).to.be.undefined; 67 | hackium = new Hackium({ 68 | headless: false, 69 | plugins: [plugin], 70 | }); 71 | expect(meta.pluginPreInit).to.be.true; 72 | expect(meta.pluginPostInit).to.be.true; 73 | expect(meta.pluginPreLaunch).to.be.undefined; 74 | expect(meta.pluginPostLaunch).to.be.undefined; 75 | browser = await hackium.launch(); 76 | expect(meta.pluginPreLaunch).to.be.true; 77 | expect(meta.pluginPostLaunch).to.be.true; 78 | expect(meta.pluginPrePageCreate).to.be.true; 79 | expect(meta.pluginPostPageCreate).to.be.true; 80 | const [page] = await browser.pages(); 81 | //@ts-ignore 82 | expect(page.customProp).to.be.true; 83 | const page2 = await browser.newPage(); 84 | //@ts-ignore 85 | expect(page2.customProp).to.be.true; 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /test/headless.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Hackium } from '../src'; 3 | import { HackiumBrowser } from '../src/hackium/hackium-browser'; 4 | 5 | describe('headless', function () { 6 | let hackium: Hackium | null = null; 7 | 8 | afterEach(async () => { 9 | if (hackium) await hackium.close(); 10 | }); 11 | 12 | it('Should launch headlessly', async () => { 13 | hackium = new Hackium({ 14 | headless: true, 15 | }); 16 | const browser = await hackium.launch(); 17 | expect(browser).to.be.instanceOf(HackiumBrowser); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /test/helper.ts: -------------------------------------------------------------------------------- 1 | import DEBUG from 'debug'; 2 | import yargs from 'yargs'; 3 | import { Arguments, cliArgsDefinition } from '../src/arguments'; 4 | export const debug = DEBUG('hackium:test'); 5 | 6 | export function getArgs(argv: string): Arguments { 7 | debug('simulating arguments %o', argv); 8 | const y = yargs.options(cliArgsDefinition()); 9 | const parsed = y.parse(argv); 10 | debug('parsed arguments as %O', parsed); 11 | return parsed; 12 | } 13 | -------------------------------------------------------------------------------- /test/upstream.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Hackium } from '../src'; 3 | import { HackiumBrowser } from '../src/hackium/hackium-browser'; 4 | 5 | describe('upstream', function () { 6 | let hackium: Hackium | null = null; 7 | 8 | afterEach(async () => { 9 | if (hackium) await hackium.close(); 10 | }); 11 | 12 | // Don't need to run these. TypeScript will check types on compilation. 13 | xdescribe('types', async function () { 14 | let browser: HackiumBrowser; 15 | beforeEach(async () => { 16 | hackium = new Hackium(); 17 | browser = await hackium.launch(); 18 | }); 19 | afterEach(async () => { 20 | await browser.close(); 21 | }); 22 | it('page.goto should not need options', async () => { 23 | const [page] = await browser.pages(); 24 | await page.goto('http://example.com'); 25 | }); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /test/util/file.test.ts: -------------------------------------------------------------------------------- 1 | import { resolve, watch, remove, write, read } from '../../src/util/file'; 2 | import { expect } from 'chai'; 3 | import { debug } from '../helper'; 4 | import { defer, delay } from '../../src/util/promises'; 5 | import path from 'path'; 6 | 7 | describe('file', function () { 8 | it('.resolve() should resolve relative paths with passed pwd', async () => { 9 | const fullPath = resolve(['.', 'foo', 'bar.js'], '/a'); 10 | expect(fullPath).to.equal(path.resolve('/a/foo/bar.js')); 11 | }); 12 | it('.resolve() should resolve absolute paths regardless of pwd', async () => { 13 | const fullPath = resolve(['/foo/bar/baz.js'], '/a'); 14 | expect(fullPath).to.equal(path.resolve('/foo/bar/baz.js')); 15 | }); 16 | it('.read() reads a file', async () => { 17 | const fullPath = resolve(['..', '_fixtures', 'dummy.txt'], __dirname); 18 | const contents = 'dummy contents'; 19 | await write(fullPath, contents); 20 | const readContents = await read(fullPath); 21 | expect(readContents).to.equal(contents); 22 | await remove(fullPath); 23 | }); 24 | it('.read() should take in file parts like resolve()', async () => { 25 | const fullPath = resolve(['..', '_fixtures', 'dummy.txt'], __dirname); 26 | const contents = 'dummy contents'; 27 | await write(fullPath, contents); 28 | const readContents = await read(['..', '_fixtures', 'dummy.txt'], __dirname); 29 | expect(readContents).to.equal(contents); 30 | await remove(fullPath); 31 | }); 32 | it('.watch() files and calls a callback on change', async () => { 33 | const fullPath = resolve(['..', '_fixtures', 'dummy.txt'], __dirname); 34 | const contents = 'dummy contents'; 35 | await write(fullPath, contents); 36 | const promise = new Promise((resolve, reject) => watch(fullPath, resolve)); 37 | await delay(100); 38 | await write(fullPath, contents + contents); 39 | return promise.finally(() => remove(fullPath)); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /test/util/logger.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import Logger from '../../src/util/logger'; 3 | 4 | describe('logger', function () { 5 | it('should format log, warn, and error like debug', async () => { 6 | const logger = new Logger('test'); 7 | const warn = logger.format('warn %o', 'foo'); 8 | const error = logger.format('error %o', 100); 9 | expect(warn).to.match(/'foo'/); 10 | expect(error).to.match(/100/); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /test/util/movement.test.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from '../../src/util/file'; 2 | import { expect } from 'chai'; 3 | import { SimulatedMovement, Vector } from '../../src/util/movement'; 4 | import path from 'path'; 5 | 6 | describe('movement', function () { 7 | it('first and last points should be at start and end', async () => { 8 | const from = new Vector(10, 10); 9 | const to = new Vector(900, 900); 10 | const points = new SimulatedMovement(4, 2, 5).generatePath(from, to); 11 | const first = points.shift() || []; 12 | const last = points.pop() || []; 13 | expect(first[0]).to.equal(from.x); 14 | expect(first[1]).to.equal(from.y); 15 | expect(last[0]).to.equal(to.x); 16 | expect(last[1]).to.equal(to.y); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /test/util/object.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { merge } from '../../src/util/object'; 3 | 4 | describe('object', function () { 5 | it('.merge() should take varargs', async () => { 6 | const other1 = { 7 | a: 1, 8 | b: 'not this', 9 | }; 10 | const other2 = { 11 | b: 2, 12 | }; 13 | const final = merge({}, other1, other2); 14 | const expected = { 15 | a: 1, 16 | b: 2, 17 | }; 18 | expect(final).to.deep.equal(expected); 19 | }); 20 | it('.merge() should not mutate objects', async () => { 21 | const dest = { 22 | a: 1, 23 | }; 24 | const other = { 25 | b: 2, 26 | }; 27 | const final = merge(dest, other); 28 | const expected = { 29 | a: 1, 30 | b: 2, 31 | }; 32 | expect(final).to.deep.equal(expected); 33 | expect(final).to.not.equal(dest); 34 | expect('b' in dest).to.be.false; 35 | expect('a' in other).to.be.false; 36 | }); 37 | it('.merge() should merge objects', async () => { 38 | const dest = { 39 | undef1: undefined, 40 | undef2: undefined, 41 | defined1: 'defined', 42 | defined2: 'defined', 43 | array1: ['array1'], 44 | array2: [], 45 | }; 46 | const other = { 47 | undef1: 'defined', 48 | defined1: 'redefined', 49 | defined3: 'defined', 50 | array1: ['otherArray1'], 51 | array2: ['otherArray2'], 52 | array3: ['otherArray3'], 53 | }; 54 | const mutatedDest = merge(dest, other); 55 | const expected = { 56 | undef1: 'defined', 57 | defined1: 'redefined', 58 | defined2: 'defined', 59 | defined3: 'defined', 60 | array1: ['array1', 'otherArray1'], 61 | array2: ['otherArray2'], 62 | array3: ['otherArray3'], 63 | }; 64 | expect(mutatedDest).to.deep.equal(expected); 65 | }); 66 | it('.merge() cliArgs should override file config when defined', () => { 67 | const cliArgs = { 68 | devtools: false, 69 | inject: [], 70 | interceptor: [], 71 | execute: [], 72 | headless: false, 73 | pwd: '/Users/jsoverson/development/src/hackium', 74 | _: [], 75 | adblock: false, 76 | env: [], 77 | I: [], 78 | e: [], 79 | i: [], 80 | userDataDir: '/Users/jsoverson/.hackium/chromium', 81 | U: '/Users/jsoverson/.hackium/chromium', 82 | 'user-data-dir': '/Users/jsoverson/.hackium/chromium', 83 | d: true, 84 | watch: false, 85 | w: false, 86 | plugin: [], 87 | p: [], 88 | timeout: 30000, 89 | t: 30000, 90 | chromeOutput: false, 91 | 'chrome-output': false, 92 | config: '', 93 | c: '', 94 | $0: '../../../.npm/_npx/13379/bin/hackium', 95 | plugins: [], 96 | }; 97 | let config = { 98 | url: 'https://example.com', 99 | devtools: false, 100 | inject: [], 101 | interceptor: [], 102 | execute: [], 103 | headless: false, 104 | pwd: '/Users/jsoverson/development/src/hackium', 105 | }; 106 | config = merge(config, cliArgs); 107 | expect(config.url).to.equal('https://example.com'); 108 | expect(config.devtools).to.equal(false); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /test/util/promises.test.ts: -------------------------------------------------------------------------------- 1 | import { onlySettled, defer, delay, waterfallMap } from '../../src/util/promises'; 2 | import { expect } from 'chai'; 3 | 4 | describe('Promises', function () { 5 | it('onlySettled() should act like allSettled + filtering out rejections', async () => { 6 | const results = await onlySettled([Promise.resolve(1), Promise.reject(2), Promise.resolve(3)]); 7 | expect(results).to.deep.equal([1, 3]); 8 | }); 9 | it('defer() should yield value after timeout', async () => { 10 | const value = await defer(() => 2, 100); 11 | expect(value).to.equal(2); 12 | }); 13 | it('defer() should take non-function as input', async () => { 14 | const value = await defer(22, 100); 15 | expect(value).to.equal(22); 16 | }); 17 | it('delay() should resolve after the passed timeout', async () => { 18 | const value = await delay(100); 19 | expect(value).to.be.greaterThan(99); 20 | }); 21 | it('waterfallMap() should run a series of promises in order', async function () { 22 | const array = ['something', 1, { other: 'this' }]; 23 | 24 | const arrayIndex: any[] = []; 25 | 26 | function promiseGenerator(el: any, i: number) { 27 | return new Promise((res, rej) => { 28 | setTimeout(() => res(el), Math.random() * 200); 29 | }); 30 | } 31 | function promiseGeneratorIndex(el: any, i: number) { 32 | return new Promise((res, rej) => { 33 | setTimeout(() => { 34 | arrayIndex[i] = el; 35 | res(); 36 | }, Math.random() * 200); 37 | }); 38 | } 39 | const newArray = await waterfallMap(array, promiseGenerator); 40 | expect(newArray).to.deep.equal(array); 41 | await waterfallMap(array, promiseGeneratorIndex); 42 | expect(arrayIndex).to.deep.equal(array); 43 | expect(arrayIndex).to.deep.equal(newArray); 44 | }); 45 | it('waterfallMap() should tolerate non-promise values', async function () { 46 | const array = ['something', 1, { other: 'this' }]; 47 | 48 | const newArray = await waterfallMap(array, (x) => x); 49 | expect(newArray).to.deep.equal(array); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*", "test/**/*"], 3 | "compilerOptions": { 4 | /* Basic Options */ 5 | // "incremental": true, /* Enable incremental compilation */ 6 | "target": "ES2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 7 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 8 | "lib": ["ES2020", "DOM"] /* Specify library files to be included in the compilation. */, 9 | // "allowJs": true, /* Allow javascript files to be compiled. */ 10 | // "checkJs": true, /* Report errors in .js files. */ 11 | // "resolveJsonModule": true, 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | "sourceMap": true /* Generates corresponding '.map' file. */, 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "./dist" /* Redirect output structure to the directory. */, 18 | "rootDir": "./" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | /* Strict Type-Checking Options */ 27 | "strict": true /* Enable all strict type-checking options. */, 28 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 29 | // "strictNullChecks": true, /* Enable strict null checks. */ 30 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 31 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 32 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 33 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 34 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 35 | /* Additional Checks */ 36 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 40 | /* Module Resolution Options */ 41 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 42 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 43 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 44 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 45 | // "typeRoots": [], /* List of folders to include type definitions from. */ 46 | "typeRoots": ["./test/@types", "./node_modules/@types"], 47 | // "types": [], /* Type declaration files to be included in compilation. */ 48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 49 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 51 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 52 | /* Source Map Options */ 53 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 54 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 55 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 56 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 57 | /* Experimental Options */ 58 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 59 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 60 | /* Advanced Options */ 61 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 62 | } 63 | } 64 | --------------------------------------------------------------------------------