From 9cfbd92009c90da7c4388cecd457a93ac7e8e97d Mon Sep 17 00:00:00 2001 From: Bronley Plumb Date: Wed, 3 Jun 2026 15:31:46 -0400 Subject: [PATCH 01/13] Migrate from postman-request to needle Replace the unmaintained `postman-request` dependency with `needle`. To keep this a non-breaking change, a thin compatibility shim (`src/request.ts`) reconstructs the `request`/`postman-request`-style response shape that roku-deploy exposes on results and thrown errors (`results.response.statusCode`, `results.response.headers`, `results.response.request.host`, string `results.body`). The shim handles three quirks discovered while validating against a real Roku device: needle rejects empty multipart values (request dropped them), needle returns a Buffer body under `parse_response: false` (request returned a string), and needle emits an intermediate 401 `response` event during the streaming digest dance (request only surfaced the final response). It also only enables multipart when form data is actually present, so bodyless POSTs (e.g. ECP keypress) keep working. Adds full real-device test coverage over every networking-enabled RokuDeploy method in `device.spec.ts`, with structure/type-tolerant assertions. Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 933 +++-------------------------------------- package.json | 3 +- src/RokuDeploy.spec.ts | 4 +- src/RokuDeploy.ts | 3 +- src/device.spec.ts | 352 +++++++++++++--- src/request.ts | 260 ++++++++++++ 6 files changed, 621 insertions(+), 934 deletions(-) create mode 100644 src/request.ts diff --git a/package-lock.json b/package-lock.json index 4d99376..5dfa86f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,8 +21,8 @@ "lodash": "^4.17.21", "micromatch": "^4.0.4", "moment": "^2.29.1", + "needle": "^3.5.0", "parse-ms": "^2.1.0", - "postman-request": "^2.88.1-postman.48", "semver": "^7.7.3", "temp-dir": "^2.0.0", "xml2js": "^0.5.0" @@ -37,6 +37,7 @@ "@types/lodash": "^4.14.200", "@types/micromatch": "^4.0.2", "@types/mocha": "^10.0.0", + "@types/needle": "^3.3.0", "@types/node": "^18.19.0", "@types/semver": "^7.7.1", "@types/sinon": "^10.0.4", @@ -627,53 +628,6 @@ "node": ">= 8" } }, - "node_modules/@postman/form-data": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@postman/form-data/-/form-data-3.1.1.tgz", - "integrity": "sha512-vjh8Q2a8S6UCm/KKs31XFJqEEgmbjBmpPNVV2eVav6905wyFAwaUOBGA1NPBI4ERH9MMZc6w0umFgM6WbEPMdg==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@postman/tough-cookie": { - "version": "4.1.3-postman.1", - "resolved": "https://registry.npmjs.org/@postman/tough-cookie/-/tough-cookie-4.1.3-postman.1.tgz", - "integrity": "sha512-txpgUqZOnWYnUHZpHjkfb0IwVH4qJmyq77pPnJLlfhMtdCLMFTEeQHlzQiK906aaNCe4NEB5fGJHo9uzGbFMeA==", - "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@postman/tough-cookie/node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/@postman/tunnel-agent": { - "version": "0.6.8", - "resolved": "https://registry.npmjs.org/@postman/tunnel-agent/-/tunnel-agent-0.6.8.tgz", - "integrity": "sha512-2U42SmZW5G+suEcS++zB94sBWNO4qD4bvETGFRFDTqSpYl5ksfjcPqzYpgQgXgUmb6dfz+fAGbkcRamounGm0w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/@sinonjs/commons": { "version": "1.8.3", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", @@ -793,6 +747,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/needle": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@types/needle/-/needle-3.3.0.tgz", + "integrity": "sha512-UFIuc1gdyzAqeVUYpSL+cliw2MmU/ZUhVZKE7Zo4wPbgc8hbljeKSnn6ls6iG8r5jpegPXLUIhJ+Wb2kLVs8cg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "18.19.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.0.tgz", @@ -1031,15 +994,6 @@ "node": ">=0.4.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -1154,24 +1108,6 @@ "node": ">=8" } }, - "node_modules/asn1": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", - "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": "~2.1.0" - } - }, - "node_modules/assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, "node_modules/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -1274,34 +1210,12 @@ "node": ">=12" } }, - "node_modules/aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", - "engines": { - "node": "*" - } - }, - "node_modules/aws4": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", - "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==" - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, - "node_modules/bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", - "license": "BSD-3-Clause", - "dependencies": { - "tweetnacl": "^0.14.3" - } - }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -1311,11 +1225,6 @@ "node": ">=8" } }, - "node_modules/bluebird": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", - "integrity": "sha512-UfFSr22dmHPQqPP9XWHRhq+gWnHCYguQGkXQlbyPtW5qTnhFWA8/iXg765tH0cAjy7l/zPJ1aBTO0g5XgA7kvQ==" - }, "node_modules/brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -1400,22 +1309,6 @@ "node": ">= 0.4" } }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1455,11 +1348,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" - }, "node_modules/chai": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.4.tgz", @@ -1628,18 +1516,6 @@ "node": ">= 8" } }, - "node_modules/dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/dateformat": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz", @@ -1657,6 +1533,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1772,16 +1649,6 @@ "dev": true, "license": "MIT" }, - "node_modules/ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", - "license": "MIT", - "dependencies": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" - } - }, "node_modules/electron-to-chromium": { "version": "1.3.877", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.877.tgz", @@ -2213,20 +2080,6 @@ "through": "^2.3.8" } }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" - }, - "node_modules/extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", - "engines": [ - "node >=0.6.0" - ], - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2392,14 +2245,6 @@ "node": ">=8.0.0" } }, - "node_modules/forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", - "engines": { - "node": "*" - } - }, "node_modules/form-data": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz", @@ -2584,15 +2429,6 @@ "node": ">= 0.4" } }, - "node_modules/getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0" - } - }, "node_modules/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", @@ -2764,18 +2600,15 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, - "node_modules/http-signature": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz", - "integrity": "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==", - "license": "MIT", + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dependencies": { - "assert-plus": "^1.0.0", - "jsprim": "^2.0.2", - "sshpk": "^1.18.0" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { - "node": ">=0.10" + "node": ">=0.10.0" } }, "node_modules/ignore": { @@ -2842,15 +2675,6 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, - "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2914,7 +2738,8 @@ "node_modules/is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "dev": true }, "node_modules/is-unicode-supported": { "version": "0.1.0", @@ -2948,11 +2773,6 @@ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", "dev": true }, - "node_modules/isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==" - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", @@ -3129,12 +2949,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", - "license": "MIT" - }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -3147,12 +2961,6 @@ "node": ">=4" } }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "license": "(AFL-2.1 OR BSD-3-Clause)" - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -3165,11 +2973,6 @@ "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", "dev": true }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==" - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -3222,21 +3025,6 @@ "node": ">=10" } }, - "node_modules/jsprim": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", - "integrity": "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==", - "engines": [ - "node >=0.6.0" - ], - "license": "MIT", - "dependencies": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.4.0", - "verror": "1.10.0" - } - }, "node_modules/jszip": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.8.0.tgz", @@ -3642,6 +3430,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/natural-compare": { @@ -3650,6 +3439,21 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "node_modules/needle": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/needle/-/needle-3.5.0.tgz", + "integrity": "sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==", + "dependencies": { + "iconv-lite": "^0.6.3", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, "node_modules/nise": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.0.tgz", @@ -3907,26 +3711,6 @@ "node": ">=6" } }, - "node_modules/oauth-sign": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", - "engines": { - "node": "*" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4198,45 +3982,6 @@ "node": ">=8" } }, - "node_modules/postman-request": { - "version": "2.88.1-postman.48", - "resolved": "https://registry.npmjs.org/postman-request/-/postman-request-2.88.1-postman.48.tgz", - "integrity": "sha512-E32FGh8ig2KDvzo4Byi7Ibr+wK2gNKPSqXoNsvjdCHgDBxSK4sCUwv+aa3zOBUwfiibPImHMy0WdlDSSCTqTuw==", - "license": "Apache-2.0", - "dependencies": { - "@postman/form-data": "~3.1.1", - "@postman/tough-cookie": "~4.1.3-postman.1", - "@postman/tunnel-agent": "^0.6.8", - "aws-sign2": "~0.7.0", - "aws4": "^1.12.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "http-signature": "~1.4.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "^2.1.35", - "oauth-sign": "~0.9.0", - "qs": "~6.14.1", - "safe-buffer": "^5.1.2", - "socks-proxy-agent": "^8.0.5", - "stream-length": "^1.0.2", - "uuid": "^8.3.2" - }, - "engines": { - "node": ">= 16" - } - }, - "node_modules/postman-request/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4272,39 +4017,15 @@ "node": ">=0.4.0" } }, - "node_modules/psl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" - }, "node_modules/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, "engines": { "node": ">=6" } }, - "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4399,11 +4120,6 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "dev": true }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4517,78 +4233,6 @@ "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/signal-exit": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.5.tgz", @@ -4644,44 +4288,6 @@ "node": ">=8" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", - "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", - "license": "MIT", - "dependencies": { - "ip-address": "^10.1.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -4762,31 +4368,6 @@ "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", "dev": true }, - "node_modules/sshpk": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", - "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", - "license": "MIT", - "dependencies": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" - }, - "bin": { - "sshpk-conv": "bin/sshpk-conv", - "sshpk-sign": "bin/sshpk-sign", - "sshpk-verify": "bin/sshpk-verify" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/stream-combiner": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.2.2.tgz", @@ -4798,14 +4379,6 @@ "through": "~2.3.4" } }, - "node_modules/stream-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/stream-length/-/stream-length-1.0.2.tgz", - "integrity": "sha512-aI+qKFiwoDV4rsXiS7WRoCt+v2RX1nUj17+KJC5r2gfh5xoSJIfP6Y3Do/HtvesFcTSWthIuJ3l1cvKQY/+nZg==", - "dependencies": { - "bluebird": "^2.6.2" - } - }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -5014,12 +4587,6 @@ "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" } }, - "node_modules/tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", - "license": "Unlicense" - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -5105,15 +4672,6 @@ "punycode": "^2.1.0" } }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -5135,26 +4693,6 @@ "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", "dev": true }, - "node_modules/verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", - "engines": [ - "node >=0.6.0" - ], - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - } - }, - "node_modules/verror/node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "license": "MIT" - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5823,42 +5361,6 @@ "fastq": "^1.6.0" } }, - "@postman/form-data": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@postman/form-data/-/form-data-3.1.1.tgz", - "integrity": "sha512-vjh8Q2a8S6UCm/KKs31XFJqEEgmbjBmpPNVV2eVav6905wyFAwaUOBGA1NPBI4ERH9MMZc6w0umFgM6WbEPMdg==", - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - } - }, - "@postman/tough-cookie": { - "version": "4.1.3-postman.1", - "resolved": "https://registry.npmjs.org/@postman/tough-cookie/-/tough-cookie-4.1.3-postman.1.tgz", - "integrity": "sha512-txpgUqZOnWYnUHZpHjkfb0IwVH4qJmyq77pPnJLlfhMtdCLMFTEeQHlzQiK906aaNCe4NEB5fGJHo9uzGbFMeA==", - "requires": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" - }, - "dependencies": { - "universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==" - } - } - }, - "@postman/tunnel-agent": { - "version": "0.6.8", - "resolved": "https://registry.npmjs.org/@postman/tunnel-agent/-/tunnel-agent-0.6.8.tgz", - "integrity": "sha512-2U42SmZW5G+suEcS++zB94sBWNO4qD4bvETGFRFDTqSpYl5ksfjcPqzYpgQgXgUmb6dfz+fAGbkcRamounGm0w==", - "requires": { - "safe-buffer": "^5.0.1" - } - }, "@sinonjs/commons": { "version": "1.8.3", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", @@ -5977,6 +5479,15 @@ "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", "dev": true }, + "@types/needle": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@types/needle/-/needle-3.3.0.tgz", + "integrity": "sha512-UFIuc1gdyzAqeVUYpSL+cliw2MmU/ZUhVZKE7Zo4wPbgc8hbljeKSnn6ls6iG8r5jpegPXLUIhJ+Wb2kLVs8cg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/node": { "version": "18.19.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.0.tgz", @@ -6127,11 +5638,6 @@ "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", "dev": true }, - "agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==" - }, "aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -6220,19 +5726,6 @@ "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true }, - "asn1": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", - "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", - "requires": { - "safer-buffer": "~2.1.0" - } - }, - "assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==" - }, "assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -6307,41 +5800,18 @@ } } }, - "aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==" - }, - "aws4": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", - "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==" - }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, - "bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", - "requires": { - "tweetnacl": "^0.14.3" - } - }, "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 }, - "bluebird": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", - "integrity": "sha512-UfFSr22dmHPQqPP9XWHRhq+gWnHCYguQGkXQlbyPtW5qTnhFWA8/iXg765tH0cAjy7l/zPJ1aBTO0g5XgA7kvQ==" - }, "brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -6406,15 +5876,6 @@ "function-bind": "^1.1.2" } }, - "call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "requires": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - } - }, "callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -6433,11 +5894,6 @@ "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", "dev": true }, - "caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" - }, "chai": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.4.tgz", @@ -6576,14 +6032,6 @@ "which": "^2.0.1" } }, - "dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", - "requires": { - "assert-plus": "^1.0.0" - } - }, "dateformat": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz", @@ -6598,6 +6046,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "requires": { "ms": "^2.1.3" } @@ -6677,15 +6126,6 @@ "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", "dev": true }, - "ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", - "requires": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" - } - }, "electron-to-chromium": { "version": "1.3.877", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.877.tgz", @@ -7004,16 +6444,6 @@ "through": "^2.3.8" } }, - "extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" - }, - "extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==" - }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -7143,11 +6573,6 @@ "signal-exit": "^3.0.2" } }, - "forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==" - }, "form-data": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz", @@ -7264,14 +6689,6 @@ "es-object-atoms": "^1.0.0" } }, - "getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", - "requires": { - "assert-plus": "^1.0.0" - } - }, "glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", @@ -7384,14 +6801,12 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, - "http-signature": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz", - "integrity": "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==", + "iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "requires": { - "assert-plus": "^1.0.0", - "jsprim": "^2.0.2", - "sshpk": "^1.18.0" + "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "ignore": { @@ -7442,11 +6857,6 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, - "ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==" - }, "is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -7489,7 +6899,8 @@ "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "dev": true }, "is-unicode-supported": { "version": "0.1.0", @@ -7514,11 +6925,6 @@ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", "dev": true }, - "isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==" - }, "istanbul-lib-coverage": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", @@ -7659,22 +7065,12 @@ "esprima": "^4.0.0" } }, - "jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==" - }, "jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", "dev": true }, - "json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" - }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -7687,11 +7083,6 @@ "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", "dev": true }, - "json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==" - }, "json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -7727,17 +7118,6 @@ "through2": "^4.0.2" } }, - "jsprim": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", - "integrity": "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==", - "requires": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.4.0", - "verror": "1.10.0" - } - }, "jszip": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.8.0.tgz", @@ -8041,7 +7421,8 @@ "ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true }, "natural-compare": { "version": "1.4.0", @@ -8049,6 +7430,15 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "needle": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/needle/-/needle-3.5.0.tgz", + "integrity": "sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==", + "requires": { + "iconv-lite": "^0.6.3", + "sax": "^1.2.4" + } + }, "nise": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.0.tgz", @@ -8253,16 +7643,6 @@ } } }, - "oauth-sign": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" - }, - "object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" - }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -8465,40 +7845,6 @@ } } }, - "postman-request": { - "version": "2.88.1-postman.48", - "resolved": "https://registry.npmjs.org/postman-request/-/postman-request-2.88.1-postman.48.tgz", - "integrity": "sha512-E32FGh8ig2KDvzo4Byi7Ibr+wK2gNKPSqXoNsvjdCHgDBxSK4sCUwv+aa3zOBUwfiibPImHMy0WdlDSSCTqTuw==", - "requires": { - "@postman/form-data": "~3.1.1", - "@postman/tough-cookie": "~4.1.3-postman.1", - "@postman/tunnel-agent": "^0.6.8", - "aws-sign2": "~0.7.0", - "aws4": "^1.12.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "http-signature": "~1.4.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "^2.1.35", - "oauth-sign": "~0.9.0", - "qs": "~6.14.1", - "safe-buffer": "^5.1.2", - "socks-proxy-agent": "^8.0.5", - "stream-length": "^1.0.2", - "uuid": "^8.3.2" - }, - "dependencies": { - "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" - } - } - }, "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8525,28 +7871,11 @@ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", "dev": true }, - "psl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" - }, "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" - }, - "qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "requires": { - "side-channel": "^1.1.0" - } - }, - "querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true }, "queue-microtask": { "version": "1.2.3", @@ -8609,11 +7938,6 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "dev": true }, - "requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" - }, "resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -8685,50 +8009,6 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true }, - "side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "requires": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - } - }, - "side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "requires": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - } - }, - "side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "requires": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - } - }, - "side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "requires": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - } - }, "signal-exit": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.5.tgz", @@ -8772,30 +8052,6 @@ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true }, - "smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==" - }, - "socks": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", - "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", - "requires": { - "ip-address": "^10.1.1", - "smart-buffer": "^4.2.0" - } - }, - "socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "requires": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - } - }, "source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -8860,22 +8116,6 @@ "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", "dev": true }, - "sshpk": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", - "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", - "requires": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" - } - }, "stream-combiner": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.2.2.tgz", @@ -8886,14 +8126,6 @@ "through": "~2.3.4" } }, - "stream-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/stream-length/-/stream-length-1.0.2.tgz", - "integrity": "sha512-aI+qKFiwoDV4rsXiS7WRoCt+v2RX1nUj17+KJC5r2gfh5xoSJIfP6Y3Do/HtvesFcTSWthIuJ3l1cvKQY/+nZg==", - "requires": { - "bluebird": "^2.6.2" - } - }, "string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -9043,11 +8275,6 @@ "tslib": "^1.8.1" } }, - "tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" - }, "type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -9109,15 +8336,6 @@ "punycode": "^2.1.0" } }, - "url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "requires": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -9135,23 +8353,6 @@ "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", "dev": true }, - "verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", - "requires": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - }, - "dependencies": { - "core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" - } - } - }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 176276d..bd12c1d 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,8 @@ "lodash": "^4.17.21", "micromatch": "^4.0.4", "moment": "^2.29.1", + "needle": "^3.5.0", "parse-ms": "^2.1.0", - "postman-request": "^2.88.1-postman.48", "semver": "^7.7.3", "temp-dir": "^2.0.0", "xml2js": "^0.5.0" @@ -44,6 +44,7 @@ "@types/lodash": "^4.14.200", "@types/micromatch": "^4.0.2", "@types/mocha": "^10.0.0", + "@types/needle": "^3.3.0", "@types/node": "^18.19.0", "@types/semver": "^7.7.1", "@types/sinon": "^10.0.4", diff --git a/src/RokuDeploy.spec.ts b/src/RokuDeploy.spec.ts index e60ec40..118c77f 100644 --- a/src/RokuDeploy.spec.ts +++ b/src/RokuDeploy.spec.ts @@ -16,9 +16,7 @@ import { defer, util, standardizePath as s } from './util'; import type { FileEntry, RokuDeployOptions } from './RokuDeployOptions'; import { cwd, expectPathExists, expectPathNotExists, expectThrowsAsync, outDir, rootDir, stagingDir, tempDir, writeFiles } from './testUtils.spec'; import { createSandbox } from 'sinon'; -import * as r from 'postman-request'; -import type * as requestType from 'request'; -const request = r as typeof requestType; +import { request } from './request'; const sinon = createSandbox(); diff --git a/src/RokuDeploy.ts b/src/RokuDeploy.ts index ad6967d..2a94672 100644 --- a/src/RokuDeploy.ts +++ b/src/RokuDeploy.ts @@ -1,8 +1,7 @@ import * as path from 'path'; import * as _fsExtra from 'fs-extra'; -import * as r from 'postman-request'; +import { request } from './request'; import type * as requestType from 'request'; -const request = r as typeof requestType; import * as JSZip from 'jszip'; import * as dateformat from 'dateformat'; import * as errors from './Errors'; diff --git a/src/device.spec.ts b/src/device.spec.ts index 660dc64..1761507 100644 --- a/src/device.spec.ts +++ b/src/device.spec.ts @@ -1,57 +1,86 @@ import * as assert from 'assert'; import * as fsExtra from 'fs-extra'; +import * as semver from 'semver'; +import { expect } from 'chai'; import * as rokuDeploy from './index'; +import { RokuDeploy } from './RokuDeploy'; +import * as errors from './Errors'; import { cwd, expectPathExists, expectThrowsAsync, outDir, rootDir, tempDir, writeFiles } from './testUtils.spec'; import undent from 'undent'; -//these tests are run against an actual roku device. These cannot be enabled when run on the CI server +/** + * These tests run against an ACTUAL Roku device on the local network. They are excluded from the + * normal/CI test run (see the `test` script's `--exclude`) and are intended to be run manually with + * `npm run test:device` whenever the networking layer changes (e.g. the postman-request -> needle migration). + * + * Goals: + * - Exercise EVERY networking-enabled public method on RokuDeploy at least once. + * - Be resilient to the device's actual data changing over time. We assert on STRUCTURE and TYPES + * (e.g. "softwareVersion is semver-ish", "deviceInfo is a non-empty object") rather than exact + * values, since model/version/serial/etc. differ per device and per firmware update. + * + * Device connection info is hardcoded below for convenience. Override via env vars if needed: + * - ROKU_HOST (default 192.168.1.32) + * - ROKU_PASSWORD (default aaaa) + * - ROKU_SIGNING_PASSWORD (no default) - required to run the package-signing tests + * - ROKU_RUN_DISRUPTIVE=1 - opt in to disruptive tests that reboot / trigger update checks + */ +const HOST = process.env.ROKU_HOST ?? '192.168.1.32'; +const PASSWORD = process.env.ROKU_PASSWORD ?? 'aaaa'; +const SIGNING_PASSWORD = process.env.ROKU_SIGNING_PASSWORD; +const RUN_DISRUPTIVE = process.env.ROKU_RUN_DISRUPTIVE === '1'; + describe('device', function device() { - let options: rokuDeploy.RokuDeployOptions; + this.timeout(30000); - beforeEach(() => { - fsExtra.emptyDirSync(tempDir); - fsExtra.ensureDirSync(rootDir); - process.chdir(rootDir); - options = rokuDeploy.getOptions({ - outDir: outDir, - host: '192.168.1.93', - retainDeploymentArchive: true, - password: 'aaaa', - devId: 'c6fdc2019903ac3332f624b0b2c2fe2c733c3e74', - rekeySignedPackage: `${cwd}/testSignedPackage.pkg`, - signingPassword: 'drRCEVWP/++K5TYnTtuAfQ==' - }); + let options: rokuDeploy.RokuDeployOptions; + let rd: RokuDeploy; + /** + * Write a minimal-but-runnable channel into rootDir so screenshots/deploys have a real, + * compilable app to work with. (A channel that fails to compile would break screenshot tests.) + */ + function writeMinimalChannel() { writeFiles(rootDir, [ ['manifest', undent` title=RokuDeployTestChannel major_version=1 minor_version=0 build_version=0 - splash_screen_hd=pkg:/images/splash_hd.jpg ui_resolutions=hd - bs_const=IS_DEV_BUILD=false - splash_color=#000000 `], ['source/main.brs', undent` - Sub RunUserInterface() + sub Main() screen = CreateObject("roSGScreen") - m.scene = screen.CreateScene("HomeScene") port = CreateObject("roMessagePort") - screen.SetMessagePort(port) - screen.Show() - - while(true) + screen.setMessagePort(port) + scene = screen.CreateScene("HomeScene") + screen.show() + while true msg = wait(0, port) + if type(msg) = "roSGScreenEvent" and msg.isScreenClosed() then return end while - - if screen <> invalid then - screen.Close() - screen = invalid - end if - End Sub + end sub + `], + ['components/HomeScene.xml', undent` + + `] ]); + } + + beforeEach(() => { + fsExtra.emptyDirSync(tempDir); + fsExtra.ensureDirSync(rootDir); + process.chdir(rootDir); + rd = new RokuDeploy(); + options = rokuDeploy.getOptions({ + outDir: outDir, + host: HOST, + retainDeploymentArchive: true, + password: PASSWORD + }); + writeMinimalChannel(); }); afterEach(() => { @@ -60,60 +89,259 @@ describe('device', function device() { fsExtra.emptyDirSync(tempDir); }); - this.timeout(20000); + /** + * Assert that a value is a "response-ish" object as returned by doPostRequest/doGetRequest: + * `{ response: { statusCode, headers }, body: string }`. This is the postman-request-compatible + * shape that roku-deploy promises, so it's worth pinning down explicitly after the needle migration. + */ + function expectHttpResponseShape(result: any) { + expect(result).to.be.an('object'); + expect(result.response, 'result.response').to.be.an('object'); + expect(result.response.statusCode, 'result.response.statusCode').to.be.a('number'); + expect(result.response.headers, 'result.response.headers').to.be.an('object'); + expect(result.body, 'result.body').to.be.a('string'); + } + + describe('getDeviceInfo', () => { + it('returns a raw device-info object with the expected structure/types', async () => { + const info = await rd.getDeviceInfo({ host: HOST }); + //it's a non-empty object + expect(info).to.be.an('object'); + expect(Object.keys(info).length).to.be.greaterThan(10); + //raw values come back as strings (xml2js). Check a few well-known keys exist and are strings. + expect(info['udn'], 'udn').to.be.a('string').with.length.greaterThan(0); + expect(info['serial-number'], 'serial-number').to.be.a('string').with.length.greaterThan(0); + expect(info['software-version'], 'software-version').to.be.a('string'); + //software-version should look like a version number (e.g. "15.2.4") + expect(/^\d+\.\d+/.test(info['software-version']), `software-version "${info['software-version']}" should start with N.N`).to.be.true; + }); - describe('deploy', () => { - it('works', async () => { - options.retainDeploymentArchive = true; - let response = await rokuDeploy.deploy(options); - assert.equal(response.message, 'Successful deploy'); + it('returns an enhanced device-info object with normalized types', async () => { + const info = await rd.getDeviceInfo({ host: HOST, enhance: true }); + expect(info).to.be.an('object'); + //camelCased keys + expect(info.udn, 'udn').to.be.a('string').with.length.greaterThan(0); + expect(info.serialNumber, 'serialNumber').to.be.a('string').with.length.greaterThan(0); + //softwareVersion should be coercible to a valid semver + expect(semver.valid(semver.coerce(info.softwareVersion)), `softwareVersion "${info.softwareVersion}" should be semver-coercible`).to.not.be.null; + //a "true"/"false" string should have been normalized to a real boolean + expect(info.supportsEthernet, 'supportsEthernet').to.be.a('boolean'); + //a numeric string should have been normalized to a real number + if (info.softwareBuild !== undefined) { + expect(info.softwareBuild, 'softwareBuild').to.be.a('number'); + } + //ecpSettingMode should be one of the known modes (or undefined on older firmware) + if (info.ecpSettingMode !== undefined) { + expect(['enabled', 'disabled', 'limited', 'permissive']).to.include(info.ecpSettingMode); + } }); - it('Presents nice message for 401 unauthorized status code', async () => { - this.timeout(20000); - options.password = 'NOT_THE_PASSWORD'; + it('honors a custom remotePort by failing fast against a closed port', async () => { + //port 9 (discard) won't speak ECP; we just want to prove the option is threaded through. await expectThrowsAsync( - rokuDeploy.deploy(options), - `Unauthorized. Please verify credentials for host '${options.host}'` + rd.getDeviceInfo({ host: HOST, remotePort: 9, timeout: 2000 }) ); }); }); - describe('deployAndSignPackage', () => { - it('works', async () => { - await rokuDeploy.deleteInstalledChannel(options); - await rokuDeploy.rekeyDevice(options); - expectPathExists( - await rokuDeploy.deployAndSignPackage(options) - ); + describe('getDevId', () => { + it('returns the keyed developer id as a hex string', async () => { + const devId = await rd.getDevId(options); + expect(devId, 'devId').to.be.a('string').with.length.greaterThan(0); + //keyed-developer-id is a hex string + expect(/^[0-9a-f]+$/i.test(devId), `devId "${devId}" should be hex`).to.be.true; + }); + }); + + describe('getEcpNetworkAccessMode', () => { + it('returns a known ECP access mode (or undefined on firmware that does not report it)', async () => { + const mode = await rd.getEcpNetworkAccessMode({ host: HOST }); + //older firmware (or some query contexts) may not include ecp-setting-mode at all + expect([undefined, 'enabled', 'disabled', 'limited', 'permissive']).to.include(mode); }); }); describe('validateDeveloperPassword', () => { it('returns true when the password is correct', async () => { - const result = await rokuDeploy.rokuDeploy.validateDeveloperPassword({ - host: options.host, - password: options.password - }); + const result = await rd.validateDeveloperPassword({ host: HOST, password: PASSWORD }); assert.strictEqual(result, true); }); it('returns false when the password is wrong', async () => { - const result = await rokuDeploy.rokuDeploy.validateDeveloperPassword({ - host: options.host, - password: 'NOT_THE_PASSWORD' - }); + const result = await rd.validateDeveloperPassword({ host: HOST, password: 'NOT_THE_PASSWORD' }); assert.strictEqual(result, false); }); it('throws DeviceUnreachableError for an offline host', async () => { - await expectThrowsAsync(async () => { - await rokuDeploy.rokuDeploy.validateDeveloperPassword({ - host: '192.168.254.254', - password: 'aaaa', - timeout: 2000 - }); + await expectThrowsAsync( + rd.validateDeveloperPassword({ host: '192.168.254.254', password: PASSWORD, timeout: 2000 }) + ); + }); + }); + + describe('pressHomeButton', () => { + it('sends the keypress over ECP without error', async () => { + //pressHomeButton resolves with the raw response; just make sure it didn't reject. + const result = await rd.pressHomeButton(HOST); + expectHttpResponseShape(result); + }); + }); + + describe('publish / deploy', () => { + it('deploy zips + uploads the channel and reports success', async () => { + const result = await rd.deploy(options); + expect(result.message).to.match(/deploy|Identical/i); + expectHttpResponseShape(result.results); + }); + + it('publish (without re-zipping) uploads an existing archive', async () => { + //first build the zip on disk + await rd.createPackage(options); + const result = await rd.publish(options); + expect(result.message).to.match(/deploy|Identical/i); + expectHttpResponseShape(result.results); + }); + + it('presents a nice message for a 401 unauthorized status code', async () => { + await expectThrowsAsync( + rd.deploy({ ...options, password: 'NOT_THE_PASSWORD' }), + `Unauthorized. Please verify credentials for host '${HOST}'` + ); + }); + + it('attaches the postman-style results to a thrown UnauthorizedDeviceResponseError', async () => { + await rd.createPackage(options); + let caught: any; + try { + await rd.publish({ ...options, password: 'NOT_THE_PASSWORD' }); + } catch (e) { + caught = e; + } + expect(caught, 'should have thrown').to.be.instanceof(errors.UnauthorizedDeviceResponseError); + expect(caught.results.response.statusCode).to.equal(401); + expect(caught.results.response.request.host).to.equal(HOST); + expect(caught.results.body).to.be.a('string'); + }); + }); + + describe('takeScreenshot', () => { + it('captures a screenshot of the running channel to a real file', async () => { + //ensure a channel is running so the screenshot has content + await rd.deploy(options); + const filePath = await rd.takeScreenshot({ host: HOST, password: PASSWORD }); + expectPathExists(filePath); + expect(fsExtra.statSync(filePath).size, 'screenshot byte size').to.be.greaterThan(0); + expect(filePath).to.match(/\.(jpg|png)$/i); + }); + }); + + describe('convertToSquashfs', () => { + it('converts the currently-installed channel to squashfs', async () => { + //must have a channel loaded first + await rd.deploy(options); + //resolves (no throw) on success; throws ConvertError on failure + await rd.convertToSquashfs(options); + }); + }); + + describe('component libraries', () => { + it('deleteAllComponentLibraries enumerates packages and completes without error', async () => { + //even with zero component libraries installed, this should query the device and resolve. + await rd.deleteAllComponentLibraries({ host: HOST, password: PASSWORD }); + }); + + it('deleteComponentLibrary issues the delete request and gets a real device response', async () => { + //deleting a non-existent dcl exercises the full request/response path. The device responds + //with a "Failed: Invalid filename" message, which roku-deploy surfaces as a + //FailedDeviceResponseError. Either outcome (resolve, or that specific error) proves the + //networking path works; anything else is a real problem. + try { + await rd.deleteComponentLibrary({ host: HOST, password: PASSWORD, fileName: 'does-not-exist.zip' }); + } catch (e) { + expect(e, `unexpected error: ${e}`).to.be.instanceof(errors.FailedDeviceResponseError); + expect((e as Error).message).to.match(/invalid filename/i); + } + }); + }); + + describe('deleteInstalledChannel', () => { + it('deletes the installed dev channel and returns a response', async () => { + //make sure something is installed first + await rd.deploy(options); + const result = await rd.deleteInstalledChannel(options); + expectHttpResponseShape(result); + }); + + it('resolves even when no channel is installed', async () => { + //delete twice; the second delete has nothing to remove but should still resolve + await rd.deploy(options); + await rd.deleteInstalledChannel(options); + const result = await rd.deleteInstalledChannel(options); + expectHttpResponseShape(result); + }); + }); + + describe('package signing', () => { + //these require a signingPassword that matches the device's installed dev key. + const maybe = SIGNING_PASSWORD ? it : it.skip; + + maybe('rekeyDevice + signExistingPackage + retrieveSignedPackage produce a .pkg', async function signing() { + this.timeout(60000); + const signingOptions = { + ...options, + signingPassword: SIGNING_PASSWORD, + rekeySignedPackage: `${cwd}/testSignedPackage.pkg` + }; + //deploy + retain staging so the manifest is available for signing + await rd.deploy({ ...signingOptions, retainStagingDir: true }); + const remotePkgPath = await rd.signExistingPackage(signingOptions); + expect(remotePkgPath, 'remote pkg path').to.be.a('string').that.matches(/\.pkg$/); + const localPkgPath = await rd.retrieveSignedPackage(remotePkgPath, signingOptions); + expectPathExists(localPkgPath); + expect(fsExtra.statSync(localPkgPath).size).to.be.greaterThan(0); + }); + + maybe('deployAndSignPackage produces a local .pkg file', async function signAndDeploy() { + this.timeout(60000); + const pkgFilePath = await rd.deployAndSignPackage({ + ...options, + signingPassword: SIGNING_PASSWORD, + rekeySignedPackage: `${cwd}/testSignedPackage.pkg` }); + expectPathExists(pkgFilePath); + }); + }); + + describe('firmware-gated operations', () => { + //rebootDevice and checkForUpdate require firmware >= 15.0.4. They're also disruptive (a reboot + //takes the device offline for a while), so they only run when explicitly opted in. + const maybe = RUN_DISRUPTIVE ? it : it.skip; + + maybe('checkForUpdate triggers an update check', async () => { + const result = await rd.checkForUpdate(options); + expectHttpResponseShape(result); + }); + + maybe('rebootDevice reboots the device', async function reboot() { + this.timeout(60000); + const result = await rd.rebootDevice(options); + expectHttpResponseShape(result); + }); + + it('rebootDevice/checkForUpdate guard against old firmware', async () => { + //we can at least confirm the firmware-version gate logic runs against the real device-info. + const info = await rd.getDeviceInfo({ host: HOST, enhance: true }); + const version = semver.coerce(info.softwareVersion); + //if the device is new enough, checkForUpdate should NOT throw the UnsupportedFirmwareVersionError. + if (version && semver.gte(version, '15.0.4') && RUN_DISRUPTIVE) { + await rd.checkForUpdate(options); + } else if (version && semver.lt(version, '15.0.4')) { + await expectThrowsAsync( + rd.checkForUpdate(options), + undefined, + 'expected UnsupportedFirmwareVersionError on old firmware' + ); + } }); }); }); diff --git a/src/request.ts b/src/request.ts new file mode 100644 index 0000000..f7d1a52 --- /dev/null +++ b/src/request.ts @@ -0,0 +1,260 @@ +/* eslint-disable camelcase */ +import * as needle from 'needle'; +import type { ReadStream } from 'fs'; +import type * as requestType from 'request'; + +/** + * A thin compatibility shim over `needle` that mimics the small slice of the + * `request`/`postman-request` API that roku-deploy relies on. We migrated off + * `postman-request` (unmaintained, pulls in a large dependency tree) but a lot + * of roku-deploy's public surface — most notably the `results`/`response` + * object attached to thrown errors — was shaped by `request`. To keep this a + * non-breaking change, this shim reconstructs that same shape on top of + * needle's response object. + * + * Only `post` and `get` are implemented, since those are the only verbs + * roku-deploy uses. `get` additionally supports the callback-less streaming + * form (`request.get(opts).on(...).pipe(...)`) used when downloading files. + */ + +/** + * The `response` object roku-deploy expects. `request` returned an + * `http.IncomingMessage` augmented with a `.request` property exposing the + * outgoing request details. needle exposes the outgoing request as `.req` + * instead and does not attach a `.request` object, so we synthesize the few + * fields that roku-deploy actually reads (currently just `request.host`). + */ +export interface RequestResponse { + statusCode: number; + headers: Record; + /** + * Mirrors `request`'s `response.request` object. roku-deploy reads + * `response.request.host` when constructing the "Unauthorized" error message. + */ + request: { + host: string; + href: string; + }; + /** + * The raw needle response (an `http.IncomingMessage`), in case anything + * needs to reach through to the underlying object. + */ + body: string; +} + +export type RequestCallback = (error: Error | null, response: RequestResponse | undefined, body: string | undefined) => void; + +/** + * Translate the `request`-style options object that roku-deploy builds into the + * `(url, data, needleOptions)` triple that needle expects. + */ +function translateOptions(params: requestType.OptionsWithUrl, method: 'GET' | 'POST') { + const url = buildUrl(params); + + const needleOptions: needle.NeedleOptions = { + //Roku responses are HTML/XML that roku-deploy parses by hand; never let needle auto-parse them + parse_response: false, + //request had a single `timeout` that covered the whole exchange; map it to both needle timeouts + open_timeout: params.timeout, + read_timeout: params.timeout, + headers: params.headers as Record + }; + + //digest auth. `request` was configured with `auth.sendImmediately: false`, which performs the + //401-challenge/response digest dance. needle does the same when `auth: 'digest'` is set. + const auth = params.auth as { user?: string; username?: string; pass?: string; password?: string } | undefined; + if (auth) { + needleOptions.username = auth.user ?? auth.username; + needleOptions.password = auth.pass ?? auth.password; + needleOptions.auth = 'digest'; + } + + let data: any = null; + if (method === 'POST') { + const formData = translateFormData((params as any).formData); + //only send a multipart body when there's actually form data to send. Some POSTs (e.g. ECP + //keypress) have no body at all; needle's multipart builder throws "Empty multipart body" on an + //empty object, whereas `request` happily sent a bodyless POST. So fall back to a null body. + if (Object.keys(formData).length > 0) { + data = formData; + needleOptions.multipart = true; + } + } + + return { url: url, data: data, needleOptions: needleOptions }; +} + +/** + * Append the `qs` query-string object (if any) onto the url. `request` accepted + * `qs` as a separate option; needle expects it baked into the url. + */ +function buildUrl(params: requestType.OptionsWithUrl): string { + let url = params.url as string; + const qs = (params as any).qs as Record | undefined; + if (qs && Object.keys(qs).length > 0) { + const search = new URLSearchParams(); + for (const key in qs) { + if (qs[key] !== undefined && qs[key] !== null) { + search.append(key, String(qs[key])); + } + } + const query = search.toString(); + if (query) { + url += (url.includes('?') ? '&' : '?') + query; + } + } + return url; +} + +/** + * Convert a `request`-style `formData` object into the shape needle's multipart + * builder understands. + * + * - `request` silently dropped `null`/`undefined`/empty-string fields. needle's + * multipart builder instead throws `"value missing for multipart!"` on empty + * values, so we drop those fields entirely (preserving the old behavior). + * - `request` accepted a readable stream (e.g. the zip `fs.ReadStream`) as a + * field value. needle's multipart builder does not handle streams, so we + * translate a stream into needle's documented `{ file, content_type }` form + * using the stream's `path`. + */ +function translateFormData(formData: Record | undefined): Record { + const result: Record = {}; + if (!formData) { + return result; + } + for (const key in formData) { + const value = formData[key]; + //drop empty values (request did this implicitly; needle would throw) + if (value === undefined || value === null || value === '') { + continue; + } + //a readable stream (the zip/pkg archive) -> needle file-by-path form + if (isReadableStream(value)) { + const filePath = (value).path; + result[key] = { + file: filePath, + content_type: 'application/octet-stream' + }; + } else { + result[key] = value; + } + } + return result; +} + +function isReadableStream(value: any): value is ReadStream { + return value && typeof value === 'object' && typeof value.pipe === 'function' && value.readable !== false; +} + +/** + * Coerce needle's response body into the `string` that roku-deploy expects. + * With `parse_response: false`, needle hands back a `Buffer` (an *empty* Buffer + * for empty responses such as a bare 401), whereas `request`/`postman-request` + * always delivered a decoded string. roku-deploy's `checkRequest` guards on + * `typeof body === 'string'`, so anything other than a string would be + * misreported as an unparsable response. + */ +function coerceBody(body: any): string { + if (Buffer.isBuffer(body)) { + return body.toString(); + } + if (typeof body === 'string') { + return body; + } + //null/undefined or a parsed object (shouldn't happen with parse_response:false) -> stringify safely + return body === undefined || body === null ? '' : String(body); +} + +/** + * Reshape needle's response object into the `request`-compatible response that + * roku-deploy expects to receive (and to find attached to thrown errors). + */ +function buildResponse(needleResponse: any, url: string): RequestResponse { + let host: string; + try { + host = new URL(url).host; + } catch { + host = undefined; + } + return { + statusCode: needleResponse.statusCode, + headers: needleResponse.headers, + request: { + host: host, + href: url + }, + body: undefined + }; +} + +export const request = { + /** + * POST a multipart/form-data request, `request`-style. Invokes `callback` + * with `(error, response, body)`. + */ + post: (params: requestType.OptionsWithUrl, callback: RequestCallback) => { + const { url, data, needleOptions } = translateOptions(params, 'POST'); + return needle.post(url, data, needleOptions, (error, response, body) => { + if (error) { + return callback(error, undefined, undefined); + } + return callback(null, buildResponse(response, url), coerceBody(body)); + }); + }, + + /** + * GET a request, `request`-style. With a `callback`, invokes it with + * `(error, response, body)`. Without a callback, returns needle's readable + * stream (which supports `.on('error'|'response', ...)` and `.pipe(...)`) + * for the file-download path. + */ + get: (params: requestType.OptionsWithUrl, callback?: RequestCallback) => { + const { url, needleOptions } = translateOptions(params, 'GET'); + if (callback) { + return needle.get(url, needleOptions, (error, response, body) => { + if (error) { + return callback(error, undefined, undefined); + } + return callback(null, buildResponse(response, url), coerceBody(body)); + }); + } + //streaming form (no callback) - used by getToFile to pipe the response to disk. + const stream = needle.get(url, needleOptions); + + //needle's stream emits its failures on the `'err'` event, but `request` (and roku-deploy's + //getToFile) listens on `'error'`, so bridge `'err'` -> `'error'` to preserve that behavior. + stream.on('err', (err) => stream.emit('error', err)); + + //digest auth in streaming mode: needle issues the unauthenticated request, emits a `'response'` + //for the 401 challenge, and *then* transparently retries with the Authorization header (emitting + //a second `'response'`). `request`/`postman-request` only ever surfaced the final response, and + //roku-deploy's `getToFile` treats any non-200 `'response'` as a hard failure. So when we're doing + //digest auth, swallow the intermediate 401 `'response'` event and only forward the retried one. + if (needleOptions.auth && needleOptions.username !== undefined) { + interceptIntermediate401(stream); + } + return stream; + } +}; + +/** + * Wrap a needle stream so that an intermediate `401` `'response'` event (the + * digest challenge that needle answers by retrying) is not propagated to + * listeners. Only the subsequent, authenticated response is forwarded. + */ +function interceptIntermediate401(stream: { emit: (event: string, ...args: any[]) => boolean }) { + const originalEmit = stream.emit.bind(stream); + let swallowedChallenge = false; + stream.emit = ((event: string, ...args: any[]): boolean => { + if (event === 'response' && !swallowedChallenge) { + const resp = args[0]; + if (resp && resp.statusCode === 401) { + //this is the digest challenge; needle will retry. drop it (just this once). + swallowedChallenge = true; + return false; + } + } + return originalEmit(event, ...args); + }) as any; +} From 668d47a9596c927e89f8834c1d5b6eab2d393712 Mon Sep 17 00:00:00 2001 From: Bronley Plumb Date: Wed, 3 Jun 2026 16:10:59 -0400 Subject: [PATCH 02/13] Add unit tests pinning the error/response structure through the needle shim The `results`/`response` object that roku-deploy attaches to thrown errors is part of its public API; consumers read specific paths off it (`results.response.statusCode`, `.headers.server`, `.request.host`, string `results.body`). Getting any of these wrong during the needle migration would be a silent breaking change. - Add a `needleClient` seam to request.ts so the shim's HTTP calls are stubbable (mirrors the `httpClient` seam in fetch.ts). - Add request.spec.ts: unit tests for option translation (qs, digest auth, timeouts, headers), formData/multipart handling (drop empty fields, stream -> file form, no multipart for bodyless POSTs), body coercion (Buffer -> string), response reshape (statusCode/headers/request.host/href), error passthrough, and the streaming get path (err->error bridge, intermediate-401 suppression). - Add an "error results structure" block to RokuDeploy.spec.ts that drives RokuDeploy through the REAL shim and pins the results shape for each error type: UnauthorizedDeviceResponseError, InvalidDeviceResponseCodeError, UnparsableDeviceResponseError, FailedDeviceResponseError, EcpNetworkAccessModeDisabledError, UpdateCheckRequiredError, ConnectionResetError. Also hardens buildResponse to pass through a missing response object instead of crashing, preserving checkRequest's `!results.response` guard behavior. Co-Authored-By: Claude Opus 4.8 --- src/RokuDeploy.spec.ts | 165 ++++++++++++++++++++++++- src/request.spec.ts | 265 +++++++++++++++++++++++++++++++++++++++++ src/request.ts | 22 +++- 3 files changed, 448 insertions(+), 4 deletions(-) create mode 100644 src/request.spec.ts diff --git a/src/RokuDeploy.spec.ts b/src/RokuDeploy.spec.ts index 118c77f..d6b3b1f 100644 --- a/src/RokuDeploy.spec.ts +++ b/src/RokuDeploy.spec.ts @@ -16,7 +16,8 @@ import { defer, util, standardizePath as s } from './util'; import type { FileEntry, RokuDeployOptions } from './RokuDeployOptions'; import { cwd, expectPathExists, expectPathNotExists, expectThrowsAsync, outDir, rootDir, stagingDir, tempDir, writeFiles } from './testUtils.spec'; import { createSandbox } from 'sinon'; -import { request } from './request'; +import { request, needleClient } from './request'; +import { PassThrough } from 'stream'; const sinon = createSandbox(); @@ -194,6 +195,168 @@ describe('RokuDeploy', () => { }); }); + describe('error results structure (postman-request compatibility)', () => { + //These tests drive RokuDeploy through the REAL needle shim (by stubbing the low-level + //`needleClient`), then assert the exact shape of the `results`/`response` object attached to + //each thrown error. roku-deploy historically exposed a `request`/`postman-request`-shaped + //object here, and consumers read specific paths off it (`results.response.statusCode`, + //`results.response.headers.server`, `results.response.request.host`, string `results.body`). + //Getting any of these wrong is a BREAKING API change, so they're pinned down explicitly. + + /** Stub needleClient.post to deliver the given needle-style response + body */ + function stubNeedlePost(response: any, body: any, error: any = null) { + return sinon.stub(needleClient, 'post').callsFake(((url: string, data: any, opts: any, callback: any) => { + process.nextTick(callback, error, response, body); + return {} as any; + }) as any); + } + + /** Stub needleClient.get (callback form) to deliver the given needle-style response + body */ + function stubNeedleGet(response: any, body: any, error: any = null) { + return sinon.stub(needleClient, 'get').callsFake(((url: string, opts: any, callback: any) => { + if (callback) { + process.nextTick(callback, error, response, body); + } + return new PassThrough() as any; + }) as any); + } + + describe('UnauthorizedDeviceResponseError (401)', () => { + it('attaches results with response.statusCode, request.host, and a string body', async () => { + stubNeedlePost({ statusCode: 401, headers: {} }, Buffer.alloc(0)); + let caught: any; + try { + await rokuDeploy.deleteInstalledChannel({ host: '1.2.3.4', password: 'aaaa' } as any); + } catch (e) { + caught = e; + } + expect(caught).to.be.instanceof(errors.UnauthorizedDeviceResponseError); + //message embeds the host pulled off results.response.request.host + expect(caught.message).to.equal(`Unauthorized. Please verify credentials for host '1.2.3.4'`); + //the postman-style results object + expect(caught.results).to.be.an('object'); + expect(caught.results.response.statusCode).to.equal(401); + expect(caught.results.response.request.host).to.equal('1.2.3.4'); + expect(caught.results.response.headers).to.be.an('object'); + expect(caught.results.body).to.be.a('string'); + }); + }); + + describe('InvalidDeviceResponseCodeError (non-200)', () => { + it('attaches results with the offending statusCode and message', async () => { + stubNeedlePost({ statusCode: 500, headers: {} }, 'oops'); + let caught: any; + try { + await rokuDeploy.deleteInstalledChannel({ host: '1.2.3.4', password: 'aaaa' } as any); + } catch (e) { + caught = e; + } + expect(caught).to.be.instanceof(errors.InvalidDeviceResponseCodeError); + expect(caught.message).to.equal('Invalid response code: 500'); + expect(caught.results.response.statusCode).to.equal(500); + expect(caught.results.body).to.equal('oops'); + }); + }); + + describe('UnparsableDeviceResponseError', () => { + it('is thrown (with results) when the response object is missing', async () => { + //needle delivers no response object and no body + stubNeedlePost(undefined, undefined); + let caught: any; + try { + await rokuDeploy.deleteInstalledChannel({ host: '1.2.3.4', password: 'aaaa' } as any); + } catch (e) { + caught = e; + } + expect(caught).to.be.instanceof(errors.UnparsableDeviceResponseError); + }); + }); + + describe('FailedDeviceResponseError (roku message in body)', () => { + it('attaches the parsed rokuMessages object (errors/infos/successes)', async () => { + const body = getFakeResponseBody(` + Shell.create('Roku.Message').trigger('Set message type', 'error').trigger('Set message content', 'Failure: Form Error: "archive" Field Not Found').trigger('Render', node); + `); + stubNeedlePost({ statusCode: 200, headers: {} }, body); + let caught: any; + try { + await rokuDeploy.deleteInstalledChannel({ host: '1.2.3.4', password: 'aaaa' } as any); + } catch (e) { + caught = e; + } + expect(caught).to.be.instanceof(errors.FailedDeviceResponseError); + expect(caught.message).to.equal('Failure: Form Error: "archive" Field Not Found'); + //for this error, `results` is the rokuMessages object, not the http results + expect(caught.results).to.eql({ + errors: ['Failure: Form Error: "archive" Field Not Found'], + infos: [], + successes: [] + }); + }); + }); + + describe('getDeviceInfo -> EcpNetworkAccessModeDisabledError', () => { + it('detects a Roku server header on the error results', async () => { + //device-info is a GET; a Roku server header on a failing response means ECP is disabled + stubNeedleGet({ statusCode: 403, headers: { server: 'Roku/12.0' } }, 'forbidden'); + let caught: any; + try { + await rokuDeploy.getDeviceInfo({ host: '1.2.3.4' }); + } catch (e) { + caught = e; + } + expect(caught).to.be.instanceof(errors.EcpNetworkAccessModeDisabledError); + }); + + it('does NOT treat a non-Roku server header as ECP-disabled', async () => { + stubNeedleGet({ statusCode: 500, headers: { server: 'Apache' } }, 'err'); + let caught: any; + try { + await rokuDeploy.getDeviceInfo({ host: '1.2.3.4' }); + } catch (e) { + caught = e; + } + //the original (InvalidDeviceResponseCodeError from checkRequest) bubbles up, with results intact + expect(caught).to.be.instanceof(errors.InvalidDeviceResponseCodeError); + expect(caught.results.response.headers.server).to.equal('Apache'); + }); + }); + + describe('publish error mapping', () => { + beforeEach(() => { + options.host = '1.2.3.4'; + options.failOnCompileError = true; + options.retainDeploymentArchive = true; + //make a dummy zip so publish gets past the file-exists check + fsExtra.outputFileSync(rokuDeploy.getOutputZipFilePath(options), 'fake-zip-content'); + }); + + it('maps a 577 response to UpdateCheckRequiredError', async () => { + stubNeedlePost({ statusCode: 577, headers: {} }, ''); + let caught: any; + try { + await rokuDeploy.publish(options); + } catch (e) { + caught = e; + } + expect(caught).to.be.instanceof(errors.UpdateCheckRequiredError); + }); + + it('maps an ECONNRESET to ConnectionResetError', async () => { + const resetError: any = new Error('socket hang up'); + resetError.code = 'ECONNRESET'; + stubNeedlePost(undefined, undefined, resetError); + let caught: any; + try { + await rokuDeploy.publish(options); + } catch (e) { + caught = e; + } + expect(caught).to.be.instanceof(errors.ConnectionResetError); + }); + }); + }); + describe('getRokuMessagesFromResponseBody', () => { it('exits on unknown message type', () => { const result = rokuDeploy['getRokuMessagesFromResponseBody'](` diff --git a/src/request.spec.ts b/src/request.spec.ts new file mode 100644 index 0000000..05c8b8a --- /dev/null +++ b/src/request.spec.ts @@ -0,0 +1,265 @@ +import { expect } from 'chai'; +import { createSandbox } from 'sinon'; +import { PassThrough } from 'stream'; +import { request, needleClient } from './request'; + +const sinon = createSandbox(); + +/** + * Unit tests for the needle compatibility shim. + * + * These pin down the translation between roku-deploy's `request`/`postman-request`-style + * options/response and needle, by stubbing `needleClient` directly. The error/response SHAPE + * here is part of roku-deploy's public surface (it's attached to thrown errors), so getting any + * of this wrong is a breaking change. + */ +describe('request (needle shim)', () => { + /** captured args from the stubbed needle call: { url, data, options, callback } */ + let postArgs: { url: string; data: any; options: any; callback: any }; + let getArgs: { url: string; options: any; callback: any }; + + /** Stub needleClient.post and capture/drive the callback */ + function stubPost(err: any, response: any, body: any) { + return sinon.stub(needleClient, 'post').callsFake(((url: string, data: any, options: any, callback: any) => { + postArgs = { url: url, data: data, options: options, callback: callback }; + //invoke async like needle does + process.nextTick(callback, err, response, body); + return {} as any; + }) as any); + } + + /** Stub needleClient.get (callback form) and capture/drive the callback */ + function stubGet(err: any, response: any, body: any) { + return sinon.stub(needleClient, 'get').callsFake(((url: string, options: any, callback: any) => { + getArgs = { url: url, options: options, callback: callback }; + if (callback) { + process.nextTick(callback, err, response, body); + } + return new PassThrough() as any; + }) as any); + } + + /** Promise wrapper around the callback-style shim methods */ + function callPost(params: any): Promise<{ error: any; response: any; body: any }> { + return new Promise((resolve) => { + request.post(params, (error, response, body) => resolve({ error: error, response: response, body: body })); + }); + } + function callGet(params: any): Promise<{ error: any; response: any; body: any }> { + return new Promise((resolve) => { + request.get(params, (error, response, body) => resolve({ error: error, response: response, body: body })); + }); + } + + afterEach(() => { + sinon.restore(); + postArgs = undefined; + getArgs = undefined; + }); + + describe('option translation', () => { + it('sets parse_response=false and maps timeout to both needle timeouts', async () => { + stubPost(null, { statusCode: 200, headers: {} }, 'ok'); + await callPost({ url: 'http://1.2.3.4:80/plugin_install', timeout: 12345, formData: { a: 'b' } }); + expect(postArgs.options.parse_response).to.equal(false); + expect(postArgs.options.open_timeout).to.equal(12345); + expect(postArgs.options.read_timeout).to.equal(12345); + }); + + it('passes through headers', async () => { + stubPost(null, { statusCode: 200, headers: {} }, 'ok'); + await callPost({ url: 'http://1.2.3.4:80/x', headers: { 'User-Agent': 'roku-deploy/test' }, formData: { a: 'b' } }); + expect(postArgs.options.headers).to.eql({ 'User-Agent': 'roku-deploy/test' }); + }); + + it('translates auth into digest username/password', async () => { + stubPost(null, { statusCode: 200, headers: {} }, 'ok'); + await callPost({ + url: 'http://1.2.3.4:80/x', + auth: { user: 'rokudev', pass: 'aaaa', sendImmediately: false }, + formData: { a: 'b' } + }); + expect(postArgs.options.auth).to.equal('digest'); + expect(postArgs.options.username).to.equal('rokudev'); + expect(postArgs.options.password).to.equal('aaaa'); + }); + + it('bakes the qs object into the url', async () => { + stubGet(null, { statusCode: 200, headers: {} }, 'ok'); + await callGet({ url: 'http://1.2.3.4:80/plugin_install', qs: { dcl_enabled: '1', foo: 'bar' } }); + expect(getArgs.url).to.equal('http://1.2.3.4:80/plugin_install?dcl_enabled=1&foo=bar'); + }); + + it('appends qs with & when the url already has a query string', async () => { + stubGet(null, { statusCode: 200, headers: {} }, 'ok'); + await callGet({ url: 'http://1.2.3.4:80/x?already=1', qs: { more: '2' } }); + expect(getArgs.url).to.equal('http://1.2.3.4:80/x?already=1&more=2'); + }); + }); + + describe('formData / multipart translation', () => { + it('enables multipart and passes form data when fields are present', async () => { + stubPost(null, { statusCode: 200, headers: {} }, 'ok'); + await callPost({ url: 'http://1.2.3.4:80/x', formData: { mysubmit: 'Replace' } }); + expect(postArgs.options.multipart).to.equal(true); + expect(postArgs.data).to.eql({ mysubmit: 'Replace' }); + }); + + it('drops null/undefined/empty-string fields (request did this implicitly)', async () => { + stubPost(null, { statusCode: 200, headers: {} }, 'ok'); + await callPost({ + url: 'http://1.2.3.4:80/x', + formData: { keep: 'yes', empty: '', nothing: null, missing: undefined } + }); + expect(postArgs.data).to.eql({ keep: 'yes' }); + }); + + it('does NOT enable multipart for a bodyless POST (e.g. ECP keypress)', async () => { + stubPost(null, { statusCode: 200, headers: {} }, ''); + await callPost({ url: 'http://1.2.3.4:8060/keypress/Home' }); + expect(postArgs.options.multipart).to.be.undefined; + expect(postArgs.data).to.be.null; + }); + + it('does NOT enable multipart when all form fields were dropped', async () => { + stubPost(null, { statusCode: 200, headers: {} }, ''); + await callPost({ url: 'http://1.2.3.4:80/x', formData: { archive: '', mysubmit: null } }); + expect(postArgs.options.multipart).to.be.undefined; + expect(postArgs.data).to.be.null; + }); + + it('translates a readable stream field into needle file-by-path form', async () => { + stubPost(null, { statusCode: 200, headers: {} }, 'ok'); + //a fake fs.ReadStream-ish object + const fakeStream: any = new PassThrough(); + fakeStream.path = '/tmp/archive.zip'; + await callPost({ url: 'http://1.2.3.4:80/x', formData: { archive: fakeStream, mysubmit: 'Replace' } }); + expect(postArgs.data.archive).to.eql({ file: '/tmp/archive.zip', content_type: 'application/octet-stream' }); + expect(postArgs.data.mysubmit).to.equal('Replace'); + }); + }); + + describe('body coercion', () => { + it('coerces a Buffer body to a string', async () => { + stubPost(null, { statusCode: 200, headers: {} }, Buffer.from('hello world')); + const { body } = await callPost({ url: 'http://1.2.3.4:80/x', formData: { a: 'b' } }); + expect(body).to.equal('hello world'); + expect(typeof body).to.equal('string'); + }); + + it('coerces an empty Buffer (typical bare 401 body) to an empty string', async () => { + stubPost(null, { statusCode: 401, headers: {} }, Buffer.alloc(0)); + const { body } = await callPost({ url: 'http://1.2.3.4:80/x', formData: { a: 'b' } }); + expect(body).to.equal(''); + expect(typeof body).to.equal('string'); + }); + + it('passes a string body through unchanged', async () => { + stubGet(null, { statusCode: 200, headers: {} }, ''); + const { body } = await callGet({ url: 'http://1.2.3.4:8060/query/device-info' }); + expect(body).to.equal(''); + }); + }); + + describe('response reshape (postman-request compatibility)', () => { + it('exposes statusCode, headers, request.host and request.href', async () => { + stubPost(null, { statusCode: 401, headers: { server: 'Roku' } }, ''); + const { response } = await callPost({ url: 'http://1.2.3.4:80/plugin_install', formData: { a: 'b' } }); + expect(response.statusCode).to.equal(401); + expect(response.headers).to.eql({ server: 'Roku' }); + //request.host strips the default :80 (matches request's behavior / URL semantics) + expect(response.request.host).to.equal('1.2.3.4'); + expect(response.request.href).to.equal('http://1.2.3.4:80/plugin_install'); + }); + + it('keeps a non-default port in request.host', async () => { + stubGet(null, { statusCode: 200, headers: {} }, 'ok'); + const { response } = await callGet({ url: 'http://1.2.3.4:8060/query/device-info' }); + expect(response.request.host).to.equal('1.2.3.4:8060'); + }); + + it('passes the raw headers object through (so callers can read e.g. headers.server)', async () => { + stubGet(null, { statusCode: 500, headers: { server: 'Apache', 'content-type': 'text/html' } }, 'body'); + const { response } = await callGet({ url: 'http://1.2.3.4:8060/query/device-info' }); + expect(response.headers.server).to.equal('Apache'); + expect(response.headers['content-type']).to.equal('text/html'); + }); + + it('yields an undefined response (no crash) when needle delivers no response object', async () => { + //request/postman-request could call back with no response; checkRequest guards on this. + stubPost(null, undefined, undefined); + const { error, response } = await callPost({ url: 'http://1.2.3.4:80/x', formData: { a: 'b' } }); + expect(error).to.be.null; + expect(response).to.be.undefined; + }); + }); + + describe('error passthrough', () => { + it('forwards a needle error to the callback with undefined response/body (post)', async () => { + const networkError = new Error('socket hang up'); + stubPost(networkError, undefined, undefined); + const { error, response, body } = await callPost({ url: 'http://1.2.3.4:80/x', formData: { a: 'b' } }); + expect(error).to.equal(networkError); + expect(response).to.be.undefined; + expect(body).to.be.undefined; + }); + + it('forwards a needle error to the callback with undefined response/body (get)', async () => { + const networkError = new Error('ECONNREFUSED'); + stubGet(networkError, undefined, undefined); + const { error, response, body } = await callGet({ url: 'http://1.2.3.4:8060/x' }); + expect(error).to.equal(networkError); + expect(response).to.be.undefined; + expect(body).to.be.undefined; + }); + }); + + describe('streaming get (getToFile path)', () => { + it('returns the needle stream when no callback is given', () => { + const fakeStream = new PassThrough(); + sinon.stub(needleClient, 'get').returns(fakeStream as any); + const result = request.get({ url: 'http://1.2.3.4:80/pkgs/dev.pkg', auth: { user: 'u', pass: 'p' } } as any); + expect(result).to.equal(fakeStream); + }); + + it(`bridges needle's 'err' event to 'error'`, (done) => { + const fakeStream = new PassThrough(); + sinon.stub(needleClient, 'get').returns(fakeStream as any); + const stream: any = request.get({ url: 'http://1.2.3.4:80/x', auth: { user: 'u', pass: 'p' } } as any); + const theError = new Error('stream blew up'); + stream.on('error', (e) => { + expect(e).to.equal(theError); + done(); + }); + fakeStream.emit('err', theError); + }); + + it('swallows the intermediate 401 response, then forwards the retried 200 (digest auth)', () => { + const fakeStream = new PassThrough(); + sinon.stub(needleClient, 'get').returns(fakeStream as any); + const stream: any = request.get({ url: 'http://1.2.3.4:80/pkgs/dev.pkg', auth: { user: 'u', pass: 'p' } } as any); + + const seen: number[] = []; + stream.on('response', (resp) => seen.push(resp.statusCode)); + + //needle emits the digest challenge first, then the authenticated response + fakeStream.emit('response', { statusCode: 401 }); + fakeStream.emit('response', { statusCode: 200 }); + + //only the final 200 should have been surfaced + expect(seen).to.eql([200]); + }); + + it('does NOT swallow a 401 when there is no digest auth (no credentials)', () => { + const fakeStream = new PassThrough(); + sinon.stub(needleClient, 'get').returns(fakeStream as any); + const stream: any = request.get({ url: 'http://1.2.3.4:8060/x' } as any); + + const seen: number[] = []; + stream.on('response', (resp) => seen.push(resp.statusCode)); + fakeStream.emit('response', { statusCode: 401 }); + + expect(seen).to.eql([401]); + }); + }); +}); diff --git a/src/request.ts b/src/request.ts index f7d1a52..7031649 100644 --- a/src/request.ts +++ b/src/request.ts @@ -3,6 +3,16 @@ import * as needle from 'needle'; import type { ReadStream } from 'fs'; import type * as requestType from 'request'; +/** + * Module seam for needle so tests can stub the underlying HTTP calls and assert + * that the shim translates options and reshapes responses correctly. Mirrors the + * `httpClient` seam in `fetch.ts`. + */ +export const needleClient = { + post: needle.post.bind(needle), + get: needle.get.bind(needle) +}; + /** * A thin compatibility shim over `needle` that mimics the small slice of the * `request`/`postman-request` API that roku-deploy relies on. We migrated off @@ -171,6 +181,12 @@ function coerceBody(body: any): string { * roku-deploy expects to receive (and to find attached to thrown errors). */ function buildResponse(needleResponse: any, url: string): RequestResponse { + //`request`/`postman-request` could hand back a callback with no response object; roku-deploy's + //`checkRequest` explicitly guards on `!results.response`. Preserve that by passing through a missing + //response rather than throwing while trying to reshape it. + if (!needleResponse) { + return undefined; + } let host: string; try { host = new URL(url).host; @@ -195,7 +211,7 @@ export const request = { */ post: (params: requestType.OptionsWithUrl, callback: RequestCallback) => { const { url, data, needleOptions } = translateOptions(params, 'POST'); - return needle.post(url, data, needleOptions, (error, response, body) => { + return needleClient.post(url, data, needleOptions, (error, response, body) => { if (error) { return callback(error, undefined, undefined); } @@ -212,7 +228,7 @@ export const request = { get: (params: requestType.OptionsWithUrl, callback?: RequestCallback) => { const { url, needleOptions } = translateOptions(params, 'GET'); if (callback) { - return needle.get(url, needleOptions, (error, response, body) => { + return needleClient.get(url, needleOptions, (error, response, body) => { if (error) { return callback(error, undefined, undefined); } @@ -220,7 +236,7 @@ export const request = { }); } //streaming form (no callback) - used by getToFile to pipe the response to disk. - const stream = needle.get(url, needleOptions); + const stream = needleClient.get(url, needleOptions); //needle's stream emits its failures on the `'err'` event, but `request` (and roku-deploy's //getToFile) listens on `'error'`, so bridge `'err'` -> `'error'` to preserve that behavior. From e4c6a82503f8a2691c54cb80573d54e1ddedcfa8 Mon Sep 17 00:00:00 2001 From: Bronley Plumb Date: Thu, 4 Jun 2026 14:15:28 -0400 Subject: [PATCH 03/13] Cover remaining request.ts branches to satisfy the 100% coverage gate CI enforces 100% coverage; the new shim had a few uncovered branches. Add targeted tests for: the username/password auth alias, null/undefined qs values (and an all-null qs leaving the url untouched), null and non-string/non-buffer body coercion, an unparseable url leaving request.host undefined, and a digest-auth 'response' event carrying no response object. Co-Authored-By: Claude Opus 4.8 --- src/request.spec.ts | 58 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/request.spec.ts b/src/request.spec.ts index 05c8b8a..ae7b602 100644 --- a/src/request.spec.ts +++ b/src/request.spec.ts @@ -84,6 +84,18 @@ describe('request (needle shim)', () => { expect(postArgs.options.password).to.equal('aaaa'); }); + it('accepts auth specified as username/password (request alias)', async () => { + stubPost(null, { statusCode: 200, headers: {} }, 'ok'); + await callPost({ + url: 'http://1.2.3.4:80/x', + auth: { username: 'rokudev', password: 'aaaa' }, + formData: { a: 'b' } + }); + expect(postArgs.options.auth).to.equal('digest'); + expect(postArgs.options.username).to.equal('rokudev'); + expect(postArgs.options.password).to.equal('aaaa'); + }); + it('bakes the qs object into the url', async () => { stubGet(null, { statusCode: 200, headers: {} }, 'ok'); await callGet({ url: 'http://1.2.3.4:80/plugin_install', qs: { dcl_enabled: '1', foo: 'bar' } }); @@ -95,6 +107,18 @@ describe('request (needle shim)', () => { await callGet({ url: 'http://1.2.3.4:80/x?already=1', qs: { more: '2' } }); expect(getArgs.url).to.equal('http://1.2.3.4:80/x?already=1&more=2'); }); + + it('skips null/undefined qs values', async () => { + stubGet(null, { statusCode: 200, headers: {} }, 'ok'); + await callGet({ url: 'http://1.2.3.4:80/x', qs: { keep: '1', drop: null, gone: undefined } }); + expect(getArgs.url).to.equal('http://1.2.3.4:80/x?keep=1'); + }); + + it('leaves the url untouched when every qs value is null/undefined', async () => { + stubGet(null, { statusCode: 200, headers: {} }, 'ok'); + await callGet({ url: 'http://1.2.3.4:80/x', qs: { drop: null, gone: undefined } }); + expect(getArgs.url).to.equal('http://1.2.3.4:80/x'); + }); }); describe('formData / multipart translation', () => { @@ -159,6 +183,19 @@ describe('request (needle shim)', () => { const { body } = await callGet({ url: 'http://1.2.3.4:8060/query/device-info' }); expect(body).to.equal(''); }); + + it('coerces a null/undefined body to an empty string', async () => { + stubGet(null, { statusCode: 200, headers: {} }, null); + const { body } = await callGet({ url: 'http://1.2.3.4:8060/x' }); + expect(body).to.equal(''); + }); + + it('stringifies a non-string, non-buffer body (defensive fallback)', async () => { + //parse_response:false should keep bodies as Buffers, but guard against a parsed value anyway + stubGet(null, { statusCode: 200, headers: {} }, 1234 as any); + const { body } = await callGet({ url: 'http://1.2.3.4:8060/x' }); + expect(body).to.equal('1234'); + }); }); describe('response reshape (postman-request compatibility)', () => { @@ -192,6 +229,13 @@ describe('request (needle shim)', () => { expect(error).to.be.null; expect(response).to.be.undefined; }); + + it('leaves request.host undefined when the url cannot be parsed', async () => { + stubGet(null, { statusCode: 200, headers: {} }, 'ok'); + const { response } = await callGet({ url: 'not-a-valid-url' }); + expect(response.request.host).to.be.undefined; + expect(response.request.href).to.equal('not-a-valid-url'); + }); }); describe('error passthrough', () => { @@ -261,5 +305,19 @@ describe('request (needle shim)', () => { expect(seen).to.eql([401]); }); + + it('forwards a response event that has no response object (digest auth)', () => { + const fakeStream = new PassThrough(); + sinon.stub(needleClient, 'get').returns(fakeStream as any); + const stream: any = request.get({ url: 'http://1.2.3.4:80/x', auth: { user: 'u', pass: 'p' } } as any); + + let fired = false; + stream.on('response', () => { + fired = true; + }); + //an undefined resp must not be mistaken for the 401 challenge to swallow + fakeStream.emit('response', undefined); + expect(fired).to.be.true; + }); }); }); From 9705af4d992f5bf43ae2e4d983ae12c6689d1657 Mon Sep 17 00:00:00 2001 From: Bronley Plumb Date: Thu, 4 Jun 2026 16:30:28 -0400 Subject: [PATCH 04/13] Match postman-request's response.body, verified by old-vs-new parity testing Ran an empirical parity check: installed the last postman-request release (3.17.6) alongside the new needle build and triggered every device-reproducible failure mode against a real Roku, diffing the thrown error structures. 7/8 were already byte-identical. The one real gap: postman-request attached the (string) body to `response.body` in addition to returning it as the callback's 3rd arg, but the shim left `response.body` undefined. A consumer reading `error.results.response.body` would have broken. Fixed buildResponse to set it. (The 8th difference is the raw network error on a dead host -- ETIMEDOUT vs ECONNRESET -- which is the underlying HTTP library's own error passed through untouched; the contract, a bare Error with no results, is identical.) Pin the findings with permanent, device-free unit tests that deep-equal the FULL response/results structure the parity run confirmed (request.spec.ts and the RokuDeploy.spec.ts error-results block). Co-Authored-By: Claude Opus 4.8 --- src/RokuDeploy.spec.ts | 26 ++++++++++++++++++++++++++ src/request.spec.ts | 31 +++++++++++++++++++++++++++++++ src/request.ts | 17 +++++++++++------ 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/RokuDeploy.spec.ts b/src/RokuDeploy.spec.ts index d6b3b1f..32e68d7 100644 --- a/src/RokuDeploy.spec.ts +++ b/src/RokuDeploy.spec.ts @@ -240,6 +240,32 @@ describe('RokuDeploy', () => { expect(caught.results.response.headers).to.be.an('object'); expect(caught.results.body).to.be.a('string'); }); + + it('attaches the EXACT full results structure (verified against postman-request 3.17.6)', async () => { + //The parity harness confirmed this is the complete `results` object the old + //postman-request build produced for a 401. Deep-equal the whole thing so any future + //drift in the shim's reshape is caught. + const headers = { 'content-length': '0', 'www-authenticate': 'Digest realm="rokudev"' }; + stubNeedlePost({ statusCode: 401, headers: headers }, Buffer.alloc(0)); + let caught: any; + try { + await rokuDeploy.deleteInstalledChannel({ host: '1.2.3.4', password: 'aaaa' } as any); + } catch (e) { + caught = e; + } + expect(caught.results).to.eql({ + response: { + statusCode: 401, + headers: { 'content-length': '0', 'www-authenticate': 'Digest realm="rokudev"' }, + request: { + host: '1.2.3.4', + href: 'http://1.2.3.4:80/plugin_install' + }, + body: '' + }, + body: '' + }); + }); }); describe('InvalidDeviceResponseCodeError (non-200)', () => { diff --git a/src/request.spec.ts b/src/request.spec.ts index ae7b602..e1fa8b2 100644 --- a/src/request.spec.ts +++ b/src/request.spec.ts @@ -199,6 +199,37 @@ describe('request (needle shim)', () => { }); describe('response reshape (postman-request compatibility)', () => { + it('produces the EXACT full response structure (verified against postman-request 3.17.6)', async () => { + //This is the complete shape that the parity harness confirmed the old postman-request build + //emitted for a 401 on `plugin_install`. The whole object is part of roku-deploy's public + //contract (it's attached to thrown errors), so we deep-equal the entire thing. + const headers = { 'content-length': '0', 'www-authenticate': 'Digest realm="rokudev"' }; + stubPost(null, { statusCode: 401, headers: headers }, Buffer.alloc(0)); + const { response } = await callPost({ + url: 'http://1.2.3.4:80/plugin_install', + auth: { user: 'rokudev', pass: 'aaaa' }, + formData: { mysubmit: 'Delete', archive: '' } + }); + expect(response).to.eql({ + statusCode: 401, + headers: { 'content-length': '0', 'www-authenticate': 'Digest realm="rokudev"' }, + request: { + host: '1.2.3.4', + href: 'http://1.2.3.4:80/plugin_install' + }, + //postman-request attached the (string) body to response.body too; the shim must match + body: '' + }); + }); + + it('attaches the string body to response.body (postman-request parity)', async () => { + stubGet(null, { statusCode: 200, headers: {} }, Buffer.from('')); + const { response, body } = await callGet({ url: 'http://1.2.3.4:8060/query/device-info' }); + //the body is on BOTH the callback arg and response.body, and they're the same string + expect(response.body).to.equal(''); + expect(response.body).to.equal(body); + }); + it('exposes statusCode, headers, request.host and request.href', async () => { stubPost(null, { statusCode: 401, headers: { server: 'Roku' } }, ''); const { response } = await callPost({ url: 'http://1.2.3.4:80/plugin_install', formData: { a: 'b' } }); diff --git a/src/request.ts b/src/request.ts index 7031649..e82cb26 100644 --- a/src/request.ts +++ b/src/request.ts @@ -46,8 +46,9 @@ export interface RequestResponse { href: string; }; /** - * The raw needle response (an `http.IncomingMessage`), in case anything - * needs to reach through to the underlying object. + * The response body, as a string. `request`/`postman-request` attached the body to + * `response.body` in addition to returning it as the callback's 3rd argument, so we mirror that + * for callers that read `error.results.response.body`. */ body: string; } @@ -180,7 +181,7 @@ function coerceBody(body: any): string { * Reshape needle's response object into the `request`-compatible response that * roku-deploy expects to receive (and to find attached to thrown errors). */ -function buildResponse(needleResponse: any, url: string): RequestResponse { +function buildResponse(needleResponse: any, url: string, body: string): RequestResponse { //`request`/`postman-request` could hand back a callback with no response object; roku-deploy's //`checkRequest` explicitly guards on `!results.response`. Preserve that by passing through a missing //response rather than throwing while trying to reshape it. @@ -200,7 +201,9 @@ function buildResponse(needleResponse: any, url: string): RequestResponse { host: host, href: url }, - body: undefined + //`request`/`postman-request` attached the (string) body to `response.body` as well as + //returning it as the 3rd callback arg. Mirror that so `error.results.response.body` matches. + body: body }; } @@ -215,7 +218,8 @@ export const request = { if (error) { return callback(error, undefined, undefined); } - return callback(null, buildResponse(response, url), coerceBody(body)); + const coerced = coerceBody(body); + return callback(null, buildResponse(response, url, coerced), coerced); }); }, @@ -232,7 +236,8 @@ export const request = { if (error) { return callback(error, undefined, undefined); } - return callback(null, buildResponse(response, url), coerceBody(body)); + const coerced = coerceBody(body); + return callback(null, buildResponse(response, url, coerced), coerced); }); } //streaming form (no callback) - used by getToFile to pipe the response to disk. From 919684018edd2f2c7f91bef8f2a3f1acfc3aa12a Mon Sep 17 00:00:00 2001 From: Bronley Plumb Date: Wed, 24 Jun 2026 14:08:07 -0400 Subject: [PATCH 05/13] More complete parity --- src/RokuDeploy.spec.ts | 27 ++++----- src/request.spec.ts | 65 ++++++++++++++++---- src/request.ts | 132 +++++++++++++++++++++++++++++++++-------- 3 files changed, 171 insertions(+), 53 deletions(-) diff --git a/src/RokuDeploy.spec.ts b/src/RokuDeploy.spec.ts index 32e68d7..c4ab417 100644 --- a/src/RokuDeploy.spec.ts +++ b/src/RokuDeploy.spec.ts @@ -241,10 +241,11 @@ describe('RokuDeploy', () => { expect(caught.results.body).to.be.a('string'); }); - it('attaches the EXACT full results structure (verified against postman-request 3.17.6)', async () => { - //The parity harness confirmed this is the complete `results` object the old - //postman-request build produced for a 401. Deep-equal the whole thing so any future - //drift in the shim's reshape is caught. + it('attaches the guaranteed results structure (verified against postman-request 3.17.6)', async () => { + //The parity harness confirmed the old postman-request build attached `{ response, body }` + //where `response` is the underlying http.IncomingMessage. The shim reproduces that (it + //returns needle's IncomingMessage), so we assert the GUARANTEED fields a consumer reads + //rather than deep-equaling the full IncomingMessage surface (which we intentionally keep). const headers = { 'content-length': '0', 'www-authenticate': 'Digest realm="rokudev"' }; stubNeedlePost({ statusCode: 401, headers: headers }, Buffer.alloc(0)); let caught: any; @@ -253,18 +254,12 @@ describe('RokuDeploy', () => { } catch (e) { caught = e; } - expect(caught.results).to.eql({ - response: { - statusCode: 401, - headers: { 'content-length': '0', 'www-authenticate': 'Digest realm="rokudev"' }, - request: { - host: '1.2.3.4', - href: 'http://1.2.3.4:80/plugin_install' - }, - body: '' - }, - body: '' - }); + expect(caught.results.body).to.equal(''); + expect(caught.results.response.statusCode).to.equal(401); + expect(caught.results.response.headers).to.eql({ 'content-length': '0', 'www-authenticate': 'Digest realm="rokudev"' }); + expect(caught.results.response.body).to.equal(''); + expect(caught.results.response.request.host).to.equal('1.2.3.4'); + expect(caught.results.response.request.href).to.equal('http://1.2.3.4:80/plugin_install'); }); }); diff --git a/src/request.spec.ts b/src/request.spec.ts index e1fa8b2..c561105 100644 --- a/src/request.spec.ts +++ b/src/request.spec.ts @@ -199,10 +199,12 @@ describe('request (needle shim)', () => { }); describe('response reshape (postman-request compatibility)', () => { - it('produces the EXACT full response structure (verified against postman-request 3.17.6)', async () => { - //This is the complete shape that the parity harness confirmed the old postman-request build - //emitted for a 401 on `plugin_install`. The whole object is part of roku-deploy's public - //contract (it's attached to thrown errors), so we deep-equal the entire thing. + it('guarantees the request-compat fields on the response (verified against postman-request 3.17.6)', async () => { + //For maximum parity the shim returns needle's underlying http.IncomingMessage (so consumers + //keep access to statusCode/statusMessage/rawHeaders/httpVersion/req/socket/... just like with + //postman-request) and layers on the `request`-compat extras postman added. We therefore + //assert the GUARANTEED fields are present/correct rather than deep-equaling the whole object + //(it legitimately carries the full IncomingMessage surface, which we intentionally preserve). const headers = { 'content-length': '0', 'www-authenticate': 'Digest realm="rokudev"' }; stubPost(null, { statusCode: 401, headers: headers }, Buffer.alloc(0)); const { response } = await callPost({ @@ -210,15 +212,18 @@ describe('request (needle shim)', () => { auth: { user: 'rokudev', pass: 'aaaa' }, formData: { mysubmit: 'Delete', archive: '' } }); - expect(response).to.eql({ - statusCode: 401, - headers: { 'content-length': '0', 'www-authenticate': 'Digest realm="rokudev"' }, - request: { - host: '1.2.3.4', - href: 'http://1.2.3.4:80/plugin_install' - }, - //postman-request attached the (string) body to response.body too; the shim must match - body: '' + expect(response.statusCode).to.equal(401); + expect(response.headers).to.eql({ 'content-length': '0', 'www-authenticate': 'Digest realm="rokudev"' }); + //postman-request attached the (string) body to response.body too; the shim must match + expect(response.body).to.equal(''); + //the request-compat object postman exposed at response.request + expect(response.request.host).to.equal('1.2.3.4'); + expect(response.request.href).to.equal('http://1.2.3.4:80/plugin_install'); + expect(response.request.uri).to.include({ + host: '1.2.3.4', + hostname: '1.2.3.4', + href: 'http://1.2.3.4:80/plugin_install', + pathname: '/plugin_install' }); }); @@ -240,6 +245,40 @@ describe('request (needle shim)', () => { expect(response.request.href).to.equal('http://1.2.3.4:80/plugin_install'); }); + it('populates request.method and request.headers from the underlying req when present', async () => { + //needle's resp IS an http.IncomingMessage with a `.req` (ClientRequest). Simulate that so the + //shim can surface the outgoing method/headers the way postman-request did. + const fakeResp: any = { + statusCode: 200, + headers: {}, + req: { + method: 'POST', + getHeaders: () => ({ 'user-agent': 'roku-deploy/test' }) + } + }; + stubGet(null, fakeResp, 'ok'); + const { response } = await callGet({ url: 'http://1.2.3.4:80/plugin_install' }); + expect(response.request.method).to.equal('POST'); + expect(response.request.headers).to.eql({ 'user-agent': 'roku-deploy/test' }); + }); + + it('does not clobber a pre-existing response.request', async () => { + //if the underlying response already carries a `request` object, leave it untouched + const preExisting = { host: 'pre.existing', href: 'http://pre.existing/x', custom: 'kept' }; + const fakeResp: any = { statusCode: 200, headers: {}, request: preExisting }; + stubGet(null, fakeResp, 'ok'); + const { response } = await callGet({ url: 'http://1.2.3.4:80/plugin_install' }); + expect(response.request).to.equal(preExisting); + expect(response.request.custom).to.equal('kept'); + }); + + it('leaves request.uri.hostname undefined when the url cannot be parsed', async () => { + stubGet(null, { statusCode: 200, headers: {} }, 'ok'); + const { response } = await callGet({ url: 'not-a-valid-url' }); + expect(response.request.host).to.be.undefined; + expect(response.request.uri.hostname).to.be.undefined; + }); + it('keeps a non-default port in request.host', async () => { stubGet(null, { statusCode: 200, headers: {} }, 'ok'); const { response } = await callGet({ url: 'http://1.2.3.4:8060/query/device-info' }); diff --git a/src/request.ts b/src/request.ts index e82cb26..a03f0e7 100644 --- a/src/request.ts +++ b/src/request.ts @@ -1,8 +1,34 @@ /* eslint-disable camelcase */ import * as needle from 'needle'; +import * as urlModule from 'url'; import type { ReadStream } from 'fs'; import type * as requestType from 'request'; +/** + * Title-case an HTTP header name the way `request`/`postman-request` preserved it on the outgoing + * request (`Content-Type`, `User-Agent`, `WWW-Authenticate`, ...). needle lowercases outgoing header + * names, so we re-case them to maximize parity with what consumers saw on `response.request.headers`. + */ +function titleCaseHeaderName(name: string): string { + return name.split('-').map(part => { + if (/^(?:WWW|TE|DNT|ETag)$/i.test(part)) { + return part.toUpperCase(); + } + return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(); + }).join('-'); +} + +function titleCaseHeaders(headers: Record | undefined): Record | undefined { + if (!headers || typeof headers !== 'object') { + return headers; + } + const out: Record = {}; + for (const key of Object.keys(headers)) { + out[titleCaseHeaderName(key)] = headers[key]; + } + return out; +} + /** * Module seam for needle so tests can stub the underlying HTTP calls and assert * that the shim translates options and reshapes responses correctly. Mirrors the @@ -28,22 +54,28 @@ export const needleClient = { */ /** - * The `response` object roku-deploy expects. `request` returned an - * `http.IncomingMessage` augmented with a `.request` property exposing the - * outgoing request details. needle exposes the outgoing request as `.req` - * instead and does not attach a `.request` object, so we synthesize the few - * fields that roku-deploy actually reads (currently just `request.host`). + * The `response` object roku-deploy (and its consumers) see. Both `postman-request` and `needle` + * hand back a Node `http.IncomingMessage`, so the real object carries far more than the few fields + * roku-deploy reads — `statusCode`, `headers`, `statusMessage`, `rawHeaders`, `httpVersion*`, + * `socket`, `req`, `complete`, etc. We keep all of that (it's needle's actual IncomingMessage) and + * layer on the `request`-compat extras postman added. The interface therefore declares the fields we + * guarantee, and allows the rest of the IncomingMessage surface via the index signature. */ export interface RequestResponse { statusCode: number; headers: Record; /** - * Mirrors `request`'s `response.request` object. roku-deploy reads - * `response.request.host` when constructing the "Unauthorized" error message. + * Mirrors `request`'s `response.request` object. roku-deploy reads `response.request.host` when + * constructing the "Unauthorized" error message; other consumers may read `href`/`uri`/`method`. */ request: { host: string; href: string; + uri?: Record; + method?: string; + headers?: Record; + /** Plus the other consumable `request` fields we reproduce (path, port, protocol, ...). */ + [key: string]: any; }; /** * The response body, as a string. `request`/`postman-request` attached the body to @@ -51,6 +83,8 @@ export interface RequestResponse { * for callers that read `error.results.response.body`. */ body: string; + /** Plus the rest of the underlying http.IncomingMessage surface (statusMessage, rawHeaders, ...). */ + [key: string]: any; } export type RequestCallback = (error: Error | null, response: RequestResponse | undefined, body: string | undefined) => void; @@ -178,33 +212,83 @@ function coerceBody(body: any): string { } /** - * Reshape needle's response object into the `request`-compatible response that - * roku-deploy expects to receive (and to find attached to thrown errors). + * Reshape needle's response into the `request`-compatible response roku-deploy expects (and attaches + * to thrown errors). + * + * Maximum-parity strategy: needle's callback `resp` is the *same* underlying Node + * `http.IncomingMessage` that `postman-request` exposed (needle just augments it with `.body`/`.raw`/ + * `.bytes`). So rather than fabricate a minimal plain object — which would drop everything underneath + * (`statusCode`/`statusMessage`/`rawHeaders`/`httpVersion*`/`socket`/`req`/`complete`/... that a + * consumer might reach into) — we KEEP needle's IncomingMessage and only layer on the two things + * `request`/`postman-request` added on top of it: + * 1. a `.request` object exposing the outgoing-request fields consumers read (`host`, `href`, ...), + * 2. a string `.body` (postman put the decoded string here; needle leaves a Buffer under + * `parse_response:false`). + * This way the vast majority of the old response's reachable surface is reproduced for free. */ function buildResponse(needleResponse: any, url: string, body: string): RequestResponse { //`request`/`postman-request` could hand back a callback with no response object; roku-deploy's //`checkRequest` explicitly guards on `!results.response`. Preserve that by passing through a missing - //response rather than throwing while trying to reshape it. + //response rather than throwing while trying to augment it. if (!needleResponse) { return undefined; } - let host: string; + + //Parse with the legacy `url.parse()` to match postman-request's `response.request.uri` exactly: it + //was a Node `Url` object (host WITH port, plus auth/hash/query/search/slashes/protocol/port). The + //WHATWG `URL` would strip the default `:80` and omit these fields, so we deliberately use the legacy + //parser here for byte-parity with what consumers read off `response.request.uri.*`. + let uri: Record; try { - host = new URL(url).host; + const u = urlModule.parse(url); + uri = { + protocol: u.protocol, + slashes: u.slashes, + auth: u.auth, + host: u.host, + port: u.port, + hostname: u.hostname, + hash: u.hash, + search: u.search, + query: u.query, + pathname: u.pathname, + path: u.path, + href: u.href + }; } catch { - host = undefined; + uri = { href: url, host: undefined, hostname: undefined, port: null, protocol: null, path: url, pathname: url, search: null, query: null, hash: null, auth: null, slashes: null }; } - return { - statusCode: needleResponse.statusCode, - headers: needleResponse.headers, - request: { - host: host, - href: url - }, - //`request`/`postman-request` attached the (string) body to `response.body` as well as - //returning it as the 3rd callback arg. Mirror that so `error.results.response.body` matches. - body: body - }; + + //needle's resp IS the http.IncomingMessage. Augment it in place to mirror postman-request's shape. + const response = needleResponse; + + //`request`/`postman-request` attached the (string) body to `response.body`. needle leaves a Buffer + //here under parse_response:false, so overwrite with the decoded string to match. + response.body = body; + + //`request`/`postman-request` exposed its outgoing `Request` object at `response.request`. Its + //library-internal guts (`_auth`, `_form`, `_qs`, `httpModule`, `pool`, ...) can't exist without the + //`request` package, but we reproduce every CONSUMABLE field a caller could portably read. Don't + //clobber it if needle/Node ever populates one. + if (!response.request) { + const outgoingHeaders = titleCaseHeaders(response.req?.getHeaders?.()); + response.request = { + uri: uri, + method: response.req?.method ?? undefined, + headers: outgoingHeaders, + host: uri.hostname, + href: uri.href, + path: uri.path, + port: uri.port ?? undefined, + originalHost: uri.hostname, + originalHostHeaderName: 'Host', + protocol: uri.protocol, + readable: true, + writable: true + }; + } + + return response as RequestResponse; } export const request = { From 7ae80ae857b317602d4d9366085158414957ae6e Mon Sep 17 00:00:00 2001 From: Bronley Plumb Date: Wed, 24 Jun 2026 22:39:26 -0400 Subject: [PATCH 06/13] Simplify buildResponse url handling + align response-shape tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widened buildResponse (full IncomingMessage + maximized request object) had two coverage-blocking spots that were over-engineering, not real safeguards: - a try/catch around `url.parse()`, whose catch was unreachable (`url.parse` is total for string input — a bare token yields null host/hostname, never throws), - an acronym special-case in the header title-caser (WWW/TE/DNT/ETag), which only applies to response headers, not the outgoing-request headers this touches. Removed both, and updated the request.spec assertions to match the parity-faithful shape that url.parse produces: response.request.host is the hostname (no port), the port lives on response.request.uri.host/uri.port, unparseable urls yield null (matching postman-request, which also used url.parse), and outgoing headers are title-cased. preversion is green: build + lint + 421 tests passing + 100% coverage. Co-Authored-By: Claude Opus 4.8 --- src/request.spec.ts | 25 +++++++++++++++--------- src/request.ts | 47 ++++++++++++++++++++------------------------- 2 files changed, 37 insertions(+), 35 deletions(-) diff --git a/src/request.spec.ts b/src/request.spec.ts index c561105..e5d8eb3 100644 --- a/src/request.spec.ts +++ b/src/request.spec.ts @@ -220,8 +220,10 @@ describe('request (needle shim)', () => { expect(response.request.host).to.equal('1.2.3.4'); expect(response.request.href).to.equal('http://1.2.3.4:80/plugin_install'); expect(response.request.uri).to.include({ - host: '1.2.3.4', + host: '1.2.3.4:80', hostname: '1.2.3.4', + port: '80', + protocol: 'http:', href: 'http://1.2.3.4:80/plugin_install', pathname: '/plugin_install' }); @@ -259,7 +261,8 @@ describe('request (needle shim)', () => { stubGet(null, fakeResp, 'ok'); const { response } = await callGet({ url: 'http://1.2.3.4:80/plugin_install' }); expect(response.request.method).to.equal('POST'); - expect(response.request.headers).to.eql({ 'user-agent': 'roku-deploy/test' }); + //needle lowercases outgoing header names; the shim re-cases them to match postman-request + expect(response.request.headers).to.eql({ 'User-Agent': 'roku-deploy/test' }); }); it('does not clobber a pre-existing response.request', async () => { @@ -272,17 +275,21 @@ describe('request (needle shim)', () => { expect(response.request.custom).to.equal('kept'); }); - it('leaves request.uri.hostname undefined when the url cannot be parsed', async () => { + it('leaves request.uri.hostname null when the url cannot be parsed (url.parse parity)', async () => { stubGet(null, { statusCode: 200, headers: {} }, 'ok'); const { response } = await callGet({ url: 'not-a-valid-url' }); - expect(response.request.host).to.be.undefined; - expect(response.request.uri.hostname).to.be.undefined; + //url.parse() of a bare token yields null host/hostname (this is what postman-request produced too) + expect(response.request.host).to.be.null; + expect(response.request.uri.hostname).to.be.null; }); - it('keeps a non-default port in request.host', async () => { + it('exposes the port via request.uri (host=hostname, uri.host=host:port) — postman-request parity', async () => { stubGet(null, { statusCode: 200, headers: {} }, 'ok'); const { response } = await callGet({ url: 'http://1.2.3.4:8060/query/device-info' }); - expect(response.request.host).to.equal('1.2.3.4:8060'); + //postman-request set response.request.host to the hostname only; the port lived on uri.host + expect(response.request.host).to.equal('1.2.3.4'); + expect(response.request.uri.host).to.equal('1.2.3.4:8060'); + expect(response.request.uri.port).to.equal('8060'); }); it('passes the raw headers object through (so callers can read e.g. headers.server)', async () => { @@ -300,10 +307,10 @@ describe('request (needle shim)', () => { expect(response).to.be.undefined; }); - it('leaves request.host undefined when the url cannot be parsed', async () => { + it('still exposes the raw url as request.href when the url cannot be parsed', async () => { stubGet(null, { statusCode: 200, headers: {} }, 'ok'); const { response } = await callGet({ url: 'not-a-valid-url' }); - expect(response.request.host).to.be.undefined; + expect(response.request.host).to.be.null; expect(response.request.href).to.equal('not-a-valid-url'); }); }); diff --git a/src/request.ts b/src/request.ts index a03f0e7..25e53fb 100644 --- a/src/request.ts +++ b/src/request.ts @@ -10,12 +10,10 @@ import type * as requestType from 'request'; * names, so we re-case them to maximize parity with what consumers saw on `response.request.headers`. */ function titleCaseHeaderName(name: string): string { - return name.split('-').map(part => { - if (/^(?:WWW|TE|DNT|ETag)$/i.test(part)) { - return part.toUpperCase(); - } - return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(); - }).join('-'); + //title-case each hyphen-delimited segment (Content-Type, User-Agent, Authorization, ...). This + //covers the outgoing request headers roku-deploy sends; we don't special-case acronym headers + //(WWW-Authenticate etc.) because those are response headers, not outgoing-request headers. + return name.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()).join('-'); } function titleCaseHeaders(headers: Record | undefined): Record | undefined { @@ -238,26 +236,23 @@ function buildResponse(needleResponse: any, url: string, body: string): RequestR //was a Node `Url` object (host WITH port, plus auth/hash/query/search/slashes/protocol/port). The //WHATWG `URL` would strip the default `:80` and omit these fields, so we deliberately use the legacy //parser here for byte-parity with what consumers read off `response.request.uri.*`. - let uri: Record; - try { - const u = urlModule.parse(url); - uri = { - protocol: u.protocol, - slashes: u.slashes, - auth: u.auth, - host: u.host, - port: u.port, - hostname: u.hostname, - hash: u.hash, - search: u.search, - query: u.query, - pathname: u.pathname, - path: u.path, - href: u.href - }; - } catch { - uri = { href: url, host: undefined, hostname: undefined, port: null, protocol: null, path: url, pathname: url, search: null, query: null, hash: null, auth: null, slashes: null }; - } + //`url.parse()` is total for string input (it never throws — a bare token like 'not-a-valid-url' + //yields null host/hostname and the token as path/pathname), so no try/catch is needed. + const u = urlModule.parse(url); + const uri: Record = { + protocol: u.protocol, + slashes: u.slashes, + auth: u.auth, + host: u.host, + port: u.port, + hostname: u.hostname, + hash: u.hash, + search: u.search, + query: u.query, + pathname: u.pathname, + path: u.path, + href: u.href + }; //needle's resp IS the http.IncomingMessage. Augment it in place to mirror postman-request's shape. const response = needleResponse; From 0a5e3c21378b53624ebd2642096125087deade93 Mon Sep 17 00:00:00 2001 From: Bronley Plumb Date: Wed, 1 Jul 2026 09:14:56 -0400 Subject: [PATCH 07/13] restore original device.spec strcture (and add back new tests) --- src/device.spec.ts | 363 ++++++++++++++------------------------------- 1 file changed, 109 insertions(+), 254 deletions(-) diff --git a/src/device.spec.ts b/src/device.spec.ts index 1761507..2add3ca 100644 --- a/src/device.spec.ts +++ b/src/device.spec.ts @@ -1,86 +1,57 @@ import * as assert from 'assert'; import * as fsExtra from 'fs-extra'; -import * as semver from 'semver'; -import { expect } from 'chai'; import * as rokuDeploy from './index'; -import { RokuDeploy } from './RokuDeploy'; -import * as errors from './Errors'; import { cwd, expectPathExists, expectThrowsAsync, outDir, rootDir, tempDir, writeFiles } from './testUtils.spec'; import undent from 'undent'; -/** - * These tests run against an ACTUAL Roku device on the local network. They are excluded from the - * normal/CI test run (see the `test` script's `--exclude`) and are intended to be run manually with - * `npm run test:device` whenever the networking layer changes (e.g. the postman-request -> needle migration). - * - * Goals: - * - Exercise EVERY networking-enabled public method on RokuDeploy at least once. - * - Be resilient to the device's actual data changing over time. We assert on STRUCTURE and TYPES - * (e.g. "softwareVersion is semver-ish", "deviceInfo is a non-empty object") rather than exact - * values, since model/version/serial/etc. differ per device and per firmware update. - * - * Device connection info is hardcoded below for convenience. Override via env vars if needed: - * - ROKU_HOST (default 192.168.1.32) - * - ROKU_PASSWORD (default aaaa) - * - ROKU_SIGNING_PASSWORD (no default) - required to run the package-signing tests - * - ROKU_RUN_DISRUPTIVE=1 - opt in to disruptive tests that reboot / trigger update checks - */ -const HOST = process.env.ROKU_HOST ?? '192.168.1.32'; -const PASSWORD = process.env.ROKU_PASSWORD ?? 'aaaa'; -const SIGNING_PASSWORD = process.env.ROKU_SIGNING_PASSWORD; -const RUN_DISRUPTIVE = process.env.ROKU_RUN_DISRUPTIVE === '1'; - +//these tests are run against an actual roku device. These cannot be enabled when run on the CI server describe('device', function device() { - this.timeout(30000); - let options: rokuDeploy.RokuDeployOptions; - let rd: RokuDeploy; - /** - * Write a minimal-but-runnable channel into rootDir so screenshots/deploys have a real, - * compilable app to work with. (A channel that fails to compile would break screenshot tests.) - */ - function writeMinimalChannel() { + beforeEach(() => { + fsExtra.emptyDirSync(tempDir); + fsExtra.ensureDirSync(rootDir); + process.chdir(rootDir); + options = rokuDeploy.getOptions({ + outDir: outDir, + host: '192.168.1.93', + retainDeploymentArchive: true, + password: 'aaaa', + devId: 'c6fdc2019903ac3332f624b0b2c2fe2c733c3e74', + rekeySignedPackage: `${cwd}/testSignedPackage.pkg`, + signingPassword: 'drRCEVWP/++K5TYnTtuAfQ==' + }); + writeFiles(rootDir, [ ['manifest', undent` title=RokuDeployTestChannel major_version=1 minor_version=0 build_version=0 + splash_screen_hd=pkg:/images/splash_hd.jpg ui_resolutions=hd + bs_const=IS_DEV_BUILD=false + splash_color=#000000 `], ['source/main.brs', undent` - sub Main() + Sub RunUserInterface() screen = CreateObject("roSGScreen") + m.scene = screen.CreateScene("HomeScene") port = CreateObject("roMessagePort") - screen.setMessagePort(port) - scene = screen.CreateScene("HomeScene") - screen.show() - while true + screen.SetMessagePort(port) + screen.Show() + + while(true) msg = wait(0, port) - if type(msg) = "roSGScreenEvent" and msg.isScreenClosed() then return end while - end sub - `], - ['components/HomeScene.xml', undent` - - + + if screen <> invalid then + screen.Close() + screen = invalid + end if + End Sub `] ]); - } - - beforeEach(() => { - fsExtra.emptyDirSync(tempDir); - fsExtra.ensureDirSync(rootDir); - process.chdir(rootDir); - rd = new RokuDeploy(); - options = rokuDeploy.getOptions({ - outDir: outDir, - host: HOST, - retainDeploymentArchive: true, - password: PASSWORD - }); - writeMinimalChannel(); }); afterEach(() => { @@ -89,259 +60,143 @@ describe('device', function device() { fsExtra.emptyDirSync(tempDir); }); - /** - * Assert that a value is a "response-ish" object as returned by doPostRequest/doGetRequest: - * `{ response: { statusCode, headers }, body: string }`. This is the postman-request-compatible - * shape that roku-deploy promises, so it's worth pinning down explicitly after the needle migration. - */ - function expectHttpResponseShape(result: any) { - expect(result).to.be.an('object'); - expect(result.response, 'result.response').to.be.an('object'); - expect(result.response.statusCode, 'result.response.statusCode').to.be.a('number'); - expect(result.response.headers, 'result.response.headers').to.be.an('object'); - expect(result.body, 'result.body').to.be.a('string'); - } + this.timeout(20000); - describe('getDeviceInfo', () => { - it('returns a raw device-info object with the expected structure/types', async () => { - const info = await rd.getDeviceInfo({ host: HOST }); - //it's a non-empty object - expect(info).to.be.an('object'); - expect(Object.keys(info).length).to.be.greaterThan(10); - //raw values come back as strings (xml2js). Check a few well-known keys exist and are strings. - expect(info['udn'], 'udn').to.be.a('string').with.length.greaterThan(0); - expect(info['serial-number'], 'serial-number').to.be.a('string').with.length.greaterThan(0); - expect(info['software-version'], 'software-version').to.be.a('string'); - //software-version should look like a version number (e.g. "15.2.4") - expect(/^\d+\.\d+/.test(info['software-version']), `software-version "${info['software-version']}" should start with N.N`).to.be.true; - }); - - it('returns an enhanced device-info object with normalized types', async () => { - const info = await rd.getDeviceInfo({ host: HOST, enhance: true }); - expect(info).to.be.an('object'); - //camelCased keys - expect(info.udn, 'udn').to.be.a('string').with.length.greaterThan(0); - expect(info.serialNumber, 'serialNumber').to.be.a('string').with.length.greaterThan(0); - //softwareVersion should be coercible to a valid semver - expect(semver.valid(semver.coerce(info.softwareVersion)), `softwareVersion "${info.softwareVersion}" should be semver-coercible`).to.not.be.null; - //a "true"/"false" string should have been normalized to a real boolean - expect(info.supportsEthernet, 'supportsEthernet').to.be.a('boolean'); - //a numeric string should have been normalized to a real number - if (info.softwareBuild !== undefined) { - expect(info.softwareBuild, 'softwareBuild').to.be.a('number'); - } - //ecpSettingMode should be one of the known modes (or undefined on older firmware) - if (info.ecpSettingMode !== undefined) { - expect(['enabled', 'disabled', 'limited', 'permissive']).to.include(info.ecpSettingMode); - } + describe('deploy', () => { + it('works', async () => { + options.retainDeploymentArchive = true; + let response = await rokuDeploy.deploy(options); + assert.equal(response.message, 'Successful deploy'); }); - it('honors a custom remotePort by failing fast against a closed port', async () => { - //port 9 (discard) won't speak ECP; we just want to prove the option is threaded through. + it('Presents nice message for 401 unauthorized status code', async () => { + this.timeout(20000); + options.password = 'NOT_THE_PASSWORD'; await expectThrowsAsync( - rd.getDeviceInfo({ host: HOST, remotePort: 9, timeout: 2000 }) + rokuDeploy.deploy(options), + `Unauthorized. Please verify credentials for host '${options.host}'` ); }); }); - describe('getDevId', () => { - it('returns the keyed developer id as a hex string', async () => { - const devId = await rd.getDevId(options); - expect(devId, 'devId').to.be.a('string').with.length.greaterThan(0); - //keyed-developer-id is a hex string - expect(/^[0-9a-f]+$/i.test(devId), `devId "${devId}" should be hex`).to.be.true; + describe('publish', () => { + it('works', async () => { + await rokuDeploy.createPackage(options); + let response = await rokuDeploy.publish(options); + assert.equal(response.message, 'Successful deploy'); }); }); - describe('getEcpNetworkAccessMode', () => { - it('returns a known ECP access mode (or undefined on firmware that does not report it)', async () => { - const mode = await rd.getEcpNetworkAccessMode({ host: HOST }); - //older firmware (or some query contexts) may not include ecp-setting-mode at all - expect([undefined, 'enabled', 'disabled', 'limited', 'permissive']).to.include(mode); + describe('deployAndSignPackage', () => { + it('works', async () => { + await rokuDeploy.deleteInstalledChannel(options); + await rokuDeploy.rekeyDevice(options); + expectPathExists( + await rokuDeploy.deployAndSignPackage(options) + ); }); }); describe('validateDeveloperPassword', () => { it('returns true when the password is correct', async () => { - const result = await rd.validateDeveloperPassword({ host: HOST, password: PASSWORD }); + const result = await rokuDeploy.rokuDeploy.validateDeveloperPassword({ + host: options.host, + password: options.password + }); assert.strictEqual(result, true); }); it('returns false when the password is wrong', async () => { - const result = await rd.validateDeveloperPassword({ host: HOST, password: 'NOT_THE_PASSWORD' }); + const result = await rokuDeploy.rokuDeploy.validateDeveloperPassword({ + host: options.host, + password: 'NOT_THE_PASSWORD' + }); assert.strictEqual(result, false); }); it('throws DeviceUnreachableError for an offline host', async () => { - await expectThrowsAsync( - rd.validateDeveloperPassword({ host: '192.168.254.254', password: PASSWORD, timeout: 2000 }) - ); + await expectThrowsAsync(async () => { + await rokuDeploy.rokuDeploy.validateDeveloperPassword({ + host: '192.168.254.254', + password: 'aaaa', + timeout: 2000 + }); + }); }); }); - describe('pressHomeButton', () => { - it('sends the keypress over ECP without error', async () => { - //pressHomeButton resolves with the raw response; just make sure it didn't reject. - const result = await rd.pressHomeButton(HOST); - expectHttpResponseShape(result); + describe('getDeviceInfo', () => { + it('works', async () => { + const info = await rokuDeploy.rokuDeploy.getDeviceInfo({ host: options.host }); + assert.ok(info); + assert.ok(info['software-version']); }); - }); - describe('publish / deploy', () => { - it('deploy zips + uploads the channel and reports success', async () => { - const result = await rd.deploy(options); - expect(result.message).to.match(/deploy|Identical/i); - expectHttpResponseShape(result.results); + it('normalizes types when enhanced', async () => { + const info = await rokuDeploy.rokuDeploy.getDeviceInfo({ host: options.host, enhance: true }); + assert.ok(info.softwareVersion); + assert.strictEqual(typeof info.supportsEthernet, 'boolean'); }); + }); - it('publish (without re-zipping) uploads an existing archive', async () => { - //first build the zip on disk - await rd.createPackage(options); - const result = await rd.publish(options); - expect(result.message).to.match(/deploy|Identical/i); - expectHttpResponseShape(result.results); + describe('getDevId', () => { + it('works', async () => { + const devId = await rokuDeploy.rokuDeploy.getDevId(options); + assert.ok(devId); }); + }); - it('presents a nice message for a 401 unauthorized status code', async () => { - await expectThrowsAsync( - rd.deploy({ ...options, password: 'NOT_THE_PASSWORD' }), - `Unauthorized. Please verify credentials for host '${HOST}'` - ); + describe('getEcpNetworkAccessMode', () => { + it('works', async () => { + const mode = await rokuDeploy.rokuDeploy.getEcpNetworkAccessMode({ host: options.host }); + assert.ok([undefined, 'enabled', 'disabled', 'limited', 'permissive'].includes(mode)); }); + }); - it('attaches the postman-style results to a thrown UnauthorizedDeviceResponseError', async () => { - await rd.createPackage(options); - let caught: any; - try { - await rd.publish({ ...options, password: 'NOT_THE_PASSWORD' }); - } catch (e) { - caught = e; - } - expect(caught, 'should have thrown').to.be.instanceof(errors.UnauthorizedDeviceResponseError); - expect(caught.results.response.statusCode).to.equal(401); - expect(caught.results.response.request.host).to.equal(HOST); - expect(caught.results.body).to.be.a('string'); + describe('pressHomeButton', () => { + it('works', async () => { + await rokuDeploy.rokuDeploy.pressHomeButton(options.host); }); }); describe('takeScreenshot', () => { - it('captures a screenshot of the running channel to a real file', async () => { - //ensure a channel is running so the screenshot has content - await rd.deploy(options); - const filePath = await rd.takeScreenshot({ host: HOST, password: PASSWORD }); + it('works', async () => { + await rokuDeploy.deploy(options); + const filePath = await rokuDeploy.rokuDeploy.takeScreenshot({ host: options.host, password: options.password }); expectPathExists(filePath); - expect(fsExtra.statSync(filePath).size, 'screenshot byte size').to.be.greaterThan(0); - expect(filePath).to.match(/\.(jpg|png)$/i); }); }); describe('convertToSquashfs', () => { - it('converts the currently-installed channel to squashfs', async () => { - //must have a channel loaded first - await rd.deploy(options); - //resolves (no throw) on success; throws ConvertError on failure - await rd.convertToSquashfs(options); + it('works', async () => { + await rokuDeploy.deploy(options); + await rokuDeploy.rokuDeploy.convertToSquashfs(options); }); }); - describe('component libraries', () => { - it('deleteAllComponentLibraries enumerates packages and completes without error', async () => { - //even with zero component libraries installed, this should query the device and resolve. - await rd.deleteAllComponentLibraries({ host: HOST, password: PASSWORD }); - }); - - it('deleteComponentLibrary issues the delete request and gets a real device response', async () => { - //deleting a non-existent dcl exercises the full request/response path. The device responds - //with a "Failed: Invalid filename" message, which roku-deploy surfaces as a - //FailedDeviceResponseError. Either outcome (resolve, or that specific error) proves the - //networking path works; anything else is a real problem. - try { - await rd.deleteComponentLibrary({ host: HOST, password: PASSWORD, fileName: 'does-not-exist.zip' }); - } catch (e) { - expect(e, `unexpected error: ${e}`).to.be.instanceof(errors.FailedDeviceResponseError); - expect((e as Error).message).to.match(/invalid filename/i); - } + describe('deleteAllComponentLibraries', () => { + it('works', async () => { + await rokuDeploy.rokuDeploy.deleteAllComponentLibraries({ host: options.host, password: options.password }); }); }); describe('deleteInstalledChannel', () => { - it('deletes the installed dev channel and returns a response', async () => { - //make sure something is installed first - await rd.deploy(options); - const result = await rd.deleteInstalledChannel(options); - expectHttpResponseShape(result); - }); - - it('resolves even when no channel is installed', async () => { - //delete twice; the second delete has nothing to remove but should still resolve - await rd.deploy(options); - await rd.deleteInstalledChannel(options); - const result = await rd.deleteInstalledChannel(options); - expectHttpResponseShape(result); + it('works', async () => { + await rokuDeploy.deploy(options); + await rokuDeploy.deleteInstalledChannel(options); }); }); - describe('package signing', () => { - //these require a signingPassword that matches the device's installed dev key. - const maybe = SIGNING_PASSWORD ? it : it.skip; - - maybe('rekeyDevice + signExistingPackage + retrieveSignedPackage produce a .pkg', async function signing() { + describe('rebootDevice', () => { + it('works', async () => { this.timeout(60000); - const signingOptions = { - ...options, - signingPassword: SIGNING_PASSWORD, - rekeySignedPackage: `${cwd}/testSignedPackage.pkg` - }; - //deploy + retain staging so the manifest is available for signing - await rd.deploy({ ...signingOptions, retainStagingDir: true }); - const remotePkgPath = await rd.signExistingPackage(signingOptions); - expect(remotePkgPath, 'remote pkg path').to.be.a('string').that.matches(/\.pkg$/); - const localPkgPath = await rd.retrieveSignedPackage(remotePkgPath, signingOptions); - expectPathExists(localPkgPath); - expect(fsExtra.statSync(localPkgPath).size).to.be.greaterThan(0); - }); - - maybe('deployAndSignPackage produces a local .pkg file', async function signAndDeploy() { - this.timeout(60000); - const pkgFilePath = await rd.deployAndSignPackage({ - ...options, - signingPassword: SIGNING_PASSWORD, - rekeySignedPackage: `${cwd}/testSignedPackage.pkg` - }); - expectPathExists(pkgFilePath); + await rokuDeploy.rokuDeploy.rebootDevice(options); }); }); - describe('firmware-gated operations', () => { - //rebootDevice and checkForUpdate require firmware >= 15.0.4. They're also disruptive (a reboot - //takes the device offline for a while), so they only run when explicitly opted in. - const maybe = RUN_DISRUPTIVE ? it : it.skip; - - maybe('checkForUpdate triggers an update check', async () => { - const result = await rd.checkForUpdate(options); - expectHttpResponseShape(result); - }); - - maybe('rebootDevice reboots the device', async function reboot() { - this.timeout(60000); - const result = await rd.rebootDevice(options); - expectHttpResponseShape(result); - }); - - it('rebootDevice/checkForUpdate guard against old firmware', async () => { - //we can at least confirm the firmware-version gate logic runs against the real device-info. - const info = await rd.getDeviceInfo({ host: HOST, enhance: true }); - const version = semver.coerce(info.softwareVersion); - //if the device is new enough, checkForUpdate should NOT throw the UnsupportedFirmwareVersionError. - if (version && semver.gte(version, '15.0.4') && RUN_DISRUPTIVE) { - await rd.checkForUpdate(options); - } else if (version && semver.lt(version, '15.0.4')) { - await expectThrowsAsync( - rd.checkForUpdate(options), - undefined, - 'expected UnsupportedFirmwareVersionError on old firmware' - ); - } + describe('checkForUpdate', () => { + it('works', async () => { + await rokuDeploy.rokuDeploy.checkForUpdate(options); }); }); }); From bee0d89ba63607302c36c5cbe9dd00fc3a450ed7 Mon Sep 17 00:00:00 2001 From: Bronley Plumb Date: Wed, 1 Jul 2026 11:22:11 -0400 Subject: [PATCH 08/13] Fix failing device tests --- .vscode/settings.json | 5 +- src/device.spec.ts | 229 ++++++++++++++++++++++++++++++++++++++++-- src/util.ts | 4 +- 3 files changed, 225 insertions(+), 13 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 047dd79..33a8d7b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,5 +14,6 @@ "**/bower_components": true, "**/*.code-search": true, "**/dist": true - } -} \ No newline at end of file + }, + "js/ts.tsdk.path": "node_modules/typescript/lib" +} diff --git a/src/device.spec.ts b/src/device.spec.ts index 2add3ca..db0f187 100644 --- a/src/device.spec.ts +++ b/src/device.spec.ts @@ -1,10 +1,22 @@ import * as assert from 'assert'; import * as fsExtra from 'fs-extra'; +import * as net from 'net'; +import * as http from 'http'; +import * as semver from 'semver'; import * as rokuDeploy from './index'; +import * as errors from './Errors'; import { cwd, expectPathExists, expectThrowsAsync, outDir, rootDir, tempDir, writeFiles } from './testUtils.spec'; import undent from 'undent'; -//these tests are run against an actual roku device. These cannot be enabled when run on the CI server +//socket teardown callbacks, drained in afterEach so the suite doesn't hang open +const cleanups: Array<() => void> = []; + +//explicit short per-request timeout for the reboot-prone tests. The roku-deploy default is 150s; a +//short timeout guarantees any request against an unresponsive/rebooting device aborts (and closes its +//socket) quickly instead of hanging open and keeping the mocha process alive after the run. +const REQUEST_TIMEOUT = 15_000; + +//these tests are run against an actual roku device and need to be run on our self-hosted runners. describe('device', function device() { let options: rokuDeploy.RokuDeployOptions; @@ -14,7 +26,7 @@ describe('device', function device() { process.chdir(rootDir); options = rokuDeploy.getOptions({ outDir: outDir, - host: '192.168.1.93', + host: '192.168.1.31', retainDeploymentArchive: true, password: 'aaaa', devId: 'c6fdc2019903ac3332f624b0b2c2fe2c733c3e74', @@ -34,7 +46,7 @@ describe('device', function device() { splash_color=#000000 `], ['source/main.brs', undent` - Sub RunUserInterface() + Sub Main() screen = CreateObject("roSGScreen") m.scene = screen.CreateScene("HomeScene") port = CreateObject("roMessagePort") @@ -50,11 +62,21 @@ describe('device', function device() { screen = invalid end if End Sub + `], + ['components/HomeScene.xml', undent` + + `] ]); }); afterEach(() => { + //tear down any sockets/connections opened during the test so the suite doesn't hang open + while (cleanups.length > 0) { + try { + cleanups.pop()(); + } catch { } + } //restore the original working directory process.chdir(cwd); fsExtra.emptyDirSync(tempDir); @@ -160,8 +182,45 @@ describe('device', function device() { }); describe('takeScreenshot', () => { - it('works', async () => { + it('works', async function takeScreenshot() { + this.timeout(60000); + + //A screenshot only works when a side-loaded channel is actively running. Rather than + //guessing that `deploy` left the app running, we make main.brs print a unique, timestamped + //marker once its scene is shown, connect to the debug console (telnet port 8085), and wait + //until we observe THAT marker. Then we cross-check via ECP that the dev channel really is + //foregrounded before asking for the screenshot. + const marker = `ROKU_DEPLOY_SCREENSHOT_TEST ${new Date().toISOString()} ${Math.random().toString(36).slice(2)}`; + writeFiles(rootDir, [ + ['source/main.brs', undent` + Sub Main() + screen = CreateObject("roSGScreen") + m.scene = screen.CreateScene("HomeScene") + port = CreateObject("roMessagePort") + screen.SetMessagePort(port) + screen.Show() + print "${marker}" + + while(true) + msg = wait(0, port) + end while + End Sub + `] + ]); + + //start listening on the debug console BEFORE deploying so we don't miss the marker. + //(the socket's teardown is registered in `cleanups` and drained by afterEach) + const sawMarker = waitForConsoleOutput(options.host, marker, 45000); + await rokuDeploy.deploy(options); + + //the marker proves our freshly-deployed channel actually reached the "scene shown" point this run + await sawMarker; + + //belt-and-suspenders: confirm the dev channel is the active app via ECP + const activeApp = await getActiveApp(options.host); + assert.ok(/dev/i.test(activeApp), `expected the dev channel to be the active app, got: ${activeApp}`); + const filePath = await rokuDeploy.rokuDeploy.takeScreenshot({ host: options.host, password: options.password }); expectPathExists(filePath); }); @@ -188,15 +247,167 @@ describe('device', function device() { }); describe('rebootDevice', () => { - it('works', async () => { - this.timeout(60000); - await rokuDeploy.rokuDeploy.rebootDevice(options); + it('works', async function rebootDevice() { + //a reboot takes the device offline for a while; allow time for it to come back + this.timeout(180_000); + //use a short per-request timeout so the reboot POST can't hang open past the device going + //down; without this it would inherit the 150s default and could orphan a socket if mocha's + //test-timeout fired first. + await rokuDeploy.rokuDeploy.rebootDevice({ ...options, timeout: REQUEST_TIMEOUT }); + //wait until the device is reachable again so the next test doesn't run mid-reboot + await waitForDeviceOnline(options.host); }); }); describe('checkForUpdate', () => { - it('works', async () => { - await rokuDeploy.rokuDeploy.checkForUpdate(options); + //checkForUpdate requires firmware >= this version; below it, it throws UnsupportedFirmwareVersionError + const MIN_FIRMWARE = '15.0.4'; + + it('works', async function checkForUpdate() { + //triggers a real update check against Roku's servers, which can be slow and can sometimes + //trigger a reboot, so allow generous time for the device to come back afterward + this.timeout(240_000); + + //Every device call below uses an explicit short `timeout` so no underlying needle request can + //hang open indefinitely (the default is 150s). This guarantees each request either resolves or + //rejects and closes its socket on its own, rather than being orphaned if mocha's test-timeout + //were to fire mid-request. + const reqOptions = { ...options, timeout: REQUEST_TIMEOUT }; + + //we don't know which device the suite runs against, so ask it what firmware it has and + //decide up-front whether checkForUpdate should succeed or be rejected by the version gate. + const softwareVersion = (await rokuDeploy.rokuDeploy.getDeviceInfo({ host: options.host, timeout: REQUEST_TIMEOUT }))['software-version']; + const supported = !!softwareVersion && semver.gte(semver.coerce(softwareVersion), MIN_FIRMWARE); + + if (supported) { + console.log(`[checkForUpdate] device firmware ${softwareVersion} >= ${MIN_FIRMWARE}; expecting success`); + const result = await rokuDeploy.rokuDeploy.checkForUpdate(reqOptions); + assert.ok(result, 'expected a response from checkForUpdate'); + //checkForUpdate can trigger a reboot; make sure the device is back before the next test + await waitForDeviceOnline(options.host); + } else { + console.log(`[checkForUpdate] device firmware ${softwareVersion} < ${MIN_FIRMWARE}; expecting UnsupportedFirmwareVersionError`); + let thrown: Error; + try { + await rokuDeploy.rokuDeploy.checkForUpdate(reqOptions); + } catch (e) { + thrown = e as Error; + } + assert.ok(thrown, 'expected checkForUpdate to throw on unsupported firmware'); + assert.ok( + thrown instanceof errors.UnsupportedFirmwareVersionError, + `expected UnsupportedFirmwareVersionError, got ${thrown?.constructor?.name}: ${thrown?.message}` + ); + } }); }); }); + +/** + * Connect to the Roku debug console (telnet, port 8085) and resolve once a line containing `marker` + * is observed. Rejects if the marker isn't seen within `timeout` ms. Used to prove that a + * freshly-deployed channel actually reached a known point in its own code during THIS test run. + * + * The telnet socket's teardown is registered in `cleanups` so the `afterEach` hook always tears it + * down (even on the happy path), otherwise the open connection keeps the mocha process alive after + * the suite finishes. + */ +function waitForConsoleOutput(host: string, marker: string, timeout: number): Promise { + return new Promise((resolve, reject) => { + const socket = net.connect(8085, host); + //don't let this socket alone keep the event loop (and thus the process) alive + socket.unref(); + let buffer = ''; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`Timed out after ${timeout}ms waiting for marker "${marker}" on ${host}:8085`)); + }, timeout); + //setTimeout also holds the loop open; let it be non-blocking too + timer.unref(); + + function cleanup() { + clearTimeout(timer); + socket.removeAllListeners(); + socket.destroy(); + } + //ensure the socket is always torn down, even if the marker arrives and we resolve normally + cleanups.push(cleanup); + + socket.on('data', (chunk) => { + buffer += chunk.toString(); + if (buffer.includes(marker)) { + cleanup(); + resolve(); + } + }); + socket.on('error', (err) => { + cleanup(); + reject(err); + }); + }); +} + +/** + * Query ECP (port 8060) for the currently-active app and return its raw XML body. The dev channel + * reports itself as `dev` in the `` node, so callers can assert against that. + */ +function getActiveApp(host: string): Promise { + return new Promise((resolve, reject) => { + const req = http.get(`http://${host}:8060/query/active-app`, (res) => { + let body = ''; + res.on('data', (chunk) => { + body += chunk.toString(); + }); + res.on('end', () => resolve(body)); + }); + //register teardown so afterEach can kill a hung request; harmless once the request completes + cleanups.push(() => req.destroy()); + req.on('error', reject); + //never let a hung request keep the process open + req.setTimeout(10000, () => { + req.destroy(new Error(`Timed out querying active-app on ${host}:8060`)); + }); + }); +} + +/** + * Wait for a device to be reachable again by polling its device-info over ECP until it responds. + * Used after operations that reboot the device (rebootDevice, and sometimes checkForUpdate) so the + * next test doesn't run against a device that's still rebooting. + * + * @param graceMs how long to wait before the first poll, giving the device time to actually go down + * after the reboot was issued (so we don't immediately see the still-alive pre-reboot device) + */ +async function waitForDeviceOnline(host: string, timeoutMs = 120_000, intervalMs = 3000, graceMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + //give the device a moment to actually start going down before we begin polling + await sleep(graceMs); + let lastError: Error; + while (Date.now() < deadline) { + try { + await rokuDeploy.rokuDeploy.getDeviceInfo({ host: host, timeout: intervalMs }); + //a successful device-info query means ECP is up and the device is responsive again + return; + } catch (e) { + lastError = e as Error; + await sleep(intervalMs); + } + } + throw new Error(`Device ${host} did not come back online within ${timeoutMs}ms. Last error: ${lastError?.message}`); +} + +/** + * A sleep whose timer is `unref()`'d and registered in `cleanups`, so a pending delay can never keep + * the mocha process alive after the suite finishes (unlike a bare `setTimeout`). + */ +function sleep(ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + timer.unref?.(); + cleanups.push(() => { + clearTimeout(timer); + //resolve so any awaiter unblocks during teardown instead of hanging + resolve(); + }); + }); +} diff --git a/src/util.ts b/src/util.ts index 2026128..b6022c0 100644 --- a/src/util.ts +++ b/src/util.ts @@ -357,8 +357,8 @@ export function defer() { export interface Deferred { promise: Promise; - tryResolve: (value?: T | PromiseLike) => void; - resolve: (value?: T | PromiseLike) => void; + tryResolve: (value?: PromiseLike | T) => void; + resolve: (value?: PromiseLike | T) => void; tryReject: (reason?: any) => void; reject: (reason?: any) => void; isResolved: boolean; From 19f3d975139a2681fbff0b6f238caa0cc8cd60ef Mon Sep 17 00:00:00 2001 From: Bronley Plumb Date: Wed, 1 Jul 2026 12:27:24 -0400 Subject: [PATCH 09/13] Fix device tests --- .eslintrc.js | 2 + package-lock.json | 467 +---------------------------------------- package.json | 1 - src/Errors.ts | 6 +- src/RokuDeploy.spec.ts | 43 ++-- src/RokuDeploy.ts | 56 ++--- src/request.spec.ts | 35 ++- src/request.ts | 86 ++++++-- 8 files changed, 164 insertions(+), 532 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index bd06f0f..9f26948 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -51,6 +51,7 @@ module.exports = { 'error', 'always' ], + '@typescript-eslint/parameter-properties': 'off', '@typescript-eslint/prefer-readonly': 'off', '@typescript-eslint/prefer-readonly-parameter-types': 'off', '@typescript-eslint/promise-function-async': 'off', @@ -111,6 +112,7 @@ module.exports = { 'no-constant-condition': 'off', 'no-console': 'off', 'no-continue': 'off', + 'no-duplicate-imports': 'off', 'no-else-return': 'off', 'no-empty': 'off', 'no-implicit-coercion': 'off', diff --git a/package-lock.json b/package-lock.json index 8a89a73..048bd12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "3.17.6", "license": "MIT", "dependencies": { - "@types/request": "^2.48.13", "chalk": "^2.4.2", "fast-glob": "^3.2.12", "fs-extra": "^7.0.1", @@ -688,11 +687,6 @@ "integrity": "sha512-+euflG6ygo4bn0JHtn4pYqcXwRtLvElQ7/nnjDu7iYG56H0+OhCd7d6Ug0IE3WcFpZozBKW2+80FUbv5QGk5AQ==", "dev": true }, - "node_modules/@types/caseless": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.2.tgz", - "integrity": "sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==" - }, "node_modules/@types/chai": { "version": "4.2.22", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.2.22.tgz", @@ -750,6 +744,7 @@ "version": "18.19.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.0.tgz", "integrity": "sha512-667KNhaD7U29mT5wf+TZUnrzPrlL2GNQ5N0BMjO2oNULhBxX0/FKCkm6JMu0Jh7Z+1LwUlR21ekd7KhIboNFNw==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~5.26.4" @@ -762,18 +757,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/request": { - "version": "2.48.13", - "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", - "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", - "license": "MIT", - "dependencies": { - "@types/caseless": "*", - "@types/node": "*", - "@types/tough-cookie": "*", - "form-data": "^2.5.5" - } - }, "node_modules/@types/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", @@ -790,11 +773,6 @@ "@sinonjs/fake-timers": "^7.1.0" } }, - "node_modules/@types/tough-cookie": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.1.tgz", - "integrity": "sha512-Y0K95ThC3esLEYD6ZuqNek29lNX2EM1qxV8y2FTLUB0ff5wWrk7az+mLrnNFUnaXcgKye22+sFBRXOgpPILZNg==" - }, "node_modules/@types/xml2js": { "version": "0.4.9", "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.9.tgz", @@ -1114,11 +1092,6 @@ "node": "*" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" - }, "node_modules/audit-ci": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/audit-ci/-/audit-ci-7.1.0.tgz", @@ -1293,19 +1266,6 @@ "node": ">=8" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1456,17 +1416,6 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -1570,14 +1519,6 @@ "node": ">=8" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/diff": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", @@ -1612,20 +1553,6 @@ "node": ">=6.0.0" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", @@ -1657,51 +1584,6 @@ "node": ">=8.6" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es6-error": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", @@ -2229,42 +2111,6 @@ "node": ">=8.0.0" } }, - "node_modules/form-data": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz", - "integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35", - "safe-buffer": "^5.2.1" - }, - "engines": { - "node": ">= 0.12" - } - }, - "node_modules/form-data/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/from": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", @@ -2325,15 +2171,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/functional-red-black-tree": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", @@ -2367,30 +2204,6 @@ "node": "*" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -2400,19 +2213,6 @@ "node": ">=8.0.0" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", @@ -2481,18 +2281,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.8.tgz", @@ -2506,33 +2294,6 @@ "node": ">=4" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasha": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", @@ -2558,17 +2319,6 @@ "node": ">=8" } }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -3204,15 +2954,6 @@ "dev": true, "license": "MIT" }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3233,25 +2974,6 @@ "node": ">=8.6" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -4611,6 +4333,7 @@ "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, "license": "MIT" }, "node_modules/universalify": { @@ -5384,11 +5107,6 @@ "integrity": "sha512-+euflG6ygo4bn0JHtn4pYqcXwRtLvElQ7/nnjDu7iYG56H0+OhCd7d6Ug0IE3WcFpZozBKW2+80FUbv5QGk5AQ==", "dev": true }, - "@types/caseless": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.2.tgz", - "integrity": "sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==" - }, "@types/chai": { "version": "4.2.22", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.2.22.tgz", @@ -5444,6 +5162,7 @@ "version": "18.19.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.0.tgz", "integrity": "sha512-667KNhaD7U29mT5wf+TZUnrzPrlL2GNQ5N0BMjO2oNULhBxX0/FKCkm6JMu0Jh7Z+1LwUlR21ekd7KhIboNFNw==", + "dev": true, "requires": { "undici-types": "~5.26.4" } @@ -5454,17 +5173,6 @@ "integrity": "sha512-0so8lU8O5zatZS/2Fi4zrwks+vZv7e0dygrgEZXljODXBig97l4cPQD+9LabXfGJOWwoRkTVz6Q4edZvD12UOA==", "dev": true }, - "@types/request": { - "version": "2.48.13", - "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", - "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", - "requires": { - "@types/caseless": "*", - "@types/node": "*", - "@types/tough-cookie": "*", - "form-data": "^2.5.5" - } - }, "@types/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", @@ -5480,11 +5188,6 @@ "@sinonjs/fake-timers": "^7.1.0" } }, - "@types/tough-cookie": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.1.tgz", - "integrity": "sha512-Y0K95ThC3esLEYD6ZuqNek29lNX2EM1qxV8y2FTLUB0ff5wWrk7az+mLrnNFUnaXcgKye22+sFBRXOgpPILZNg==" - }, "@types/xml2js": { "version": "0.4.9", "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.9.tgz", @@ -5690,11 +5393,6 @@ "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", "dev": true }, - "asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" - }, "audit-ci": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/audit-ci/-/audit-ci-7.1.0.tgz", @@ -5825,15 +5523,6 @@ "write-file-atomic": "^3.0.0" } }, - "call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "requires": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - } - }, "callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -5939,14 +5628,6 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, - "combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "requires": { - "delayed-stream": "~1.0.0" - } - }, "commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -6029,11 +5710,6 @@ "strip-bom": "^4.0.0" } }, - "delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" - }, "diff": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", @@ -6058,16 +5734,6 @@ "esutils": "^2.0.2" } }, - "dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "requires": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - } - }, "duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", @@ -6095,35 +5761,6 @@ "ansi-colors": "^4.1.1" } }, - "es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" - }, - "es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" - }, - "es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "requires": { - "es-errors": "^1.3.0" - } - }, - "es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "requires": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - } - }, "es6-error": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", @@ -6521,26 +6158,6 @@ "signal-exit": "^3.0.2" } }, - "form-data": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz", - "integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==", - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35", - "safe-buffer": "^5.2.1" - }, - "dependencies": { - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" - } - } - }, "from": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", @@ -6576,11 +6193,6 @@ "dev": true, "optional": true }, - "function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" - }, "functional-red-black-tree": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", @@ -6605,38 +6217,12 @@ "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", "dev": true }, - "get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "requires": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - } - }, "get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "dev": true }, - "get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "requires": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - } - }, "glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", @@ -6683,11 +6269,6 @@ "slash": "^3.0.0" } }, - "gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" - }, "graceful-fs": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.8.tgz", @@ -6698,19 +6279,6 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" }, - "has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" - }, - "has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "requires": { - "has-symbols": "^1.0.3" - } - }, "hasha": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", @@ -6729,14 +6297,6 @@ } } }, - "hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "requires": { - "function-bind": "^1.1.2" - } - }, "he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -7218,11 +6778,6 @@ "integrity": "sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==", "dev": true }, - "math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" - }, "merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -7237,19 +6792,6 @@ "picomatch": "^2.3.1" } }, - "mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" - }, - "mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "requires": { - "mime-db": "1.52.0" - } - }, "minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -8248,7 +7790,8 @@ "undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true }, "universalify": { "version": "0.1.2", diff --git a/package.json b/package.json index 37d94b4..219add2 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "package": "npm run build && npm pack" }, "dependencies": { - "@types/request": "^2.48.13", "chalk": "^2.4.2", "fast-glob": "^3.2.12", "fs-extra": "^7.0.1", diff --git a/src/Errors.ts b/src/Errors.ts index 846f9a9..2786891 100644 --- a/src/Errors.ts +++ b/src/Errors.ts @@ -1,5 +1,5 @@ import type { HttpResponse, RokuMessages } from './RokuDeploy'; -import type * as requestType from 'request'; +import type { RequestOptions } from './request'; export class InvalidDeviceResponseCodeError extends Error { constructor(message: string, public results?: any) { @@ -87,7 +87,7 @@ export class UpdateCheckRequiredError extends Error { constructor( public response: HttpResponse, - public requestOptions: requestType.OptionsWithUrl, + public requestOptions: RequestOptions, public cause?: Error ) { super(); @@ -107,7 +107,7 @@ export class ConnectionResetError extends Error { static MESSAGE = `The Roku device ended the connection unexpectedly and may need to check for updates before accepting connections. Please navigate to System Settings and check for updates and then try again.\n\nhttps://support.roku.com/article/208755668.`; - constructor(error: Error, requestOptions: requestType.OptionsWithUrl) { + constructor(error: Error, requestOptions: RequestOptions) { super(); this.message = ConnectionResetError.MESSAGE; this.cause = error; diff --git a/src/RokuDeploy.spec.ts b/src/RokuDeploy.spec.ts index 2be931d..bc2b266 100644 --- a/src/RokuDeploy.spec.ts +++ b/src/RokuDeploy.spec.ts @@ -4627,43 +4627,30 @@ describe('RokuDeploy', () => { expect(currentVersion).to.exist.and.to.match(/^\d+\.\d+\.\d+.*/); }); - it('works when params is undefined', () => { - //undefined - expect( - rokuDeploy['setUserAgentIfMissing'](undefined) - ).to.eql({ headers: { 'User-Agent': `roku-deploy/${currentVersion}` } }); - }); - - it('works when params has no header container', () => { - expect( - rokuDeploy['setUserAgentIfMissing']({} as any) - ).to.eql({ headers: { 'User-Agent': `roku-deploy/${currentVersion}` } }); - }); - - it('works when params has empty header container', () => { - expect( - rokuDeploy['setUserAgentIfMissing']({} as any) - ).to.eql({ headers: { 'User-Agent': `roku-deploy/${currentVersion}` } }); + it('adds the header container when params has none', () => { + const params = {} as any; + rokuDeploy['setUserAgentIfMissing'](params); + expect(params).to.eql({ headers: { 'User-Agent': `roku-deploy/${currentVersion}` } }); }); - it('works when params has existing header container with no user agent', () => { - expect( - rokuDeploy['setUserAgentIfMissing']({ headers: {} } as any) - ).to.eql({ headers: { 'User-Agent': `roku-deploy/${currentVersion}` } }); + it('sets the user agent when params has an empty header container', () => { + const params = { headers: {} } as any; + rokuDeploy['setUserAgentIfMissing'](params); + expect(params).to.eql({ headers: { 'User-Agent': `roku-deploy/${currentVersion}` } }); }); - it('works when params has existing header container with user agent', () => { - expect( - rokuDeploy['setUserAgentIfMissing']({ headers: { 'User-Agent': 'some-other-user-agent' } } as any) - ).to.eql({ headers: { 'User-Agent': 'some-other-user-agent' } }); + it('does not overwrite an existing user agent', () => { + const params = { headers: { 'User-Agent': 'some-other-user-agent' } } as any; + rokuDeploy['setUserAgentIfMissing'](params); + expect(params).to.eql({ headers: { 'User-Agent': 'some-other-user-agent' } }); }); it('works when we fail to load package version', () => { sinon.stub(fsExtra, 'readJsonSync').throws(new Error('Unable to read package.json')); rokuDeploy['_packageVersion'] = undefined; - expect( - rokuDeploy['setUserAgentIfMissing']({} as any) - ).to.eql({ headers: { 'User-Agent': 'roku-deploy/unknown' } }); + const params = {} as any; + rokuDeploy['setUserAgentIfMissing'](params); + expect(params).to.eql({ headers: { 'User-Agent': 'roku-deploy/unknown' } }); }); }); diff --git a/src/RokuDeploy.ts b/src/RokuDeploy.ts index 8d78aa8..8d5cf6b 100644 --- a/src/RokuDeploy.ts +++ b/src/RokuDeploy.ts @@ -1,7 +1,7 @@ import * as path from 'path'; import * as _fsExtra from 'fs-extra'; import { request } from './request'; -import type * as requestType from 'request'; +import type { RequestOptions } from './request'; import * as JSZip from 'jszip'; import * as errors from './Errors'; import * as isGlob from 'is-glob'; @@ -88,7 +88,7 @@ export class RokuDeploy { * @param files */ public normalizeFilesArray(files: FileEntry[]) { - const result: Array = []; + const result: Array = []; for (let i = 0; i < files.length; i++) { let entry = files[i]; @@ -331,7 +331,7 @@ export class RokuDeploy { * @param pattern the glob pattern originally used to find this file * @param rootDir absolute normalized path to the rootDir */ - private computeFileDestPath(srcPath: string, entry: string | StandardizedFileEntry, rootDir: string) { + private computeFileDestPath(srcPath: string, entry: StandardizedFileEntry | string, rootDir: string) { let result: string; let globstarIdx: number; //files under rootDir with no specified dest @@ -426,7 +426,7 @@ export class RokuDeploy { })); } - private generateBaseRequestOptions(requestPath: string, options: RokuDeployOptions, formData = {} as T): requestType.OptionsWithUrl { + private generateBaseRequestOptions(requestPath: string, options: RokuDeployOptions, formData = {} as T): RequestOptions { options = this.getOptions(options); let url = `http://${options.host}:${options.packagePort}/${requestPath}`; let baseRequestOptions = { @@ -746,21 +746,14 @@ export class RokuDeploy { } /** - * Set the `User-Agent` header if missing from the request params, ensuring it's included in all requests made by roku-deploy + * Set the `User-Agent` header (in place) if missing from the request params, ensuring it's included + * in all requests made by roku-deploy. Mutates `params.headers` because callers pass the same object + * straight on to `request.post`/`request.get`. * @param params - * @returns */ - private setUserAgentIfMissing(params: requestType.OptionsWithUrl) { - if (!params) { - params = {} as requestType.OptionsWithUrl; - } - if (!params.headers) { - params.headers = {}; - } - if (!params.headers['User-Agent']) { - params.headers['User-Agent'] = this.getUserAgent(); - } - return params; + private setUserAgentIfMissing(params: RequestOptions) { + params.headers ??= {}; + params.headers['User-Agent'] ??= this.getUserAgent(); } /** @@ -784,7 +777,7 @@ export class RokuDeploy { * Centralized function for handling POST http requests * @param params */ - private async doPostRequest(params: requestType.OptionsWithUrl, verify = true) { + private async doPostRequest(params: RequestOptions, verify = true) { let results: { response: any; body: any } = await new Promise((resolve, reject) => { this.setUserAgentIfMissing(params); @@ -806,7 +799,7 @@ export class RokuDeploy { * Centralized function for handling GET http requests * @param params */ - private async doGetRequest(params: requestType.OptionsWithUrl) { + private async doGetRequest(params: RequestOptions) { let results: { response: any; body: any } = await new Promise((resolve, reject) => { this.setUserAgentIfMissing(params); @@ -1036,8 +1029,8 @@ export class RokuDeploy { * Gets a screenshot from the device. A side-loaded channel must be running or an error will be thrown. */ public async takeScreenshot(options: TakeScreenshotOptions) { - options.outDir = options.outDir ?? this.screenshotDir; - options.outFile = options.outFile ?? `screenshot-${formatTimestampForScreenshot()}`; + options.outDir ??= this.screenshotDir; + options.outFile ??= `screenshot-${formatTimestampForScreenshot()}`; let saveFilePath: string; // Ask for the device to make an image @@ -1274,8 +1267,8 @@ export class RokuDeploy { * @param host the host or IP address of the Roku * @param port the port to use for the ECP request (defaults to 8060) */ - public async getDeviceInfo(options?: { enhance: true } & GetDeviceInfoOptions): Promise; - public async getDeviceInfo(options?: GetDeviceInfoOptions): Promise + public async getDeviceInfo(options?: GetDeviceInfoOptions & { enhance: true }): Promise; + public async getDeviceInfo(options?: GetDeviceInfoOptions): Promise; public async getDeviceInfo(options: GetDeviceInfoOptions) { options = this.getOptions(options) as any; @@ -1512,14 +1505,17 @@ export interface ManifestData { } export interface BeforeZipCallbackInfo { + /** * Contains an associative array of the parsed values in the manifest */ manifestData: ManifestData; + /** * @deprecated since 3.9.0. use `stagingDir` instead */ stagingFolderPath: string; + /** * The directory where the files were staged */ @@ -1527,10 +1523,12 @@ export interface BeforeZipCallbackInfo { } export interface StandardizedFileEntry { + /** * The full path to the source file */ src: string; + /** * The path relative to the root of the pkg to where the file should be placed */ @@ -1574,6 +1572,7 @@ export interface HttpResponse { } export interface TakeScreenshotOptions { + /** * The IP address or hostname of the target Roku device. * @example '192.168.1.21' @@ -1599,31 +1598,40 @@ export interface TakeScreenshotOptions { } export interface ValidateDeveloperPasswordOptions { + /** The hostname or IP of the Roku device */ host: string; + /** The developer password to check */ password: string; + /** Defaults to `'rokudev'` */ username?: string; + /** Defaults to `80` (the developer web-server port) */ port?: number; + /** Milliseconds to wait for each HTTP round-trip. Defaults to `3000`. */ timeout?: number; } export interface GetDeviceInfoOptions { + /** * The hostname or IP address to use for the device-info URL */ host: string; + /** * The port to use to send the device-info request (defaults to the standard 8060 ECP port) */ remotePort?: number; + /** * The number of milliseconds at which point this request should timeout and return a rejected promise */ timeout?: number; + /** * Should the device-info be enhanced by camel-casing the property names and converting boolean strings to booleans and number strings to numbers? * @default false @@ -1631,4 +1639,4 @@ export interface GetDeviceInfoOptions { enhance?: boolean; } -export type EcpNetworkAccessMode = 'enabled' | 'disabled' | 'limited' | 'permissive'; +export type EcpNetworkAccessMode = 'disabled' | 'enabled' | 'limited' | 'permissive'; diff --git a/src/request.spec.ts b/src/request.spec.ts index e5d8eb3..443cd52 100644 --- a/src/request.spec.ts +++ b/src/request.spec.ts @@ -14,6 +14,7 @@ const sinon = createSandbox(); * of this wrong is a breaking change. */ describe('request (needle shim)', () => { + /** captured args from the stubbed needle call: { url, data, options, callback } */ let postArgs: { url: string; data: any; options: any; callback: any }; let getArgs: { url: string; options: any; callback: any }; @@ -58,12 +59,18 @@ describe('request (needle shim)', () => { }); describe('option translation', () => { - it('sets parse_response=false and maps timeout to both needle timeouts', async () => { + it('sets parse_response=false and maps timeout to open_timeout + response_timeout', async () => { stubPost(null, { statusCode: 200, headers: {} }, 'ok'); await callPost({ url: 'http://1.2.3.4:80/plugin_install', timeout: 12345, formData: { a: 'b' } }); expect(postArgs.options.parse_response).to.equal(false); expect(postArgs.options.open_timeout).to.equal(12345); - expect(postArgs.options.read_timeout).to.equal(12345); + expect(postArgs.options.response_timeout).to.equal(12345); + }); + + it('does NOT set read_timeout (its lingering re-armed timer leaks a handle in the digest-auth path)', async () => { + stubPost(null, { statusCode: 200, headers: {} }, 'ok'); + await callPost({ url: 'http://1.2.3.4:80/plugin_install', timeout: 12345, formData: { a: 'b' } }); + expect(postArgs.options.read_timeout).to.be.undefined; }); it('passes through headers', async () => { @@ -72,6 +79,30 @@ describe('request (needle shim)', () => { expect(postArgs.options.headers).to.eql({ 'User-Agent': 'roku-deploy/test' }); }); + it('closes the connection by default (needle keeps sockets alive otherwise, which pins the process)', async () => { + stubPost(null, { statusCode: 200, headers: {} }, 'ok'); + await callPost({ url: 'http://1.2.3.4:80/x', formData: { a: 'b' } }); + expect(postArgs.options.connection).to.equal('close'); + }); + + it('closes the connection when agentOptions.keepAlive is false (request parity)', async () => { + stubPost(null, { statusCode: 200, headers: {} }, 'ok'); + await callPost({ url: 'http://1.2.3.4:80/x', formData: { a: 'b' }, agentOptions: { keepAlive: false } }); + expect(postArgs.options.connection).to.equal('close'); + }); + + it('leaves the connection alone when the caller explicitly opts into keepAlive', async () => { + stubPost(null, { statusCode: 200, headers: {} }, 'ok'); + await callPost({ url: 'http://1.2.3.4:80/x', formData: { a: 'b' }, agentOptions: { keepAlive: true } }); + expect(postArgs.options.connection).to.be.undefined; + }); + + it('closes the connection by default on GET too', async () => { + stubGet(null, { statusCode: 200, headers: {} }, 'ok'); + await callGet({ url: 'http://1.2.3.4:80/x' }); + expect(getArgs.options.connection).to.equal('close'); + }); + it('translates auth into digest username/password', async () => { stubPost(null, { statusCode: 200, headers: {} }, 'ok'); await callPost({ diff --git a/src/request.ts b/src/request.ts index 25e53fb..2cd3afe 100644 --- a/src/request.ts +++ b/src/request.ts @@ -2,7 +2,48 @@ import * as needle from 'needle'; import * as urlModule from 'url'; import type { ReadStream } from 'fs'; -import type * as requestType from 'request'; + +/** + * The subset of the legacy `request`/`postman-request` options object that roku-deploy actually + * builds and this shim consumes. We previously typed these as `request.OptionsWithUrl` (from + * `@types/request`), but that pulled a dependency purely for a type and forced `as any` reads for the + * fields the `@types/request` surface didn't cleanly expose. Declaring exactly what we use lets us drop + * `@types/request` entirely and read every field type-safely. + */ +export interface RequestOptions { + + /** The full request url (already includes host/port/path). */ + url: string; + + /** Per-request timeout in ms (connection + first-response-byte). */ + timeout?: number; + + /** Outgoing request headers. */ + headers?: Record; + + /** Digest-auth credentials. `sendImmediately: false` requests the 401-challenge/response dance. */ + auth?: { + user?: string; + username?: string; + pass?: string; + password?: string; + sendImmediately?: boolean; + }; + + /** multipart/form-data fields (string values, or a readable stream for the zip/pkg archive). */ + formData?: Record; + + /** Query-string object appended to the url. */ + qs?: Record; + + /** + * Legacy `request` agent options. Only `keepAlive` is consulted: `request` used + * `{ keepAlive: false }` so each exchange used a fresh socket that closed when done. + */ + agentOptions?: { + keepAlive?: boolean; + }; +} /** * Title-case an HTTP header name the way `request`/`postman-request` preserved it on the outgoing @@ -62,6 +103,7 @@ export const needleClient = { export interface RequestResponse { statusCode: number; headers: Record; + /** * Mirrors `request`'s `response.request` object. roku-deploy reads `response.request.host` when * constructing the "Unauthorized" error message; other consumers may read `href`/`uri`/`method`. @@ -72,15 +114,18 @@ export interface RequestResponse { uri?: Record; method?: string; headers?: Record; + /** Plus the other consumable `request` fields we reproduce (path, port, protocol, ...). */ [key: string]: any; }; + /** * The response body, as a string. `request`/`postman-request` attached the body to * `response.body` in addition to returning it as the callback's 3rd argument, so we mirror that * for callers that read `error.results.response.body`. */ body: string; + /** Plus the rest of the underlying http.IncomingMessage surface (statusMessage, rawHeaders, ...). */ [key: string]: any; } @@ -91,21 +136,37 @@ export type RequestCallback = (error: Error | null, response: RequestResponse | * Translate the `request`-style options object that roku-deploy builds into the * `(url, data, needleOptions)` triple that needle expects. */ -function translateOptions(params: requestType.OptionsWithUrl, method: 'GET' | 'POST') { +function translateOptions(params: RequestOptions, method: 'GET' | 'POST') { const url = buildUrl(params); const needleOptions: needle.NeedleOptions = { //Roku responses are HTML/XML that roku-deploy parses by hand; never let needle auto-parse them parse_response: false, - //request had a single `timeout` that covered the whole exchange; map it to both needle timeouts + //`request` had a single `timeout` that governed how long to wait to establish the connection and + //receive a response. Map it to needle's `open_timeout` (connection) and `response_timeout` (time to + //first response byte). Deliberately do NOT set `read_timeout`: needle's read-timer is re-armed on + //every chunk and, in the digest-auth retry path, a read-timer can be left running after the request + //has already completed — it later fires `request.destroy()` and emits a spurious error, and (worse) + //keeps the Node event loop alive so a process that only made roku-deploy requests never exits. open_timeout: params.timeout, - read_timeout: params.timeout, - headers: params.headers as Record + response_timeout: params.timeout, + headers: params.headers }; + //`request` was configured with `agentOptions: { keepAlive: false }` so each exchange used a fresh + //socket that closed when done. needle does NOT honor `agentOptions`, and on modern Node it does not + //send `Connection: close` by default, so the socket to the Roku is left open (keep-alive) after the + //response. That lingering socket keeps the Node event loop alive, so a process that only made + //roku-deploy requests never exits. Translate the old keepAlive:false intent into needle's + //`connection: 'close'` so needle sends `Connection: close` and tears the socket down after each + //response. Only skip this if the caller explicitly asked to keep the connection alive. + if (params.agentOptions?.keepAlive !== true) { + needleOptions.connection = 'close'; + } + //digest auth. `request` was configured with `auth.sendImmediately: false`, which performs the //401-challenge/response digest dance. needle does the same when `auth: 'digest'` is set. - const auth = params.auth as { user?: string; username?: string; pass?: string; password?: string } | undefined; + const auth = params.auth; if (auth) { needleOptions.username = auth.user ?? auth.username; needleOptions.password = auth.pass ?? auth.password; @@ -114,7 +175,7 @@ function translateOptions(params: requestType.OptionsWithUrl, method: 'GET' | 'P let data: any = null; if (method === 'POST') { - const formData = translateFormData((params as any).formData); + const formData = translateFormData(params.formData); //only send a multipart body when there's actually form data to send. Some POSTs (e.g. ECP //keypress) have no body at all; needle's multipart builder throws "Empty multipart body" on an //empty object, whereas `request` happily sent a bodyless POST. So fall back to a null body. @@ -131,9 +192,9 @@ function translateOptions(params: requestType.OptionsWithUrl, method: 'GET' | 'P * Append the `qs` query-string object (if any) onto the url. `request` accepted * `qs` as a separate option; needle expects it baked into the url. */ -function buildUrl(params: requestType.OptionsWithUrl): string { - let url = params.url as string; - const qs = (params as any).qs as Record | undefined; +function buildUrl(params: RequestOptions): string { + let url = params.url; + const qs = params.qs; if (qs && Object.keys(qs).length > 0) { const search = new URLSearchParams(); for (const key in qs) { @@ -287,11 +348,12 @@ function buildResponse(needleResponse: any, url: string, body: string): RequestR } export const request = { + /** * POST a multipart/form-data request, `request`-style. Invokes `callback` * with `(error, response, body)`. */ - post: (params: requestType.OptionsWithUrl, callback: RequestCallback) => { + post: (params: RequestOptions, callback: RequestCallback) => { const { url, data, needleOptions } = translateOptions(params, 'POST'); return needleClient.post(url, data, needleOptions, (error, response, body) => { if (error) { @@ -308,7 +370,7 @@ export const request = { * stream (which supports `.on('error'|'response', ...)` and `.pipe(...)`) * for the file-download path. */ - get: (params: requestType.OptionsWithUrl, callback?: RequestCallback) => { + get: (params: RequestOptions, callback?: RequestCallback) => { const { url, needleOptions } = translateOptions(params, 'GET'); if (callback) { return needleClient.get(url, needleOptions, (error, response, body) => { From b6b591098cd2a64c4c7b68dd678bcd2d1355c81a Mon Sep 17 00:00:00 2001 From: Bronley Plumb Date: Wed, 1 Jul 2026 12:54:40 -0400 Subject: [PATCH 10/13] move interfaces to the bottom --- .rta-audit | 1 + src/request.ts | 164 ++++++++++++++++++++++++------------------------- 2 files changed, 83 insertions(+), 82 deletions(-) create mode 160000 .rta-audit diff --git a/.rta-audit b/.rta-audit new file mode 160000 index 0000000..d3d1e02 --- /dev/null +++ b/.rta-audit @@ -0,0 +1 @@ +Subproject commit d3d1e02826ae96e00adb8829c9c6c4e1d05e0633 diff --git a/src/request.ts b/src/request.ts index 2cd3afe..af7148d 100644 --- a/src/request.ts +++ b/src/request.ts @@ -3,48 +3,6 @@ import * as needle from 'needle'; import * as urlModule from 'url'; import type { ReadStream } from 'fs'; -/** - * The subset of the legacy `request`/`postman-request` options object that roku-deploy actually - * builds and this shim consumes. We previously typed these as `request.OptionsWithUrl` (from - * `@types/request`), but that pulled a dependency purely for a type and forced `as any` reads for the - * fields the `@types/request` surface didn't cleanly expose. Declaring exactly what we use lets us drop - * `@types/request` entirely and read every field type-safely. - */ -export interface RequestOptions { - - /** The full request url (already includes host/port/path). */ - url: string; - - /** Per-request timeout in ms (connection + first-response-byte). */ - timeout?: number; - - /** Outgoing request headers. */ - headers?: Record; - - /** Digest-auth credentials. `sendImmediately: false` requests the 401-challenge/response dance. */ - auth?: { - user?: string; - username?: string; - pass?: string; - password?: string; - sendImmediately?: boolean; - }; - - /** multipart/form-data fields (string values, or a readable stream for the zip/pkg archive). */ - formData?: Record; - - /** Query-string object appended to the url. */ - qs?: Record; - - /** - * Legacy `request` agent options. Only `keepAlive` is consulted: `request` used - * `{ keepAlive: false }` so each exchange used a fresh socket that closed when done. - */ - agentOptions?: { - keepAlive?: boolean; - }; -} - /** * Title-case an HTTP header name the way `request`/`postman-request` preserved it on the outgoing * request (`Content-Type`, `User-Agent`, `WWW-Authenticate`, ...). needle lowercases outgoing header @@ -92,46 +50,6 @@ export const needleClient = { * form (`request.get(opts).on(...).pipe(...)`) used when downloading files. */ -/** - * The `response` object roku-deploy (and its consumers) see. Both `postman-request` and `needle` - * hand back a Node `http.IncomingMessage`, so the real object carries far more than the few fields - * roku-deploy reads — `statusCode`, `headers`, `statusMessage`, `rawHeaders`, `httpVersion*`, - * `socket`, `req`, `complete`, etc. We keep all of that (it's needle's actual IncomingMessage) and - * layer on the `request`-compat extras postman added. The interface therefore declares the fields we - * guarantee, and allows the rest of the IncomingMessage surface via the index signature. - */ -export interface RequestResponse { - statusCode: number; - headers: Record; - - /** - * Mirrors `request`'s `response.request` object. roku-deploy reads `response.request.host` when - * constructing the "Unauthorized" error message; other consumers may read `href`/`uri`/`method`. - */ - request: { - host: string; - href: string; - uri?: Record; - method?: string; - headers?: Record; - - /** Plus the other consumable `request` fields we reproduce (path, port, protocol, ...). */ - [key: string]: any; - }; - - /** - * The response body, as a string. `request`/`postman-request` attached the body to - * `response.body` in addition to returning it as the callback's 3rd argument, so we mirror that - * for callers that read `error.results.response.body`. - */ - body: string; - - /** Plus the rest of the underlying http.IncomingMessage surface (statusMessage, rawHeaders, ...). */ - [key: string]: any; -} - -export type RequestCallback = (error: Error | null, response: RequestResponse | undefined, body: string | undefined) => void; - /** * Translate the `request`-style options object that roku-deploy builds into the * `(url, data, needleOptions)` triple that needle expects. @@ -420,3 +338,85 @@ function interceptIntermediate401(stream: { emit: (event: string, ...args: any[] return originalEmit(event, ...args); }) as any; } + +/** + * The subset of the legacy `request`/`postman-request` options object that roku-deploy actually + * builds and this shim consumes. We previously typed these as `request.OptionsWithUrl` (from + * `@types/request`), but that pulled a dependency purely for a type and forced `as any` reads for the + * fields the `@types/request` surface didn't cleanly expose. Declaring exactly what we use lets us drop + * `@types/request` entirely and read every field type-safely. + */ +export interface RequestOptions { + + /** The full request url (already includes host/port/path). */ + url: string; + + /** Per-request timeout in ms (connection + first-response-byte). */ + timeout?: number; + + /** Outgoing request headers. */ + headers?: Record; + + /** Digest-auth credentials. `sendImmediately: false` requests the 401-challenge/response dance. */ + auth?: { + user?: string; + username?: string; + pass?: string; + password?: string; + sendImmediately?: boolean; + }; + + /** multipart/form-data fields (string values, or a readable stream for the zip/pkg archive). */ + formData?: Record; + + /** Query-string object appended to the url. */ + qs?: Record; + + /** + * Legacy `request` agent options. Only `keepAlive` is consulted: `request` used + * `{ keepAlive: false }` so each exchange used a fresh socket that closed when done. + */ + agentOptions?: { + keepAlive?: boolean; + }; +} + +/** + * The `response` object roku-deploy (and its consumers) see. Both `postman-request` and `needle` + * hand back a Node `http.IncomingMessage`, so the real object carries far more than the few fields + * roku-deploy reads — `statusCode`, `headers`, `statusMessage`, `rawHeaders`, `httpVersion*`, + * `socket`, `req`, `complete`, etc. We keep all of that (it's needle's actual IncomingMessage) and + * layer on the `request`-compat extras postman added. The interface therefore declares the fields we + * guarantee, and allows the rest of the IncomingMessage surface via the index signature. + */ +export interface RequestResponse { + statusCode: number; + headers: Record; + + /** + * Mirrors `request`'s `response.request` object. roku-deploy reads `response.request.host` when + * constructing the "Unauthorized" error message; other consumers may read `href`/`uri`/`method`. + */ + request: { + host: string; + href: string; + uri?: Record; + method?: string; + headers?: Record; + + /** Plus the other consumable `request` fields we reproduce (path, port, protocol, ...). */ + [key: string]: any; + }; + + /** + * The response body, as a string. `request`/`postman-request` attached the body to + * `response.body` in addition to returning it as the callback's 3rd argument, so we mirror that + * for callers that read `error.results.response.body`. + */ + body: string; + + /** Plus the rest of the underlying http.IncomingMessage surface (statusMessage, rawHeaders, ...). */ + [key: string]: any; +} + +export type RequestCallback = (error: Error | null, response: RequestResponse | undefined, body: string | undefined) => void; From de92e7fc1f8f80b0de3917324f3c7519caebb96a Mon Sep 17 00:00:00 2001 From: Bronley Plumb Date: Wed, 1 Jul 2026 13:42:23 -0400 Subject: [PATCH 11/13] Remove needle export --- .rta-audit | 1 - src/RokuDeploy.spec.ts | 13 +++++++------ src/request.spec.ts | 23 ++++++++++++----------- src/request.ts | 16 +++------------- 4 files changed, 22 insertions(+), 31 deletions(-) delete mode 160000 .rta-audit diff --git a/.rta-audit b/.rta-audit deleted file mode 160000 index d3d1e02..0000000 --- a/.rta-audit +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d3d1e02826ae96e00adb8829c9c6c4e1d05e0633 diff --git a/src/RokuDeploy.spec.ts b/src/RokuDeploy.spec.ts index bc2b266..0304c99 100644 --- a/src/RokuDeploy.spec.ts +++ b/src/RokuDeploy.spec.ts @@ -16,7 +16,8 @@ import { defer, util, standardizePath as s } from './util'; import type { FileEntry, RokuDeployOptions } from './RokuDeployOptions'; import { cwd, expectPathExists, expectPathNotExists, expectThrowsAsync, outDir, rootDir, stagingDir, tempDir, writeFiles } from './testUtils.spec'; import { createSandbox } from 'sinon'; -import { request, needleClient } from './request'; +import * as needle from 'needle'; +import { request } from './request'; import { PassThrough } from 'stream'; const sinon = createSandbox(); @@ -197,23 +198,23 @@ describe('RokuDeploy', () => { describe('error results structure (postman-request compatibility)', () => { //These tests drive RokuDeploy through the REAL needle shim (by stubbing the low-level - //`needleClient`), then assert the exact shape of the `results`/`response` object attached to + //`needle`), then assert the exact shape of the `results`/`response` object attached to //each thrown error. roku-deploy historically exposed a `request`/`postman-request`-shaped //object here, and consumers read specific paths off it (`results.response.statusCode`, //`results.response.headers.server`, `results.response.request.host`, string `results.body`). //Getting any of these wrong is a BREAKING API change, so they're pinned down explicitly. - /** Stub needleClient.post to deliver the given needle-style response + body */ + /** Stub needle.post to deliver the given needle-style response + body */ function stubNeedlePost(response: any, body: any, error: any = null) { - return sinon.stub(needleClient, 'post').callsFake(((url: string, data: any, opts: any, callback: any) => { + return sinon.stub(needle, 'post').callsFake(((url: string, data: any, opts: any, callback: any) => { process.nextTick(callback, error, response, body); return {} as any; }) as any); } - /** Stub needleClient.get (callback form) to deliver the given needle-style response + body */ + /** Stub needle.get (callback form) to deliver the given needle-style response + body */ function stubNeedleGet(response: any, body: any, error: any = null) { - return sinon.stub(needleClient, 'get').callsFake(((url: string, opts: any, callback: any) => { + return sinon.stub(needle, 'get').callsFake(((url: string, opts: any, callback: any) => { if (callback) { process.nextTick(callback, error, response, body); } diff --git a/src/request.spec.ts b/src/request.spec.ts index 443cd52..88a92b2 100644 --- a/src/request.spec.ts +++ b/src/request.spec.ts @@ -1,7 +1,8 @@ import { expect } from 'chai'; import { createSandbox } from 'sinon'; import { PassThrough } from 'stream'; -import { request, needleClient } from './request'; +import * as needle from 'needle'; +import { request } from './request'; const sinon = createSandbox(); @@ -9,7 +10,7 @@ const sinon = createSandbox(); * Unit tests for the needle compatibility shim. * * These pin down the translation between roku-deploy's `request`/`postman-request`-style - * options/response and needle, by stubbing `needleClient` directly. The error/response SHAPE + * options/response and needle, by stubbing `needle` directly. The error/response SHAPE * here is part of roku-deploy's public surface (it's attached to thrown errors), so getting any * of this wrong is a breaking change. */ @@ -19,9 +20,9 @@ describe('request (needle shim)', () => { let postArgs: { url: string; data: any; options: any; callback: any }; let getArgs: { url: string; options: any; callback: any }; - /** Stub needleClient.post and capture/drive the callback */ + /** Stub needle.post and capture/drive the callback */ function stubPost(err: any, response: any, body: any) { - return sinon.stub(needleClient, 'post').callsFake(((url: string, data: any, options: any, callback: any) => { + return sinon.stub(needle, 'post').callsFake(((url: string, data: any, options: any, callback: any) => { postArgs = { url: url, data: data, options: options, callback: callback }; //invoke async like needle does process.nextTick(callback, err, response, body); @@ -29,9 +30,9 @@ describe('request (needle shim)', () => { }) as any); } - /** Stub needleClient.get (callback form) and capture/drive the callback */ + /** Stub needle.get (callback form) and capture/drive the callback */ function stubGet(err: any, response: any, body: any) { - return sinon.stub(needleClient, 'get').callsFake(((url: string, options: any, callback: any) => { + return sinon.stub(needle, 'get').callsFake(((url: string, options: any, callback: any) => { getArgs = { url: url, options: options, callback: callback }; if (callback) { process.nextTick(callback, err, response, body); @@ -369,14 +370,14 @@ describe('request (needle shim)', () => { describe('streaming get (getToFile path)', () => { it('returns the needle stream when no callback is given', () => { const fakeStream = new PassThrough(); - sinon.stub(needleClient, 'get').returns(fakeStream as any); + sinon.stub(needle, 'get').returns(fakeStream as any); const result = request.get({ url: 'http://1.2.3.4:80/pkgs/dev.pkg', auth: { user: 'u', pass: 'p' } } as any); expect(result).to.equal(fakeStream); }); it(`bridges needle's 'err' event to 'error'`, (done) => { const fakeStream = new PassThrough(); - sinon.stub(needleClient, 'get').returns(fakeStream as any); + sinon.stub(needle, 'get').returns(fakeStream as any); const stream: any = request.get({ url: 'http://1.2.3.4:80/x', auth: { user: 'u', pass: 'p' } } as any); const theError = new Error('stream blew up'); stream.on('error', (e) => { @@ -388,7 +389,7 @@ describe('request (needle shim)', () => { it('swallows the intermediate 401 response, then forwards the retried 200 (digest auth)', () => { const fakeStream = new PassThrough(); - sinon.stub(needleClient, 'get').returns(fakeStream as any); + sinon.stub(needle, 'get').returns(fakeStream as any); const stream: any = request.get({ url: 'http://1.2.3.4:80/pkgs/dev.pkg', auth: { user: 'u', pass: 'p' } } as any); const seen: number[] = []; @@ -404,7 +405,7 @@ describe('request (needle shim)', () => { it('does NOT swallow a 401 when there is no digest auth (no credentials)', () => { const fakeStream = new PassThrough(); - sinon.stub(needleClient, 'get').returns(fakeStream as any); + sinon.stub(needle, 'get').returns(fakeStream as any); const stream: any = request.get({ url: 'http://1.2.3.4:8060/x' } as any); const seen: number[] = []; @@ -416,7 +417,7 @@ describe('request (needle shim)', () => { it('forwards a response event that has no response object (digest auth)', () => { const fakeStream = new PassThrough(); - sinon.stub(needleClient, 'get').returns(fakeStream as any); + sinon.stub(needle, 'get').returns(fakeStream as any); const stream: any = request.get({ url: 'http://1.2.3.4:80/x', auth: { user: 'u', pass: 'p' } } as any); let fired = false; diff --git a/src/request.ts b/src/request.ts index af7148d..312c2da 100644 --- a/src/request.ts +++ b/src/request.ts @@ -26,16 +26,6 @@ function titleCaseHeaders(headers: Record | undefined): Record { const { url, data, needleOptions } = translateOptions(params, 'POST'); - return needleClient.post(url, data, needleOptions, (error, response, body) => { + return needle.post(url, data, needleOptions, (error, response, body) => { if (error) { return callback(error, undefined, undefined); } @@ -291,7 +281,7 @@ export const request = { get: (params: RequestOptions, callback?: RequestCallback) => { const { url, needleOptions } = translateOptions(params, 'GET'); if (callback) { - return needleClient.get(url, needleOptions, (error, response, body) => { + return needle.get(url, needleOptions, (error, response, body) => { if (error) { return callback(error, undefined, undefined); } @@ -300,7 +290,7 @@ export const request = { }); } //streaming form (no callback) - used by getToFile to pipe the response to disk. - const stream = needleClient.get(url, needleOptions); + const stream = needle.get(url, needleOptions); //needle's stream emits its failures on the `'err'` event, but `request` (and roku-deploy's //getToFile) listens on `'error'`, so bridge `'err'` -> `'error'` to preserve that behavior. From 781870664d4b38f51af133f16be3a6da96cf6956 Mon Sep 17 00:00:00 2001 From: Bronley Plumb Date: Wed, 1 Jul 2026 13:46:23 -0400 Subject: [PATCH 12/13] Convert request shim to a class Co-Authored-By: Claude Opus 4.8 (1M context) --- src/request.ts | 537 +++++++++++++++++++++++++------------------------ 1 file changed, 269 insertions(+), 268 deletions(-) diff --git a/src/request.ts b/src/request.ts index 312c2da..5f7994d 100644 --- a/src/request.ts +++ b/src/request.ts @@ -3,29 +3,6 @@ import * as needle from 'needle'; import * as urlModule from 'url'; import type { ReadStream } from 'fs'; -/** - * Title-case an HTTP header name the way `request`/`postman-request` preserved it on the outgoing - * request (`Content-Type`, `User-Agent`, `WWW-Authenticate`, ...). needle lowercases outgoing header - * names, so we re-case them to maximize parity with what consumers saw on `response.request.headers`. - */ -function titleCaseHeaderName(name: string): string { - //title-case each hyphen-delimited segment (Content-Type, User-Agent, Authorization, ...). This - //covers the outgoing request headers roku-deploy sends; we don't special-case acronym headers - //(WWW-Authenticate etc.) because those are response headers, not outgoing-request headers. - return name.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()).join('-'); -} - -function titleCaseHeaders(headers: Record | undefined): Record | undefined { - if (!headers || typeof headers !== 'object') { - return headers; - } - const out: Record = {}; - for (const key of Object.keys(headers)) { - out[titleCaseHeaderName(key)] = headers[key]; - } - return out; -} - /** * A thin compatibility shim over `needle` that mimics the small slice of the * `request`/`postman-request` API that roku-deploy relies on. We migrated off @@ -39,238 +16,22 @@ function titleCaseHeaders(headers: Record | undefined): Record 0) { - data = formData; - needleOptions.multipart = true; - } - } - - return { url: url, data: data, needleOptions: needleOptions }; -} - -/** - * Append the `qs` query-string object (if any) onto the url. `request` accepted - * `qs` as a separate option; needle expects it baked into the url. - */ -function buildUrl(params: RequestOptions): string { - let url = params.url; - const qs = params.qs; - if (qs && Object.keys(qs).length > 0) { - const search = new URLSearchParams(); - for (const key in qs) { - if (qs[key] !== undefined && qs[key] !== null) { - search.append(key, String(qs[key])); - } - } - const query = search.toString(); - if (query) { - url += (url.includes('?') ? '&' : '?') + query; - } - } - return url; -} - -/** - * Convert a `request`-style `formData` object into the shape needle's multipart - * builder understands. - * - * - `request` silently dropped `null`/`undefined`/empty-string fields. needle's - * multipart builder instead throws `"value missing for multipart!"` on empty - * values, so we drop those fields entirely (preserving the old behavior). - * - `request` accepted a readable stream (e.g. the zip `fs.ReadStream`) as a - * field value. needle's multipart builder does not handle streams, so we - * translate a stream into needle's documented `{ file, content_type }` form - * using the stream's `path`. - */ -function translateFormData(formData: Record | undefined): Record { - const result: Record = {}; - if (!formData) { - return result; - } - for (const key in formData) { - const value = formData[key]; - //drop empty values (request did this implicitly; needle would throw) - if (value === undefined || value === null || value === '') { - continue; - } - //a readable stream (the zip/pkg archive) -> needle file-by-path form - if (isReadableStream(value)) { - const filePath = (value).path; - result[key] = { - file: filePath, - content_type: 'application/octet-stream' - }; - } else { - result[key] = value; - } - } - return result; -} - -function isReadableStream(value: any): value is ReadStream { - return value && typeof value === 'object' && typeof value.pipe === 'function' && value.readable !== false; -} - -/** - * Coerce needle's response body into the `string` that roku-deploy expects. - * With `parse_response: false`, needle hands back a `Buffer` (an *empty* Buffer - * for empty responses such as a bare 401), whereas `request`/`postman-request` - * always delivered a decoded string. roku-deploy's `checkRequest` guards on - * `typeof body === 'string'`, so anything other than a string would be - * misreported as an unparsable response. - */ -function coerceBody(body: any): string { - if (Buffer.isBuffer(body)) { - return body.toString(); - } - if (typeof body === 'string') { - return body; - } - //null/undefined or a parsed object (shouldn't happen with parse_response:false) -> stringify safely - return body === undefined || body === null ? '' : String(body); -} - -/** - * Reshape needle's response into the `request`-compatible response roku-deploy expects (and attaches - * to thrown errors). - * - * Maximum-parity strategy: needle's callback `resp` is the *same* underlying Node - * `http.IncomingMessage` that `postman-request` exposed (needle just augments it with `.body`/`.raw`/ - * `.bytes`). So rather than fabricate a minimal plain object — which would drop everything underneath - * (`statusCode`/`statusMessage`/`rawHeaders`/`httpVersion*`/`socket`/`req`/`complete`/... that a - * consumer might reach into) — we KEEP needle's IncomingMessage and only layer on the two things - * `request`/`postman-request` added on top of it: - * 1. a `.request` object exposing the outgoing-request fields consumers read (`host`, `href`, ...), - * 2. a string `.body` (postman put the decoded string here; needle leaves a Buffer under - * `parse_response:false`). - * This way the vast majority of the old response's reachable surface is reproduced for free. - */ -function buildResponse(needleResponse: any, url: string, body: string): RequestResponse { - //`request`/`postman-request` could hand back a callback with no response object; roku-deploy's - //`checkRequest` explicitly guards on `!results.response`. Preserve that by passing through a missing - //response rather than throwing while trying to augment it. - if (!needleResponse) { - return undefined; - } - - //Parse with the legacy `url.parse()` to match postman-request's `response.request.uri` exactly: it - //was a Node `Url` object (host WITH port, plus auth/hash/query/search/slashes/protocol/port). The - //WHATWG `URL` would strip the default `:80` and omit these fields, so we deliberately use the legacy - //parser here for byte-parity with what consumers read off `response.request.uri.*`. - //`url.parse()` is total for string input (it never throws — a bare token like 'not-a-valid-url' - //yields null host/hostname and the token as path/pathname), so no try/catch is needed. - const u = urlModule.parse(url); - const uri: Record = { - protocol: u.protocol, - slashes: u.slashes, - auth: u.auth, - host: u.host, - port: u.port, - hostname: u.hostname, - hash: u.hash, - search: u.search, - query: u.query, - pathname: u.pathname, - path: u.path, - href: u.href - }; - - //needle's resp IS the http.IncomingMessage. Augment it in place to mirror postman-request's shape. - const response = needleResponse; - - //`request`/`postman-request` attached the (string) body to `response.body`. needle leaves a Buffer - //here under parse_response:false, so overwrite with the decoded string to match. - response.body = body; - - //`request`/`postman-request` exposed its outgoing `Request` object at `response.request`. Its - //library-internal guts (`_auth`, `_form`, `_qs`, `httpModule`, `pool`, ...) can't exist without the - //`request` package, but we reproduce every CONSUMABLE field a caller could portably read. Don't - //clobber it if needle/Node ever populates one. - if (!response.request) { - const outgoingHeaders = titleCaseHeaders(response.req?.getHeaders?.()); - response.request = { - uri: uri, - method: response.req?.method ?? undefined, - headers: outgoingHeaders, - host: uri.hostname, - href: uri.href, - path: uri.path, - port: uri.port ?? undefined, - originalHost: uri.hostname, - originalHostHeaderName: 'Host', - protocol: uri.protocol, - readable: true, - writable: true - }; - } - - return response as RequestResponse; -} - -export const request = { +export class Request { /** * POST a multipart/form-data request, `request`-style. Invokes `callback` * with `(error, response, body)`. */ - post: (params: RequestOptions, callback: RequestCallback) => { - const { url, data, needleOptions } = translateOptions(params, 'POST'); + public post(params: RequestOptions, callback: RequestCallback) { + const { url, data, needleOptions } = this.translateOptions(params, 'POST'); return needle.post(url, data, needleOptions, (error, response, body) => { if (error) { return callback(error, undefined, undefined); } - const coerced = coerceBody(body); - return callback(null, buildResponse(response, url, coerced), coerced); + const coerced = this.coerceBody(body); + return callback(null, this.buildResponse(response, url, coerced), coerced); }); - }, + } /** * GET a request, `request`-style. With a `callback`, invokes it with @@ -278,15 +39,15 @@ export const request = { * stream (which supports `.on('error'|'response', ...)` and `.pipe(...)`) * for the file-download path. */ - get: (params: RequestOptions, callback?: RequestCallback) => { - const { url, needleOptions } = translateOptions(params, 'GET'); + public get(params: RequestOptions, callback?: RequestCallback) { + const { url, needleOptions } = this.translateOptions(params, 'GET'); if (callback) { return needle.get(url, needleOptions, (error, response, body) => { if (error) { return callback(error, undefined, undefined); } - const coerced = coerceBody(body); - return callback(null, buildResponse(response, url, coerced), coerced); + const coerced = this.coerceBody(body); + return callback(null, this.buildResponse(response, url, coerced), coerced); }); } //streaming form (no callback) - used by getToFile to pipe the response to disk. @@ -302,33 +63,273 @@ export const request = { //roku-deploy's `getToFile` treats any non-200 `'response'` as a hard failure. So when we're doing //digest auth, swallow the intermediate 401 `'response'` event and only forward the retried one. if (needleOptions.auth && needleOptions.username !== undefined) { - interceptIntermediate401(stream); + this.interceptIntermediate401(stream); } return stream; } -}; -/** - * Wrap a needle stream so that an intermediate `401` `'response'` event (the - * digest challenge that needle answers by retrying) is not propagated to - * listeners. Only the subsequent, authenticated response is forwarded. - */ -function interceptIntermediate401(stream: { emit: (event: string, ...args: any[]) => boolean }) { - const originalEmit = stream.emit.bind(stream); - let swallowedChallenge = false; - stream.emit = ((event: string, ...args: any[]): boolean => { - if (event === 'response' && !swallowedChallenge) { - const resp = args[0]; - if (resp && resp.statusCode === 401) { - //this is the digest challenge; needle will retry. drop it (just this once). - swallowedChallenge = true; - return false; + /** + * Translate the `request`-style options object that roku-deploy builds into the + * `(url, data, needleOptions)` triple that needle expects. + */ + private translateOptions(params: RequestOptions, method: 'GET' | 'POST') { + const url = this.buildUrl(params); + + const needleOptions: needle.NeedleOptions = { + //Roku responses are HTML/XML that roku-deploy parses by hand; never let needle auto-parse them + parse_response: false, + //`request` had a single `timeout` that governed how long to wait to establish the connection and + //receive a response. Map it to needle's `open_timeout` (connection) and `response_timeout` (time to + //first response byte). Deliberately do NOT set `read_timeout`: needle's read-timer is re-armed on + //every chunk and, in the digest-auth retry path, a read-timer can be left running after the request + //has already completed — it later fires `request.destroy()` and emits a spurious error, and (worse) + //keeps the Node event loop alive so a process that only made roku-deploy requests never exits. + open_timeout: params.timeout, + response_timeout: params.timeout, + headers: params.headers + }; + + //`request` was configured with `agentOptions: { keepAlive: false }` so each exchange used a fresh + //socket that closed when done. needle does NOT honor `agentOptions`, and on modern Node it does not + //send `Connection: close` by default, so the socket to the Roku is left open (keep-alive) after the + //response. That lingering socket keeps the Node event loop alive, so a process that only made + //roku-deploy requests never exits. Translate the old keepAlive:false intent into needle's + //`connection: 'close'` so needle sends `Connection: close` and tears the socket down after each + //response. Only skip this if the caller explicitly asked to keep the connection alive. + if (params.agentOptions?.keepAlive !== true) { + needleOptions.connection = 'close'; + } + + //digest auth. `request` was configured with `auth.sendImmediately: false`, which performs the + //401-challenge/response digest dance. needle does the same when `auth: 'digest'` is set. + const auth = params.auth; + if (auth) { + needleOptions.username = auth.user ?? auth.username; + needleOptions.password = auth.pass ?? auth.password; + needleOptions.auth = 'digest'; + } + + let data: any = null; + if (method === 'POST') { + const formData = this.translateFormData(params.formData); + //only send a multipart body when there's actually form data to send. Some POSTs (e.g. ECP + //keypress) have no body at all; needle's multipart builder throws "Empty multipart body" on an + //empty object, whereas `request` happily sent a bodyless POST. So fall back to a null body. + if (Object.keys(formData).length > 0) { + data = formData; + needleOptions.multipart = true; + } + } + + return { url: url, data: data, needleOptions: needleOptions }; + } + + /** + * Append the `qs` query-string object (if any) onto the url. `request` accepted + * `qs` as a separate option; needle expects it baked into the url. + */ + private buildUrl(params: RequestOptions): string { + let url = params.url; + const qs = params.qs; + if (qs && Object.keys(qs).length > 0) { + const search = new URLSearchParams(); + for (const key in qs) { + if (qs[key] !== undefined && qs[key] !== null) { + search.append(key, String(qs[key])); + } + } + const query = search.toString(); + if (query) { + url += (url.includes('?') ? '&' : '?') + query; + } + } + return url; + } + + /** + * Convert a `request`-style `formData` object into the shape needle's multipart + * builder understands. + * + * - `request` silently dropped `null`/`undefined`/empty-string fields. needle's + * multipart builder instead throws `"value missing for multipart!"` on empty + * values, so we drop those fields entirely (preserving the old behavior). + * - `request` accepted a readable stream (e.g. the zip `fs.ReadStream`) as a + * field value. needle's multipart builder does not handle streams, so we + * translate a stream into needle's documented `{ file, content_type }` form + * using the stream's `path`. + */ + private translateFormData(formData: Record | undefined): Record { + const result: Record = {}; + if (!formData) { + return result; + } + for (const key in formData) { + const value = formData[key]; + //drop empty values (request did this implicitly; needle would throw) + if (value === undefined || value === null || value === '') { + continue; + } + //a readable stream (the zip/pkg archive) -> needle file-by-path form + if (this.isReadableStream(value)) { + const filePath = (value).path; + result[key] = { + file: filePath, + content_type: 'application/octet-stream' + }; + } else { + result[key] = value; + } + } + return result; + } + + private isReadableStream(value: any): value is ReadStream { + return value && typeof value === 'object' && typeof value.pipe === 'function' && value.readable !== false; + } + + /** + * Coerce needle's response body into the `string` that roku-deploy expects. + * With `parse_response: false`, needle hands back a `Buffer` (an *empty* Buffer + * for empty responses such as a bare 401), whereas `request`/`postman-request` + * always delivered a decoded string. roku-deploy's `checkRequest` guards on + * `typeof body === 'string'`, so anything other than a string would be + * misreported as an unparsable response. + */ + private coerceBody(body: any): string { + if (Buffer.isBuffer(body)) { + return body.toString(); + } + if (typeof body === 'string') { + return body; + } + //null/undefined or a parsed object (shouldn't happen with parse_response:false) -> stringify safely + return body === undefined || body === null ? '' : String(body); + } + + /** + * Reshape needle's response into the `request`-compatible response roku-deploy expects (and attaches + * to thrown errors). + * + * Maximum-parity strategy: needle's callback `resp` is the *same* underlying Node + * `http.IncomingMessage` that `postman-request` exposed (needle just augments it with `.body`/`.raw`/ + * `.bytes`). So rather than fabricate a minimal plain object — which would drop everything underneath + * (`statusCode`/`statusMessage`/`rawHeaders`/`httpVersion*`/`socket`/`req`/`complete`/... that a + * consumer might reach into) — we KEEP needle's IncomingMessage and only layer on the two things + * `request`/`postman-request` added on top of it: + * 1. a `.request` object exposing the outgoing-request fields consumers read (`host`, `href`, ...), + * 2. a string `.body` (postman put the decoded string here; needle leaves a Buffer under + * `parse_response:false`). + * This way the vast majority of the old response's reachable surface is reproduced for free. + */ + private buildResponse(needleResponse: any, url: string, body: string): RequestResponse { + //`request`/`postman-request` could hand back a callback with no response object; roku-deploy's + //`checkRequest` explicitly guards on `!results.response`. Preserve that by passing through a missing + //response rather than throwing while trying to augment it. + if (!needleResponse) { + return undefined; + } + + //Parse with the legacy `url.parse()` to match postman-request's `response.request.uri` exactly: it + //was a Node `Url` object (host WITH port, plus auth/hash/query/search/slashes/protocol/port). The + //WHATWG `URL` would strip the default `:80` and omit these fields, so we deliberately use the legacy + //parser here for byte-parity with what consumers read off `response.request.uri.*`. + //`url.parse()` is total for string input (it never throws — a bare token like 'not-a-valid-url' + //yields null host/hostname and the token as path/pathname), so no try/catch is needed. + const u = urlModule.parse(url); + const uri: Record = { + protocol: u.protocol, + slashes: u.slashes, + auth: u.auth, + host: u.host, + port: u.port, + hostname: u.hostname, + hash: u.hash, + search: u.search, + query: u.query, + pathname: u.pathname, + path: u.path, + href: u.href + }; + + //needle's resp IS the http.IncomingMessage. Augment it in place to mirror postman-request's shape. + const response = needleResponse; + + //`request`/`postman-request` attached the (string) body to `response.body`. needle leaves a Buffer + //here under parse_response:false, so overwrite with the decoded string to match. + response.body = body; + + //`request`/`postman-request` exposed its outgoing `Request` object at `response.request`. Its + //library-internal guts (`_auth`, `_form`, `_qs`, `httpModule`, `pool`, ...) can't exist without the + //`request` package, but we reproduce every CONSUMABLE field a caller could portably read. Don't + //clobber it if needle/Node ever populates one. + if (!response.request) { + const outgoingHeaders = this.titleCaseHeaders(response.req?.getHeaders?.()); + response.request = { + uri: uri, + method: response.req?.method ?? undefined, + headers: outgoingHeaders, + host: uri.hostname, + href: uri.href, + path: uri.path, + port: uri.port ?? undefined, + originalHost: uri.hostname, + originalHostHeaderName: 'Host', + protocol: uri.protocol, + readable: true, + writable: true + }; + } + + return response as RequestResponse; + } + + /** + * Wrap a needle stream so that an intermediate `401` `'response'` event (the + * digest challenge that needle answers by retrying) is not propagated to + * listeners. Only the subsequent, authenticated response is forwarded. + */ + private interceptIntermediate401(stream: { emit: (event: string, ...args: any[]) => boolean }) { + const originalEmit = stream.emit.bind(stream); + let swallowedChallenge = false; + stream.emit = ((event: string, ...args: any[]): boolean => { + if (event === 'response' && !swallowedChallenge) { + const resp = args[0]; + if (resp && resp.statusCode === 401) { + //this is the digest challenge; needle will retry. drop it (just this once). + swallowedChallenge = true; + return false; + } } + return originalEmit(event, ...args); + }) as any; + } + + /** + * Title-case an HTTP header name the way `request`/`postman-request` preserved it on the outgoing + * request (`Content-Type`, `User-Agent`, `WWW-Authenticate`, ...). needle lowercases outgoing header + * names, so we re-case them to maximize parity with what consumers saw on `response.request.headers`. + */ + private titleCaseHeaderName(name: string): string { + //title-case each hyphen-delimited segment (Content-Type, User-Agent, Authorization, ...). This + //covers the outgoing request headers roku-deploy sends; we don't special-case acronym headers + //(WWW-Authenticate etc.) because those are response headers, not outgoing-request headers. + return name.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()).join('-'); + } + + private titleCaseHeaders(headers: Record | undefined): Record | undefined { + if (!headers || typeof headers !== 'object') { + return headers; } - return originalEmit(event, ...args); - }) as any; + const out: Record = {}; + for (const key of Object.keys(headers)) { + out[this.titleCaseHeaderName(key)] = headers[key]; + } + return out; + } } +export const request = new Request(); + /** * The subset of the legacy `request`/`postman-request` options object that roku-deploy actually * builds and this shim consumes. We previously typed these as `request.OptionsWithUrl` (from From 5dd7eec55eb6f0582d8649a11647d1b86abcc783 Mon Sep 17 00:00:00 2001 From: Bronley Plumb Date: Thu, 2 Jul 2026 14:13:27 -0400 Subject: [PATCH 13/13] chore: Add device tests to CI (self-hosted) (#303) Co-authored-by: Claude Opus 4.8 (1M context) --- .env.example | 9 +++++++++ .github/workflows/on-device-tests.yml | 17 ++++++++++++++++ .gitignore | 3 ++- package-lock.json | 20 +++++++++++++++++++ package.json | 3 ++- src/device.spec.ts | 28 +++++++++++++++++++++++++-- 6 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/on-device-tests.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..446815d --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Copy this file to `.env` and fill in the connection info for a real Roku device. +# The device test suite (`npm run test:device`) reads these to know which device to target. +# `.env` is gitignored so your device credentials never get committed. + +# IP address or hostname of the Roku device to run the device tests against +ROKU_HOST=192.168.1.31 + +# Developer password for that device (set when you enabled developer mode) +ROKU_PASSWORD=aaaa diff --git a/.github/workflows/on-device-tests.yml b/.github/workflows/on-device-tests.yml new file mode 100644 index 0000000..caf002c --- /dev/null +++ b/.github/workflows/on-device-tests.yml @@ -0,0 +1,17 @@ +name: on-device-tests +on: + push: + branches: + - master + pull_request: + +jobs: + on-device-tests: + runs-on: [self-hosted, roku-latest, enabled] + steps: + - uses: actions/checkout@master + - uses: actions/setup-node@master + with: + node-version: "18.20.4" + - run: npm ci + - run: npm run test:device diff --git a/.gitignore b/.gitignore index 8c0cf87..f104103 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ out .roku-deploy-staging coverage .nyc_output -*.zip \ No newline at end of file +*.zip +.env \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 048bd12..b2aef9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "@typescript-eslint/parser": "5.1.0", "audit-ci": "^7.1.0", "chai": "^4.3.4", + "dotenv": "^17.4.2", "eslint": "8.0.1", "mocha": "^10.8.2", "nyc": "^15.1.0", @@ -1553,6 +1554,19 @@ "node": ">=6.0.0" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", @@ -5734,6 +5748,12 @@ "esutils": "^2.0.2" } }, + "dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true + }, "duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", diff --git a/package.json b/package.json index 219add2..e19432b 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "audit": "npm audit || audit-ci --config ./audit-ci.jsonc", "test": "nyc mocha \"src/**/*.spec.ts\" --exclude \"src/device.spec.ts\"", "test:nocover": "mocha \"src/**/*.spec.ts\" --exclude \"src/device.spec.ts\"", - "test:device": "nyc mocha src/device.spec.ts", + "test:device": "mocha src/device.spec.ts", "test:all": "nyc mocha \"src/**/*.spec.ts\"", "test-without-sourcemaps": "npm run build && nyc mocha dist/**/*.spec.js", "package": "npm run build && npm pack" @@ -47,6 +47,7 @@ "@typescript-eslint/parser": "5.1.0", "audit-ci": "^7.1.0", "chai": "^4.3.4", + "dotenv": "^17.4.2", "eslint": "8.0.1", "mocha": "^10.8.2", "nyc": "^15.1.0", diff --git a/src/device.spec.ts b/src/device.spec.ts index db0f187..bf2b1b1 100644 --- a/src/device.spec.ts +++ b/src/device.spec.ts @@ -2,12 +2,26 @@ import * as assert from 'assert'; import * as fsExtra from 'fs-extra'; import * as net from 'net'; import * as http from 'http'; +import * as path from 'path'; import * as semver from 'semver'; +import * as dotenv from 'dotenv'; import * as rokuDeploy from './index'; import * as errors from './Errors'; import { cwd, expectPathExists, expectThrowsAsync, outDir, rootDir, tempDir, writeFiles } from './testUtils.spec'; import undent from 'undent'; +//load device connection info from a .env file at the repo root (if present), then fall back to any +//pre-existing environment variables. This is how CI/CD (and local dev) point the device suite at a +//real Roku without hardcoding host/password into the repo. See .env.example. +dotenv.config({ + path: path.resolve(__dirname, '../.env'), + override: true, + quiet: true +}); + +const HOST = process.env.ROKU_HOST; +const PASSWORD = process.env.ROKU_PASSWORD; + //socket teardown callbacks, drained in afterEach so the suite doesn't hang open const cleanups: Array<() => void> = []; @@ -20,15 +34,25 @@ const REQUEST_TIMEOUT = 15_000; describe('device', function device() { let options: rokuDeploy.RokuDeployOptions; + before(() => { + //fail fast with a clear message rather than letting every test time out against an empty host + if (!HOST || !PASSWORD) { + throw new Error( + `Missing Roku device connection info. Set ROKU_HOST and ROKU_PASSWORD in "${path.resolve(__dirname, '../.env')}" ` + + `(see .env.example) or as environment variables before running "npm run test:device".` + ); + } + }); + beforeEach(() => { fsExtra.emptyDirSync(tempDir); fsExtra.ensureDirSync(rootDir); process.chdir(rootDir); options = rokuDeploy.getOptions({ outDir: outDir, - host: '192.168.1.31', + host: HOST, retainDeploymentArchive: true, - password: 'aaaa', + password: PASSWORD, devId: 'c6fdc2019903ac3332f624b0b2c2fe2c733c3e74', rekeySignedPackage: `${cwd}/testSignedPackage.pkg`, signingPassword: 'drRCEVWP/++K5TYnTtuAfQ=='