From 4bcb431a36b93dcf5bfddd42323b9d03e0fb64f2 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Thu, 3 Feb 2022 18:40:49 +0100 Subject: [PATCH] feat: implement runner API with extension points (#4) --- .eslintrc.cjs | 1 + package-lock.json | 358 ++++++++++++++++++++ package.json | 4 +- src/PuppeteerRunnerExtension.ts | 574 ++++++++++++++++++++++++++++++++ src/Runner.ts | 77 +++++ src/RunnerExtension.ts | 25 ++ src/main.ts | 1 + test/runner_test.ts | 53 +++ tsconfig.json | 3 +- 9 files changed, 1094 insertions(+), 2 deletions(-) create mode 100644 src/PuppeteerRunnerExtension.ts create mode 100644 src/Runner.ts create mode 100644 src/RunnerExtension.ts create mode 100644 test/runner_test.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 175f0c2e..14b64448 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -15,5 +15,6 @@ module.exports = { rules: { "require-jsdoc": 0, "no-redeclare": 0, + "valid-jsdoc": 0, // Figure jsdoc once we look into documentation. }, }; diff --git a/package-lock.json b/package-lock.json index 097967ab..1aab4883 100644 --- a/package-lock.json +++ b/package-lock.json @@ -135,6 +135,16 @@ "integrity": "sha512-Y86MAxASe25hNzlDbsviXl8jQHb0RDvKt4c40ZJQ1Don0AAL0STLZSs4N+6gLEO55pedy7r2cLwS+ZDxPm/2Bw==", "dev": true }, + "@types/yauzl": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.2.tgz", + "integrity": "sha512-8uALY5LTvSuHgloDVUvWP3pIauILm+8/0pDMokuDYIoNsOkSwd5AiHBTSEJjKTDcZr5z8UpgOWZkxBF4iJftoA==", + "dev": true, + "optional": true, + "requires": { + "@types/node": "*" + } + }, "@typescript-eslint/eslint-plugin": { "version": "5.10.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.10.1.tgz", @@ -254,6 +264,15 @@ "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", "dev": true }, + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "requires": { + "debug": "4" + } + }, "ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -327,12 +346,29 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true + }, "binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", "dev": true }, + "bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "requires": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -358,6 +394,22 @@ "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", "dev": true }, + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=", + "dev": true + }, "callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -417,6 +469,12 @@ "readdirp": "~3.6.0" } }, + "chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true + }, "cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", @@ -496,6 +554,12 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "devtools-protocol": { + "version": "0.0.948846", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.948846.tgz", + "integrity": "sha512-5fGyt9xmMqUl2VI7+rnUkKCiAQIpLns8sfQtTENy5L70ktbNw0Z3TFJ1JoFNYdx/jffz4YXU45VF75wKZD7sZQ==", + "dev": true + }, "diff": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", @@ -526,6 +590,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "requires": { + "once": "^1.4.0" + } + }, "escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -719,6 +792,18 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, + "extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "requires": { + "@types/yauzl": "^2.9.1", + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + } + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -765,6 +850,15 @@ "reusify": "^1.0.4" } }, + "fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=", + "dev": true, + "requires": { + "pend": "~1.2.0" + } + }, "file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -815,6 +909,12 @@ "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==", "dev": true }, + "fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -846,6 +946,15 @@ "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", "dev": true }, + "get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, "glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", @@ -910,6 +1019,22 @@ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "dev": true }, + "https-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", + "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", + "dev": true, + "requires": { + "agent-base": "6", + "debug": "4" + } + }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true + }, "ignore": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", @@ -1107,6 +1232,12 @@ "brace-expansion": "^1.1.7" } }, + "mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true + }, "mocha": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/mocha/-/mocha-9.2.0.tgz", @@ -1174,6 +1305,15 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "dev": true, + "requires": { + "whatwg-url": "^5.0.0" + } + }, "normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -1221,6 +1361,12 @@ "p-limit": "^3.0.2" } }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, "parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -1260,12 +1406,66 @@ "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", "dev": true }, + "pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=", + "dev": true + }, "picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + } + } + }, "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -1287,12 +1487,65 @@ "fast-diff": "^1.1.2" } }, + "progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true + }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", "dev": true }, + "puppeteer": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-13.1.3.tgz", + "integrity": "sha512-nqcJNThLUG0Dgo++2mMtGR2FCyg7olJJhj/rm0A65muyN3nrH6lGvnNRzEaNmSnHWvjaDIG9ox5kxQB+nXTg5A==", + "dev": true, + "requires": { + "debug": "4.3.2", + "devtools-protocol": "0.0.948846", + "extract-zip": "2.0.1", + "https-proxy-agent": "5.0.0", + "node-fetch": "2.6.7", + "pkg-dir": "4.2.0", + "progress": "2.0.3", + "proxy-from-env": "1.1.0", + "rimraf": "3.0.2", + "tar-fs": "2.1.1", + "unbzip2-stream": "1.4.3", + "ws": "8.2.3" + }, + "dependencies": { + "debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + } + } + }, "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -1308,6 +1561,17 @@ "safe-buffer": "^5.1.0" } }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, "readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1415,6 +1679,15 @@ "strip-ansi": "^6.0.1" } }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "requires": { + "safe-buffer": "~5.2.0" + } + }, "strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -1439,12 +1712,43 @@ "has-flag": "^4.0.0" } }, + "tar-fs": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", + "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "dev": true, + "requires": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "requires": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + } + }, "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "dev": true }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", + "dev": true + }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -1454,6 +1758,12 @@ "is-number": "^7.0.0" } }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=", + "dev": true + }, "ts-node": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.4.0.tgz", @@ -1524,6 +1834,16 @@ "integrity": "sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==", "dev": true }, + "unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "dev": true, + "requires": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, "uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -1533,12 +1853,34 @@ "punycode": "^2.1.0" } }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true + }, "v8-compile-cache": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", "dev": true }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=", + "dev": true + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", + "dev": true, + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -1577,6 +1919,12 @@ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true }, + "ws": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", + "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==", + "dev": true + }, "y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -1622,6 +1970,16 @@ "is-plain-obj": "^2.1.0" } }, + "yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=", + "dev": true, + "requires": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index cef8a7f5..4ba5863d 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,10 @@ "eslint-plugin-prettier": "4.0.0", "mocha": "9.2.0", "prettier": "2.5.1", + "puppeteer": "13.1.3", "rimraf": "3.0.2", "ts-node": "10.4.0", "typescript": "4.5.5" - } + }, + "dependencies": {} } diff --git a/src/PuppeteerRunnerExtension.ts b/src/PuppeteerRunnerExtension.ts new file mode 100644 index 00000000..677f8edc --- /dev/null +++ b/src/PuppeteerRunnerExtension.ts @@ -0,0 +1,574 @@ +/** + Copyright 2022 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { RunnerExtension } from "./RunnerExtension.js"; +import { UserFlow, Step, WaitForElementStep, Selector, Key } from "./Schema.js"; +import { + assertAllStepTypesAreHandled, + typeableInputTypes, +} from "./SchemaUtils.js"; + +export class PuppeteerRunnerExtension implements RunnerExtension { + #browser: Browser; + #page: Page; + #timeout: number; + + constructor(browser: Browser, page: Page, opts?: { timeout?: number }) { + this.#browser = browser; + this.#page = page; + this.#timeout = opts?.timeout || 5000; + } + + async runStep(step: Step, flow: UserFlow): Promise { + const timeout = step.timeout || this.#timeout; + const page = this.#page; + const browser = this.#browser; + const waitForVisible = true; + + const targetPage = await getTargetPageForStep(browser, page, step, timeout); + let targetFrame: Frame | null = null; + if (!targetPage) { + const frames = page.frames(); + for (const f of frames) { + if (f.isOOPFrame() && f.url() === step.target) { + targetFrame = f; + } + } + } + const pageOrFrame = targetPage || targetFrame; + if (!pageOrFrame) { + throw new Error("Target is not found for step: " + JSON.stringify(step)); + } + + const frame = await getFrame(pageOrFrame, step); + + const assertedEventsPromise = waitForEvents(pageOrFrame, step, timeout); + + switch (step.type) { + case "click": + { + const element = await waitForSelectors(step.selectors, frame, { + timeout, + visible: waitForVisible, + }); + if (!element) { + throw new Error("Could not find element: " + step.selectors[0]); + } + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ + offset: { + x: step.offsetX, + y: step.offsetY, + }, + }); + await element.dispose(); + } + break; + case "emulateNetworkConditions": + { + await page.emulateNetworkConditions(step); + } + break; + case "keyDown": + { + await page.keyboard.down(step.key); + await page.waitForTimeout(100); + } + break; + case "keyUp": + { + await page.keyboard.up(step.key); + await page.waitForTimeout(100); + } + break; + case "close": + { + if ("close" in pageOrFrame) { + await pageOrFrame.close(); + } + } + break; + case "change": + { + const element = await waitForSelectors(step.selectors, frame, { + timeout, + visible: waitForVisible, + }); + await scrollIntoViewIfNeeded(element, timeout); + const inputType = await element.evaluate( + (el: Element) => (el as HTMLInputElement).type + ); + if (typeableInputTypes.has(inputType)) { + await element.evaluate((el: Element) => { + (el as HTMLInputElement).value = ""; + }); + await element.type(step.value); + } else { + await element.focus(); + await element.evaluate((el: Element, value: string) => { + const input = el as HTMLInputElement; + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + }, step.value); + } + await element.dispose(); + } + break; + case "setViewport": { + if ("setViewport" in pageOrFrame) { + await pageOrFrame.setViewport(step); + } + break; + } + case "scroll": { + if ("selectors" in step) { + const element = await waitForSelectors(step.selectors, frame, { + timeout, + visible: waitForVisible, + }); + await scrollIntoViewIfNeeded(element, timeout); + await element.evaluate( + (e: Element, x: number, y: number) => { + e.scrollTop = y; + e.scrollLeft = x; + }, + step.x || 0, + step.y || 0 + ); + await element.dispose(); + } else { + await frame.evaluate( + (x, y) => { + window.scroll(x, y); + }, + step.x || 0, + step.y || 0 + ); + } + break; + } + case "navigate": { + await frame.goto(step.url); + break; + } + case "waitForElement": { + try { + await waitForElement(step, frame, timeout); + } catch (err) { + if ((err as Error).message === "Timed out") { + throw new Error( + "waitForElement timed out. The element(s) could not be found." + ); + } else { + throw err; + } + } + break; + } + case "waitForExpression": { + await frame.waitForFunction(step.expression, { + timeout, + }); + break; + } + case "customStep": { + // TODO implement these steps + break; + } + default: + assertAllStepTypesAreHandled(step); + } + + await assertedEventsPromise; + } +} + +async function getFrame(pageOrFrame: Page | Frame, step: Step): Promise { + let frame = + "mainFrame" in pageOrFrame ? pageOrFrame.mainFrame() : pageOrFrame; + if ("frame" in step && step.frame) { + for (const index of step.frame) { + frame = frame.childFrames()[index]; + } + } + return frame; +} + +async function getTargetPageForStep( + browser: Browser, + page: Page, + step: Step, + timeout: number +): Promise { + if (!step.target || step.target === "main") { + return page; + } + + const target = await browser.waitForTarget((t) => t.url() === step.target, { + timeout, + }); + const targetPage = await target.page(); + + if (!targetPage) { + return null; + } + + targetPage.setDefaultTimeout(timeout); + + return targetPage; +} + +async function waitForEvents( + targetPage: Page | Frame, + step: Step, + timeout: number +): Promise { + const promises: Promise[] = []; + if (step.assertedEvents) { + for (const event of step.assertedEvents) { + switch (event.type) { + case "navigation": { + promises.push( + targetPage.waitForNavigation({ + timeout, + }) + ); + continue; + } + default: + throw new Error(`Event type ${event.type} is not supported`); + } + } + } + await Promise.all(promises); +} + +async function waitForElement( + step: WaitForElementStep, + frame: Frame | Page, + timeout: number +): Promise { + const count = step.count || 1; + const operator = step.operator || ">="; + const comp = { + "==": (a: number, b: number): boolean => a === b, + ">=": (a: number, b: number): boolean => a >= b, + "<=": (a: number, b: number): boolean => a <= b, + }; + const compFn = comp[operator]; + await waitForFunction(async () => { + const elements = await querySelectorsAll(step.selectors, frame); + const result = compFn(elements.length, count); + await Promise.all(elements.map((element) => element.dispose())); + return result; + }, timeout); +} + +async function scrollIntoViewIfNeeded( + element: ElementHandle, + timeout: number +): Promise { + await waitForConnected(element, timeout); + const isInViewport = await element.isIntersectingViewport({ threshold: 0 }); + if (isInViewport) { + return; + } + await element.evaluate((element: Element) => { + element.scrollIntoView({ + block: "center", + inline: "center", + behavior: "auto", + }); + }); + await waitForInViewport(element, timeout); +} + +async function waitForConnected( + element: ElementHandle, + timeout: number +): Promise { + await waitForFunction(async () => { + return await element.evaluate((el: Element) => el.isConnected); + }, timeout); +} + +async function waitForInViewport( + element: ElementHandle, + timeout: number +): Promise { + await waitForFunction(async () => { + return await element.isIntersectingViewport({ threshold: 0 }); + }, timeout); +} + +interface WaitForOptions { + timeout: number; + visible: boolean; +} + +async function waitForSelectors( + selectors: Selector[], + frame: Frame, + options: WaitForOptions +): Promise> { + for (const selector of selectors) { + try { + return await waitForSelector(selector, frame, options); + } catch (err) { + console.error("error in waitForSelectors", err); + // TODO: report the error somehow + } + } + throw new Error( + "Could not find element for selectors: " + JSON.stringify(selectors) + ); +} + +async function waitForSelector( + selector: Selector, + frame: Frame, + options: WaitForOptions +): Promise> { + if (!Array.isArray(selector)) { + selector = [selector]; + } + if (!selector.length) { + throw new Error("Empty selector provided to waitForSelector"); + } + let element = null; + for (let i = 0; i < selector.length; i++) { + const part = selector[i]; + if (!element) { + element = await frame.waitForSelector(part, options); + } else { + const oldElement = element; + element = await element.waitForSelector(part, options); + await oldElement.dispose(); + } + if (!element) { + throw new Error("Could not find element: " + selector.join(">>")); + } + if (i < selector.length - 1) { + // if not the last part, try to navigate into shadowRoot + const oldElement = element; + element = ( + await element.evaluateHandle((el: Element) => + el.shadowRoot ? el.shadowRoot : el + ) + ).asElement(); + await oldElement.dispose(); + } + } + if (!element) { + throw new Error("Could not find element: " + selector.join("|")); + } + return element; +} + +async function querySelectorsAll( + selectors: Selector[], + frame: Frame | Page +): Promise[]> { + for (const selector of selectors) { + const result = await querySelectorAll(selector, frame); + if (result.length) { + return result; + } + } + return []; +} + +async function querySelectorAll( + selector: Selector, + frame: Frame | Page +): Promise[]> { + if (!Array.isArray(selector)) { + selector = [selector]; + } + if (!selector.length) { + throw new Error("Empty selector provided to querySelectorAll"); + } + let elements: ElementHandle[] = []; + for (let i = 0; i < selector.length; i++) { + const part = selector[i]; + if (i === 0) { + elements = await frame.$$(part); + } else { + const tmpElements = elements; + elements = []; + for (const el of tmpElements) { + elements.push(...(await el.$$(part))); + await el.dispose(); + } + } + if (elements.length === 0) { + return []; + } + if (i < selector.length - 1) { + const tmpElements: ElementHandle[] = []; + // if not the last part, try to navigate into shadowRoot + for (const el of elements) { + const newEl = ( + await el.evaluateHandle((el: Element) => + el.shadowRoot ? el.shadowRoot : el + ) + ).asElement(); + if (newEl) { + tmpElements.push(newEl); + } + await el.dispose(); + } + elements = tmpElements; + } + } + return elements; +} + +async function waitForFunction( + fn: () => unknown, + timeout: number +): Promise { + let isActive = true; + setTimeout(() => { + isActive = false; + }, timeout); + while (isActive) { + const result = await fn(); + if (result) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error("Timed out"); +} + +// Partial description of Puppeteer API below to allow runtime dependencies. +type EvaluateFn = string | ((arg1: T, ...args: any[]) => any); +type EvaluateFnReturnType = T extends ( + ...args: any[] +) => infer R + ? R + : any; +type SerializableOrJSHandle = Serializable | JSHandle; +type JSONArray = readonly Serializable[]; +interface JSONObject { + [key: string]: Serializable; +} +type Serializable = + | number + | string + | boolean + | null + | BigInt + | JSONArray + | JSONObject; +type UnwrapPromiseLike = T extends PromiseLike ? U : T; + +interface Target { + url(): string; + page(): Promise; +} + +export interface WaitForTargetOptions { + /** + * Maximum wait time in milliseconds. Pass `0` to disable the timeout. + * @defaultValue 30 seconds. + */ + timeout?: number; +} + +interface Browser { + waitForTarget( + predicate: (x: Target) => boolean, + options?: WaitForTargetOptions + ): Promise; +} + +interface JSHandle { + // TODO: fix the type of evaluate. + evaluate>( + ...args: any[] + ): Promise>>; + // TODO: fix the type of evaluateHandle. + evaluateHandle(...args: any[]): Promise>; + asElement(): ElementHandle | null; +} + +interface ElementHandle + extends JSHandle { + isIntersectingViewport(opts: { threshold: number }): Promise; + dispose(): Promise; + click(opts: { + offset: { + x: number; + y: number; + }; + }): Promise; + type(input: string): Promise; + focus(): Promise; + $$( + selector: string + ): Promise>>; + waitForSelector( + selector: string, + options?: { + visible?: boolean; + hidden?: boolean; + timeout?: number; + } + ): Promise | null>; + asElement(): ElementHandle | null; +} + +interface Page { + setDefaultTimeout(timeout: number): void; + frames(): Frame[]; + emulateNetworkConditions(conditions: any): void; + keyboard: { + type(value: string): void; + down(key: Key): void; + up(key: Key): void; + }; + waitForTimeout(timeout: number): Promise; + close(): Promise; + setViewport(viewport: any): Promise; + mainFrame(): Frame; + waitForNavigation(opts: { timeout: number }): Promise; + $$( + selector: string + ): Promise>>; +} + +interface Frame { + waitForSelector( + part: string, + options: WaitForOptions + ): Promise | null>; + isOOPFrame(): boolean; + url(): string; + evaluate( + pageFunction: T, + ...args: SerializableOrJSHandle[] + ): Promise>>; + goto(url: string): Promise; + waitForFunction(expr: string, opts: { timeout: number }): Promise; + childFrames(): Frame[]; + waitForNavigation(opts: { timeout: number }): Promise; + $$( + selector: string + ): Promise>>; +} diff --git a/src/Runner.ts b/src/Runner.ts new file mode 100644 index 00000000..1da85b56 --- /dev/null +++ b/src/Runner.ts @@ -0,0 +1,77 @@ +/** + Copyright 2022 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { PuppeteerRunnerExtension } from "./PuppeteerRunnerExtension.js"; +import { RunnerExtension } from "./RunnerExtension.js"; +import { UserFlow } from "./Schema.js"; + +export class Runner { + #flow: UserFlow; + #extension: RunnerExtension; + #nextStep = 0; + + constructor(flow: UserFlow, extension: RunnerExtension) { + this.#flow = flow; + this.#extension = extension; + } + + /** + * @param stepIdx Run the flow up until the step with the `stepIdx` index. + */ + async run(stepIdx?: number): Promise { + if (stepIdx === undefined) { + stepIdx = this.#flow.steps.length; + } + if (this.#nextStep === 0) { + await this.#extension.beforeAllSteps?.(this.#flow); + } + while ( + this.#nextStep < stepIdx && + this.#nextStep < this.#flow.steps.length + ) { + await this.#extension.beforeEachStep?.( + this.#flow.steps[this.#nextStep], + this.#flow + ); + await this.#extension.runStep( + this.#flow.steps[this.#nextStep], + this.#flow + ); + await this.#extension.afterEachStep?.( + this.#flow.steps[this.#nextStep], + this.#flow + ); + } + if (this.#nextStep >= this.#flow.steps.length) { + await this.#extension.afterAllSteps?.(this.#flow); + } + } +} + +export async function createRunner( + flow: UserFlow, + extension?: RunnerExtension +) { + if (!extension) { + const puppeteer = await import("puppeteer"); + const browser = await puppeteer.launch({ + headless: true, + }); + const page = await browser.newPage(); + extension = new PuppeteerRunnerExtension(browser, page); + } + return new Runner(flow, extension); +} diff --git a/src/RunnerExtension.ts b/src/RunnerExtension.ts new file mode 100644 index 00000000..a57338b0 --- /dev/null +++ b/src/RunnerExtension.ts @@ -0,0 +1,25 @@ +/** + Copyright 2022 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { UserFlow, Step } from "./Schema.js"; + +export interface RunnerExtension { + beforeAllSteps?(flow: UserFlow): Promise; + afterAllSteps?(flow: UserFlow): Promise; + beforeEachStep?(step: Step, flow: UserFlow): Promise; + runStep(step: Step, flow: UserFlow): Promise; + afterEachStep?(step: Step, flow: UserFlow): Promise; +} diff --git a/src/main.ts b/src/main.ts index a3110248..8a03e3b4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,3 +18,4 @@ export { UserFlow } from "./Schema.js"; export { parse } from "./SchemaUtils.js"; export { StringifyExtension } from "./StringifyExtension.js"; export { stringify } from "./stringify.js"; +export { createRunner } from "./Runner.js"; diff --git a/test/runner_test.ts b/test/runner_test.ts new file mode 100644 index 00000000..b1b5a626 --- /dev/null +++ b/test/runner_test.ts @@ -0,0 +1,53 @@ +/** + Copyright 2022 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { createRunner } from "../src/Runner.js"; +import { PuppeteerRunnerExtension } from "../src/PuppeteerRunnerExtension.js"; +import puppeteer from "puppeteer"; + +describe("Runner", () => { + let browser: puppeteer.Browser; + let page: puppeteer.Page; + + before(async () => { + browser = await puppeteer.launch({ + headless: true, + }); + }); + + beforeEach(async () => { + page = await browser.newPage(); + }); + + afterEach(async () => { + await page.close(); + }); + + after(async () => { + await browser.close(); + }); + + it("should run an empty flow using Puppeteer", async () => { + const runner = await createRunner( + { + title: "test", + steps: [], + }, + new PuppeteerRunnerExtension(browser, page) + ); + await runner.run(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 4ac0a79a..cf21caf4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,8 @@ "strict": true, "skipLibCheck": false, "outDir": "lib", - "declaration": true + "declaration": true, + "moduleResolution": "Node", }, "include": ["src/**/*"], }