diff --git a/visual-js/.changeset/forty-fans-tickle.md b/visual-js/.changeset/forty-fans-tickle.md new file mode 100644 index 00000000..52a87288 --- /dev/null +++ b/visual-js/.changeset/forty-fans-tickle.md @@ -0,0 +1,5 @@ +--- +"@saucelabs/visual-snapshots": minor +--- + +initial release for visual-snapshots diff --git a/visual-js/package-lock.json b/visual-js/package-lock.json index aefe116f..f7b899d0 100644 --- a/visual-js/package-lock.json +++ b/visual-js/package-lock.json @@ -11,6 +11,7 @@ "visual-cypress", "visual-nightwatch", "visual-playwright", + "visual-snapshots", "visual-storybook" ], "dependencies": { @@ -2903,7 +2904,6 @@ "version": "1.0.11", "license": "BSD-3-Clause", "optional": true, - "peer": true, "dependencies": { "detect-libc": "^2.0.0", "https-proxy-agent": "^5.0.0", @@ -2922,14 +2922,12 @@ "node_modules/@mapbox/node-pre-gyp/node_modules/abbrev": { "version": "1.1.1", "license": "ISC", - "optional": true, - "peer": true + "optional": true }, "node_modules/@mapbox/node-pre-gyp/node_modules/agent-base": { "version": "6.0.2", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "debug": "4" }, @@ -2941,7 +2939,6 @@ "version": "2.0.3", "license": "Apache-2.0", "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -2950,7 +2947,6 @@ "version": "5.0.1", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "agent-base": "6", "debug": "4" @@ -2963,7 +2959,6 @@ "version": "2.7.0", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "whatwg-url": "^5.0.0" }, @@ -2983,7 +2978,6 @@ "version": "5.0.0", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "abbrev": "1" }, @@ -2997,14 +2991,12 @@ "node_modules/@mapbox/node-pre-gyp/node_modules/webidl-conversions": { "version": "3.0.1", "license": "BSD-2-Clause", - "optional": true, - "peer": true + "optional": true }, "node_modules/@mapbox/node-pre-gyp/node_modules/whatwg-url": { "version": "5.0.0", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" @@ -3341,6 +3333,10 @@ "resolved": "visual-playwright", "link": true }, + "node_modules/@saucelabs/visual-snapshots": { + "resolved": "visual-snapshots", + "link": true + }, "node_modules/@saucelabs/visual-storybook": { "resolved": "visual-storybook", "link": true @@ -3740,6 +3736,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/async-lock": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/async-lock/-/async-lock-1.4.2.tgz", + "integrity": "sha512-HlZ6Dcr205BmNhwkdXqrg2vkFMN2PluI7Lgr8In3B3wE5PiQHhjRqtW/lGdVU9gw+sM0JcIDx2AN+cW8oSWIcw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "license": "MIT", @@ -4797,8 +4800,7 @@ "node_modules/aproba": { "version": "2.0.0", "license": "ISC", - "optional": true, - "peer": true + "optional": true }, "node_modules/arch": { "version": "2.2.0", @@ -5017,7 +5019,6 @@ "version": "2.0.0", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "delegates": "^1.0.0", "readable-stream": "^3.6.0" @@ -5134,6 +5135,12 @@ "version": "3.2.6", "license": "MIT" }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "license": "MIT" @@ -5145,6 +5152,15 @@ "node": ">= 4.0.0" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/auto-bind": { "version": "4.0.0", "dev": true, @@ -5773,7 +5789,6 @@ "hasInstallScript": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@mapbox/node-pre-gyp": "^1.0.0", "nan": "^2.17.0", @@ -5919,7 +5934,6 @@ "version": "2.0.0", "license": "ISC", "optional": true, - "peer": true, "engines": { "node": ">=10" } @@ -6099,7 +6113,6 @@ "version": "1.1.3", "license": "ISC", "optional": true, - "peer": true, "bin": { "color-support": "bin.js" } @@ -6182,8 +6195,7 @@ "node_modules/console-control-strings": { "version": "1.1.0", "license": "ISC", - "optional": true, - "peer": true + "optional": true }, "node_modules/constant-case": { "version": "3.0.4", @@ -6293,8 +6305,6 @@ }, "node_modules/cross-env": { "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", "dev": true, "license": "MIT", "dependencies": { @@ -6611,6 +6621,15 @@ "url": "https://opencollective.com/date-fns" } }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/dayjs": { "version": "1.11.13", "license": "MIT" @@ -6731,6 +6750,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "dev": true, @@ -6839,8 +6865,7 @@ "node_modules/delegates": { "version": "1.0.0", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/depd": { "version": "2.0.0", @@ -7732,6 +7757,13 @@ "node": ">= 0.8.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expand-tilde": { "version": "1.2.2", "license": "MIT", @@ -8402,6 +8434,12 @@ ], "license": "MIT" }, + "node_modules/fast-copy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", + "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==", + "license": "MIT" + }, "node_modules/fast-decode-uri-component": { "version": "1.0.1", "dev": true, @@ -8453,6 +8491,21 @@ "fast-decode-uri-component": "^1.0.1" } }, + "node_modules/fast-redact": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz", + "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, "node_modules/fast-url-parser": { "version": "1.1.3", "dev": true, @@ -8896,7 +8949,6 @@ "version": "3.0.2", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "aproba": "^1.0.3 || ^2.0.0", "color-support": "^1.1.2", @@ -9134,6 +9186,10 @@ "assert-plus": "^1.0.0" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "license": "ISC", @@ -9473,8 +9529,7 @@ "node_modules/has-unicode": { "version": "2.0.1", "license": "ISC", - "optional": true, - "peer": true + "optional": true }, "node_modules/hasha": { "version": "5.2.2", @@ -9523,6 +9578,12 @@ "tslib": "^2.0.3" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "license": "BSD-3-Clause", @@ -11276,7 +11337,6 @@ }, "node_modules/joycon": { "version": "3.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -12086,7 +12146,6 @@ "version": "2.1.2", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" @@ -12099,7 +12158,6 @@ "version": "3.3.6", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "yallist": "^4.0.0" }, @@ -12362,8 +12420,11 @@ "node_modules/nan": { "version": "2.22.2", "license": "MIT", - "optional": true, - "peer": true + "optional": true + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "license": "MIT" }, "node_modules/natural-compare": { "version": "1.4.0", @@ -12529,9 +12590,18 @@ "tslib": "^2.0.3" } }, + "node_modules/node-abi": { + "version": "3.74.0", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-addon-api": { "version": "7.1.1", - "dev": true, "license": "MIT" }, "node_modules/node-cleanup": { @@ -12641,7 +12711,6 @@ "version": "5.0.1", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "are-we-there-yet": "^2.0.0", "console-control-strings": "^1.1.0", @@ -12855,6 +12924,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "license": "MIT", @@ -13289,6 +13367,14 @@ "node": ">=8" } }, + "node_modules/path2d": { + "version": "0.2.2", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/pathe": { "version": "1.1.2", "license": "MIT", @@ -13312,6 +13398,43 @@ "through": "~2.3" } }, + "node_modules/pdf-to-img": { + "version": "4.4.0", + "license": "MIT", + "dependencies": { + "canvas": "3.1.0", + "pdfjs-dist": "4.2.67" + }, + "bin": { + "pdf2img": "bin/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pdf-to-img/node_modules/canvas": { + "version": "3.1.0", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "^18.12.0 || >= 20.9.0" + } + }, + "node_modules/pdfjs-dist": { + "version": "4.2.67", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "canvas": "^2.11.2", + "path2d": "^0.2.0" + } + }, "node_modules/pend": { "version": "1.2.0", "license": "MIT" @@ -13341,6 +13464,67 @@ "node": ">=6" } }, + "node_modules/pino": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.6.0.tgz", + "integrity": "sha512-i85pKRCt4qMjZ1+L7sy2Ag4t1atFcdbEt76+7iRJn1g2BvsnRMGu9p8pivl9fs63M2kF/A0OacFZhTub+m/qMg==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^4.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.0.0.tgz", + "integrity": "sha512-cQBBIVG3YajgoUjo1FdKVRX6t9XPxwB9lcNJVD5GCnNM4Y6T12YYx8c6zEejxQsU0wrg9TwmDulcE9LR7qcJqA==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^3.0.2", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^2.4.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^3.1.1" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", + "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==", + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.6", "license": "MIT", @@ -13454,6 +13638,88 @@ "node": ">= 14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/chownr": { + "version": "1.1.4", + "license": "ISC" + }, + "node_modules/prebuild-install/node_modules/detect-libc": { + "version": "2.0.3", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/prebuild-install/node_modules/simple-get": { + "version": "4.0.1", + "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", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.2", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/prebuild-install/node_modules/tar-stream": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "dev": true, @@ -13543,6 +13809,22 @@ "node": ">=8" } }, + "node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "license": "MIT", @@ -13977,6 +14259,12 @@ "license": "MIT", "optional": true }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/quick-lru": { "version": "5.1.1", "license": "MIT", @@ -14014,6 +14302,30 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "license": "ISC" + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "16.13.1", "license": "MIT" @@ -14189,6 +14501,15 @@ "node": ">=8.10.0" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/recast": { "version": "0.23.9", "license": "MIT", @@ -14512,6 +14833,15 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "license": "MIT" @@ -14531,6 +14861,12 @@ "dev": true, "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "license": "BSD-3-Clause" + }, "node_modules/selenium-webdriver": { "version": "4.25.0", "dev": true, @@ -14745,15 +15081,12 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/simple-get": { "version": "3.1.1", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "decompress-response": "^4.2.0", "once": "^1.3.1", @@ -14764,7 +15097,6 @@ "version": "4.2.1", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "mimic-response": "^2.0.0" }, @@ -14776,7 +15108,6 @@ "version": "2.1.0", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=8" }, @@ -14848,6 +15179,15 @@ "node": ">= 14" } }, + "node_modules/sonic-boom": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", + "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "license": "BSD-3-Clause", @@ -14984,7 +15324,6 @@ "node_modules/split2": { "version": "4.2.0", "license": "ISC", - "optional": true, "engines": { "node": ">= 10.x" } @@ -15359,7 +15698,6 @@ "version": "6.2.1", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -15396,7 +15734,6 @@ "version": "2.1.0", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "minipass": "^3.0.0" }, @@ -15408,7 +15745,6 @@ "version": "3.3.6", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "yallist": "^4.0.0" }, @@ -15420,7 +15756,6 @@ "version": "5.0.0", "license": "ISC", "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -15479,6 +15814,15 @@ "node": ">=0.8" } }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/throttleit": { "version": "1.0.1", "license": "MIT", @@ -15897,6 +16241,227 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.24.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.24.0", + "@typescript-eslint/parser": "8.24.0", + "@typescript-eslint/utils": "8.24.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.24.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.24.0", + "@typescript-eslint/type-utils": "8.24.0", + "@typescript-eslint/utils": "8.24.0", + "@typescript-eslint/visitor-keys": "8.24.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { + "version": "8.24.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.24.0", + "@typescript-eslint/types": "8.24.0", + "@typescript-eslint/typescript-estree": "8.24.0", + "@typescript-eslint/visitor-keys": "8.24.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": { + "version": "8.24.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.24.0", + "@typescript-eslint/visitor-keys": "8.24.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": { + "version": "8.24.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.24.0", + "@typescript-eslint/utils": "8.24.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/types": { + "version": "8.24.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.24.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.24.0", + "@typescript-eslint/visitor-keys": "8.24.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { + "version": "8.24.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.24.0", + "@typescript-eslint/types": "8.24.0", + "@typescript-eslint/typescript-estree": "8.24.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.24.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.24.0", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/typescript-eslint/node_modules/minimatch": { + "version": "9.0.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typescript-eslint/node_modules/ts-api-utils": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ua-parser-js": { "version": "1.0.39", "funding": [ @@ -16738,7 +17303,6 @@ "version": "1.1.5", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } @@ -17298,6 +17862,156 @@ "dev": true, "license": "MIT" }, + "visual-snapshots": { + "name": "@saucelabs/visual-snapshots", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@saucelabs/visual": "^0.13.0", + "async-lock": "^1.4.1", + "commander": "^12.0.0", + "glob": "^11.0.1", + "pdf-to-img": "~4.4.0", + "pino": "^9.6.0", + "pino-pretty": "^13.0.0", + "workerpool": "^9.2.0" + }, + "bin": { + "visual-snapshots": "lib/index.js" + }, + "devDependencies": { + "@types/async-lock": "^1.4.2", + "@types/jest": "29.5.14", + "eslint": "^8.0.1", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-prettier": "^4.0.0", + "jest": "29.7.0", + "prettier": "^2.8.8", + "rimraf": "^6.0.1", + "ts-jest": "29.2.5", + "typescript": "^5.0.4", + "typescript-eslint": "8.24.0" + }, + "engines": { + "node": ">=18" + } + }, + "visual-snapshots/node_modules/foreground-child": { + "version": "3.3.1", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "visual-snapshots/node_modules/glob": { + "version": "11.0.1", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^4.0.1", + "minimatch": "^10.0.0", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "visual-snapshots/node_modules/jackspeak": { + "version": "4.1.0", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "visual-snapshots/node_modules/lru-cache": { + "version": "11.0.2", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "visual-snapshots/node_modules/minimatch": { + "version": "10.0.1", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "visual-snapshots/node_modules/path-scurry": { + "version": "2.0.0", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "visual-snapshots/node_modules/rimraf": { + "version": "6.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^11.0.0", + "package-json-from-dist": "^1.0.0" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "visual-snapshots/node_modules/signal-exit": { + "version": "4.1.0", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "visual-snapshots/node_modules/workerpool": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.2.0.tgz", + "integrity": "sha512-PKZqBOCo6CYkVOwAxWxQaSF2Fvb5Iv2fCeTP7buyWI2GiynWr46NcXSgK/idoV6e60dgCBfgYc+Un3HMvmqP8w==", + "license": "Apache-2.0" + }, "visual-storybook": { "name": "@saucelabs/visual-storybook", "version": "0.10.2", diff --git a/visual-js/package.json b/visual-js/package.json index fe6b0afd..8938c8f0 100644 --- a/visual-js/package.json +++ b/visual-js/package.json @@ -7,6 +7,7 @@ "visual-cypress", "visual-nightwatch", "visual-playwright", + "visual-snapshots", "visual-storybook" ], "lint-staged": { diff --git a/visual-js/replace_pkg_version.sh b/visual-js/replace_pkg_version.sh index 23be0fa1..edff0509 100755 --- a/visual-js/replace_pkg_version.sh +++ b/visual-js/replace_pkg_version.sh @@ -34,6 +34,7 @@ FILE_MAP=( ["@saucelabs/nightwatch-sauce-visual-service"]="visual-nightwatch/src/utils/constants.ts" ["@saucelabs/visual-storybook"]="visual-storybook/src/api.ts" ["@saucelabs/wdio-sauce-visual-service"]="visual-wdio/src/SauceVisualService.ts" + ["@saucelabs/visual-snapshots"]="visual-snapshots/src/version.ts" ["@saucelabs/visual-playwright"]="visual-playwright/src/api.ts" # Add more mappings as needed ) diff --git a/visual-js/visual-snapshots/.eslintrc.cjs b/visual-js/visual-snapshots/.eslintrc.cjs new file mode 100644 index 00000000..d0ccd7d8 --- /dev/null +++ b/visual-js/visual-snapshots/.eslintrc.cjs @@ -0,0 +1,36 @@ +module.exports = { + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], + parser: '@typescript-eslint/parser', + parserOptions: { + project: './tsconfig.test.json', + tsconfigRootDir: __dirname, + sourceType: 'module', + }, + plugins: ['@typescript-eslint'], + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ], + root: true, + env: { + node: true, + jest: true, + }, + ignorePatterns: ['.eslintrc.js', 'codegen.ts', 'jest.config.js', 'src/graphql/__generated__'], + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + + // Allow unused vars that start with _ + "@typescript-eslint/no-unused-vars": [ + "warn", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ], + }, +}; diff --git a/visual-js/visual-snapshots/.gitignore b/visual-js/visual-snapshots/.gitignore new file mode 100644 index 00000000..151ac14b --- /dev/null +++ b/visual-js/visual-snapshots/.gitignore @@ -0,0 +1,6 @@ +.work +build/ +lib/ +coverage/ +.parent/ +*.tsbuildinfo diff --git a/visual-js/visual-snapshots/README.md b/visual-js/visual-snapshots/README.md new file mode 100644 index 00000000..8413b25a --- /dev/null +++ b/visual-js/visual-snapshots/README.md @@ -0,0 +1,40 @@ +# Sauce Labs Visual Snapshot CLI + +This package provides a CLI tool to create Visual snapshots of a provided PDF document. + +## Requirements + +```sh +node >= 18 +``` + +## Installation + +```sh +npm install --save @saucelabs/visual-snapshots +``` + +## Development + +Build: + +```sh +npm run build +``` + +Execute: + +```sh +node lib/index.js pdf [params] +``` + +Run tests: + +```sh +npm run test +``` + +## Reusing pdf conversion code + +While it is possible to use `VisualSnapshotsApi` outside this package, please bear in mind it can only be used with ESM modules. +CommonJS modules are not supported. diff --git a/visual-js/visual-snapshots/jest.config.cjs b/visual-js/visual-snapshots/jest.config.cjs new file mode 100644 index 00000000..82350928 --- /dev/null +++ b/visual-js/visual-snapshots/jest.config.cjs @@ -0,0 +1,21 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + // [...] + preset: "ts-jest/presets/default-esm", // or other ESM presets + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1", + }, + testPathIgnorePatterns: ["/lib/"], + setupFiles: ["./jest.setup.mjs"], + transform: { + // '^.+\\.[tj]sx?$' to process js/ts with `ts-jest` + // '^.+\\.m?[tj]sx?$' to process js/ts/mjs/mts with `ts-jest` + "^.+\\.tsx?$": [ + "ts-jest", + { + useESM: true, + tsconfig: "tsconfig.test.json", + }, + ], + }, +}; diff --git a/visual-js/visual-snapshots/jest.setup.mjs b/visual-js/visual-snapshots/jest.setup.mjs new file mode 100644 index 00000000..664a88f7 --- /dev/null +++ b/visual-js/visual-snapshots/jest.setup.mjs @@ -0,0 +1,2 @@ +import { jest } from "@jest/globals"; +global.jest = jest; diff --git a/visual-js/visual-snapshots/package.json b/visual-js/visual-snapshots/package.json new file mode 100644 index 00000000..2c5b587c --- /dev/null +++ b/visual-js/visual-snapshots/package.json @@ -0,0 +1,54 @@ +{ + "name": "@saucelabs/visual-snapshots", + "description": "CLI which generates Visual snapshots from a data source such as pdf", + "version": "0.0.0", + "main": "./lib/index.js", + "license": "MIT", + "bin": "./lib/index.js", + "files": [ + "lib", + "README.md" + ], + "type": "module", + "engines": { + "node": ">=18" + }, + "keywords": [ + "saucelabs", + "visual", + "snapshots", + "pdf" + ], + "scripts": { + "build": "rimraf *.tsbuildinfo && tsc -b tsconfig.json", + "watch": "rimraf *.tsbuildinfo && tsc -b tsconfig.json -w", + "lint": "eslint \"{src,test}/**/*.ts\"", + "lint-fix": "eslint \"{src,test}/**/*.ts\" --fix", + "test": "cross-env NODE_NO_WARNINGS=1 NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest", + "test-update-snapshots": "cross-env NODE_NO_WARNINGS=1 NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest -u", + "test-with-coverage": "cross-env NODE_NO_WARNINGS=1 NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest --collect-coverage" + }, + "dependencies": { + "@saucelabs/visual": "^0.13.0", + "async-lock": "^1.4.1", + "commander": "^12.0.0", + "glob": "^11.0.1", + "pdf-to-img": "~4.4.0", + "pino": "^9.6.0", + "pino-pretty": "^13.0.0", + "workerpool": "^9.2.0" + }, + "devDependencies": { + "@types/async-lock": "^1.4.2", + "@types/jest": "29.5.14", + "eslint": "^8.0.1", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-prettier": "^4.0.0", + "jest": "29.7.0", + "prettier": "^2.8.8", + "rimraf": "^6.0.1", + "ts-jest": "29.2.5", + "typescript": "^5.0.4", + "typescript-eslint": "8.24.0" + } +} diff --git a/visual-js/visual-snapshots/src/api/visual-client.ts b/visual-js/visual-snapshots/src/api/visual-client.ts new file mode 100644 index 00000000..c3445e8f --- /dev/null +++ b/visual-js/visual-snapshots/src/api/visual-client.ts @@ -0,0 +1,9 @@ +import { getApi, VisualConfig } from "@saucelabs/visual"; + +export const visualApiInitializer = + (_getApi: typeof getApi) => (params: VisualConfig, clientVersion: string) => + _getApi(params, { + userAgent: `visual-snapshots/${clientVersion}`, + }); + +export const initializeVisualApi = visualApiInitializer(getApi); diff --git a/visual-js/visual-snapshots/src/api/visual-snapshots-api.ts b/visual-js/visual-snapshots/src/api/visual-snapshots-api.ts new file mode 100644 index 00000000..50ac5934 --- /dev/null +++ b/visual-js/visual-snapshots/src/api/visual-snapshots-api.ts @@ -0,0 +1,136 @@ +import { BuildStatus, DiffingMethod, VisualApi } from "@saucelabs/visual"; +import { __dirname } from "../utils/helpers.js"; +import { Logger } from "pino"; +import { logger as defaultLogger } from "../logger.js"; + +export interface CreateVisualSnapshotsParams { + branch?: string; + buildName?: string; + defaultBranch?: string; + project?: string; + customId?: string; + buildId?: string; + suiteName?: string; + testName?: string; + snapshotName?: string; +} + +export interface CreateBuildParams { + readonly buildName?: string; + readonly branch?: string; + readonly defaultBranch?: string; + readonly project?: string; + readonly customId?: string; + readonly logger?: Logger; +} + +export interface FinishBuildParams { + readonly buildId: string; + readonly logger?: Logger; +} + +export interface UploadSnapshotParams { + readonly buildId: string; + readonly snapshot: Buffer; + readonly snapshotName: string; + readonly suiteName?: string; + readonly testName?: string; + readonly logger?: Logger; +} + +export class VisualSnapshotsApi { + constructor(private readonly api: VisualApi) {} + + public async createBuild(params: CreateBuildParams): Promise { + const logger = params.logger ?? defaultLogger; + + const build = await this.api.createBuild({ + name: params.buildName, + branch: params.branch, + defaultBranch: params.defaultBranch, + project: params.project, + customId: params.customId, + }); + + logger.info( + { + buildId: build.id, + url: build.url, + }, + `Build created.` + ); + + return build.id; + } + + public async finishBuild(params: FinishBuildParams) { + const buildId = params.buildId; + const logger = params.logger ?? defaultLogger; + + const { status: buildStatus } = await this.api.finishBuild({ + uuid: buildId, + }); + + if ([BuildStatus.Running, BuildStatus.Queued].includes(buildStatus)) { + logger.info( + { + buildId, + buildStatus, + }, + `Build finished but snapshots haven't been compared yet. Check the build status in a few moments.` + ); + } else { + const { unapprovedCount, errorCount } = (await this.api.buildStatus( + buildId + ))!; + logger.info( + { + buildId, + buildStatus, + unapprovedCount, + errorCount, + }, + `Build finished.` + ); + } + } + + public async uploadImageAndCreateSnapshot(params: UploadSnapshotParams) { + const logger = params.logger ?? defaultLogger; + + const uploadId = await this.api.uploadSnapshot({ + buildId: params.buildId, + image: { data: params.snapshot }, + }); + + logger.info( + { + buildId: params.buildId, + uploadId, + }, + `Uploaded image to build.` + ); + + await this.api.createSnapshot({ + buildId: params.buildId, + uploadId, + name: params.snapshotName, + diffingMethod: DiffingMethod.Balanced, + testName: params.testName, + suiteName: params.suiteName, + }); + + logger.info( + { + buildId: params.buildId, + uploadId, + snapshotName: params.snapshotName, + testName: params.testName, + suiteName: params.suiteName, + }, + `Created a snapshot for build.` + ); + + return uploadId; + } +} diff --git a/visual-js/visual-snapshots/src/app/pdf-file-loader.ts b/visual-js/visual-snapshots/src/app/pdf-file-loader.ts new file mode 100644 index 00000000..735fd70c --- /dev/null +++ b/visual-js/visual-snapshots/src/app/pdf-file-loader.ts @@ -0,0 +1,22 @@ +import { pdf } from "pdf-to-img"; +import { PdfFile } from "./pdf-file.js"; + +export interface PdfFileLoader { + loadPdfFile(path: string): Promise; +} + +export class LibPdfFileLoader implements PdfFileLoader { + constructor(private readonly _pdf: typeof pdf = pdf) {} + + public async loadPdfFile(pdfFilePath: string): Promise { + const pdfFile = await this._pdf(pdfFilePath, { + scale: 1, + }); + + return { + path: pdfFilePath, + pages: pdfFile.length, + getPage: (page) => pdfFile.getPage(page), + }; + } +} diff --git a/visual-js/visual-snapshots/src/app/pdf-file.ts b/visual-js/visual-snapshots/src/app/pdf-file.ts new file mode 100644 index 00000000..06ad53bb --- /dev/null +++ b/visual-js/visual-snapshots/src/app/pdf-file.ts @@ -0,0 +1,5 @@ +export interface PdfFile { + readonly path: string; + readonly pages: number; + getPage(page: number): Promise; +} diff --git a/visual-js/visual-snapshots/src/app/pdf-files-snapshot-uploader.ts b/visual-js/visual-snapshots/src/app/pdf-files-snapshot-uploader.ts new file mode 100644 index 00000000..8e5261a4 --- /dev/null +++ b/visual-js/visual-snapshots/src/app/pdf-files-snapshot-uploader.ts @@ -0,0 +1,11 @@ +export interface UploadPdfSnapshotsParams { + readonly buildId: string; + readonly pdfFilePaths: string[]; + readonly suiteName?: string; + readonly testNameFormat?: string; + readonly snapshotNameFormat?: string; +} + +export interface PdfSnapshotUploader { + uploadSnapshots(params: UploadPdfSnapshotsParams): Promise; +} diff --git a/visual-js/visual-snapshots/src/app/pdf-handler.ts b/visual-js/visual-snapshots/src/app/pdf-handler.ts new file mode 100644 index 00000000..407bc225 --- /dev/null +++ b/visual-js/visual-snapshots/src/app/pdf-handler.ts @@ -0,0 +1,44 @@ +import { + CreateVisualSnapshotsParams, + VisualSnapshotsApi, +} from "../api/visual-snapshots-api.js"; +import { VisualConfig } from "@saucelabs/visual"; +import { getFiles } from "../utils/glob.js"; +import { PdfSnapshotUploader } from "./pdf-files-snapshot-uploader.js"; + +export interface PdfCommandParams + extends VisualConfig, + CreateVisualSnapshotsParams { + concurrency: number; +} + +export class PdfCommandHandler { + constructor( + private readonly visualSnapshotsApi: VisualSnapshotsApi, + private readonly pdfSnapshotUploader: PdfSnapshotUploader + ) {} + + public async handle( + globsOrDirs: string[], + params: PdfCommandParams + ): Promise { + const pdfFilePaths = await getFiles(globsOrDirs, "*.pdf"); + + const buildId = + params.buildId ?? (await this.visualSnapshotsApi.createBuild(params)); + + try { + await this.pdfSnapshotUploader.uploadSnapshots({ + buildId, + pdfFilePaths, + suiteName: params.suiteName, + testNameFormat: params.testName, + snapshotNameFormat: params.snapshotName, + }); + } finally { + if (!params.buildId) { + await this.visualSnapshotsApi.finishBuild({ buildId }); + } + } + } +} diff --git a/visual-js/visual-snapshots/src/app/single-cached-pdf-file-loader.ts b/visual-js/visual-snapshots/src/app/single-cached-pdf-file-loader.ts new file mode 100644 index 00000000..1360239b --- /dev/null +++ b/visual-js/visual-snapshots/src/app/single-cached-pdf-file-loader.ts @@ -0,0 +1,19 @@ +import { PdfFile } from "./pdf-file.js"; +import { PdfFileLoader } from "./pdf-file-loader.js"; + +export class SingleCachedPdfFileLoader implements PdfFileLoader { + private loadedFilePath?: string; + private loadedFile?: PdfFile; + + constructor(private readonly pdfConverter: PdfFileLoader) {} + + public async loadPdfFile(path: string): Promise { + if (this.loadedFile && this.loadedFilePath === path) { + return this.loadedFile; + } + + this.loadedFile = await this.pdfConverter.loadPdfFile(path); + this.loadedFilePath = path; + return this.loadedFile; + } +} diff --git a/visual-js/visual-snapshots/src/app/worker/pdf-page-snapshot-uploader.ts b/visual-js/visual-snapshots/src/app/worker/pdf-page-snapshot-uploader.ts new file mode 100644 index 00000000..a817367a --- /dev/null +++ b/visual-js/visual-snapshots/src/app/worker/pdf-page-snapshot-uploader.ts @@ -0,0 +1,65 @@ +import path from "path"; +import { formatString } from "../../utils/format.js"; +import { PdfFileLoader } from "../pdf-file-loader.js"; +import { VisualSnapshotsApi } from "../../api/visual-snapshots-api.js"; +import { logger as defaultLogger } from "../../logger.js"; + +export class PdfPageSnapshotUploader { + constructor( + private readonly visualSnapshotsApi: VisualSnapshotsApi, + private readonly pdfFileLoader: PdfFileLoader + ) {} + + public async uploadPageSnapshot( + buildId: string, + pdfFilePath: string, + pageNumber: number, + suiteName: string | undefined, + testNameFormat: string | undefined, + snapshotNameFormat: string | undefined + ) { + const pdfFile = await this.pdfFileLoader.loadPdfFile(pdfFilePath); + const page = await pdfFile.getPage(pageNumber); + + const filename = path.basename(pdfFile.path); + const testName = testNameFormat + ? formatString(testNameFormat, { filename }) + : undefined; + + const snapshotFormat = this.getSnapshotFormat(snapshotNameFormat); + const snapshotName = formatString(snapshotFormat, { + filename, + page: pageNumber, + }); + + const logger = defaultLogger.child({ + filePath: pdfFilePath, + pageNumber, + snapshotName, + suiteName, + testName, + }); + + return await this.visualSnapshotsApi.uploadImageAndCreateSnapshot({ + buildId, + snapshot: page, + snapshotName, + suiteName, + testName, + logger, + }); + } + + private getSnapshotFormat(format: string | undefined) { + if (!format) { + return `page-{page}`; + } + + // Page number is always required to make the snapshot names unique + if (!format.includes("{page}")) { + format = format += "-{page}"; + } + + return format; + } +} diff --git a/visual-js/visual-snapshots/src/app/worker/worker-pool-pdf-snapshot-uploader.ts b/visual-js/visual-snapshots/src/app/worker/worker-pool-pdf-snapshot-uploader.ts new file mode 100644 index 00000000..32222de3 --- /dev/null +++ b/visual-js/visual-snapshots/src/app/worker/worker-pool-pdf-snapshot-uploader.ts @@ -0,0 +1,75 @@ +import path from "path"; +import workerpool, { WorkerPoolOptions } from "workerpool"; +import { + PdfSnapshotUploader as PdfSnapshotUploader, + UploadPdfSnapshotsParams, +} from "../../app/pdf-files-snapshot-uploader.js"; +import { execAll } from "../../utils/pool.js"; +import { PdfFileLoader } from "../../app/pdf-file-loader.js"; +import { ProcessPdfPageMethod } from "./worker.js"; +import { __dirname } from "../../utils/helpers.js"; + +export class WorkerPoolPdfSnapshotUploader implements PdfSnapshotUploader { + constructor( + private readonly pdfLoader: PdfFileLoader, + private readonly poolOptions?: WorkerPoolOptions + ) {} + + public async uploadSnapshots({ + buildId, + pdfFilePaths, + suiteName, + testNameFormat, + snapshotNameFormat, + }: UploadPdfSnapshotsParams): Promise { + const pool = this.createPool(); + try { + await execAll( + pool, + this.processPageCalls( + buildId, + pdfFilePaths, + suiteName, + testNameFormat, + snapshotNameFormat + ) + ); + } finally { + pool.terminate(); + } + } + + private createPool() { + return workerpool.pool(path.join(__dirname(import.meta), "./worker.js"), { + workerThreadOpts: { + argv: process.argv, + }, + ...this.poolOptions, + }); + } + + private async *processPageCalls( + buildId: string, + pdfFilePaths: string[], + suiteName: string | undefined, + testNameFormat: string | undefined, + snapshotNameFormat: string | undefined + ): AsyncGenerator { + for (const pdfFilePath of pdfFilePaths) { + const loaded = await this.pdfLoader.loadPdfFile(pdfFilePath); + for (let i = 0; i < loaded.pages; i++) { + yield { + method: "processPdfPage", + args: [ + buildId, + pdfFilePath, + i + 1, + suiteName, + testNameFormat, + snapshotNameFormat, + ], + }; + } + } + } +} diff --git a/visual-js/visual-snapshots/src/app/worker/worker.ts b/visual-js/visual-snapshots/src/app/worker/worker.ts new file mode 100644 index 00000000..95ec60e1 --- /dev/null +++ b/visual-js/visual-snapshots/src/app/worker/worker.ts @@ -0,0 +1,60 @@ +import workerpool from "workerpool"; +import { program } from "commander"; +import { + usernameOption, + accessKeyOption, + regionOption, + loggerLevel, +} from "../../commands/options.js"; +import { initializeVisualApi } from "../../api/visual-client.js"; +import { LibPdfFileLoader } from "../pdf-file-loader.js"; +import { SingleCachedPdfFileLoader } from "../single-cached-pdf-file-loader.js"; +import { PdfPageSnapshotUploader } from "./pdf-page-snapshot-uploader.js"; +import type { WorkerMethod } from "../../utils/pool.js"; +import { clientVersion } from "../../version.js"; +import { VisualSnapshotsApi } from "../../api/visual-snapshots-api.js"; +import pino from "pino"; +import { logger } from "../../logger.js"; + +program + .addOption(usernameOption) + .addOption(accessKeyOption) + .addOption(regionOption) + .addOption(loggerLevel) + .allowUnknownOption(true) + .allowExcessArguments(true); + +program.on("option:log", (level: pino.Level) => { + logger.level = level; +}); + +program.parse(); + +const { user, key, region } = program.opts(); + +const api = initializeVisualApi( + { + user, + key, + region, + }, + clientVersion +); + +const pdfWorkerApi = new PdfPageSnapshotUploader( + new VisualSnapshotsApi(api), + // Use a single caching PDF file loader. The files are processed sequentially, + // thus a worker will never re-visit a file, so there's no need to cache more files. + new SingleCachedPdfFileLoader(new LibPdfFileLoader()) +); + +const functions = { + processPdfPage: pdfWorkerApi.uploadPageSnapshot.bind(pdfWorkerApi), +}; + +workerpool.worker(functions); + +export type ProcessPdfPageMethod = WorkerMethod< + "processPdfPage", + (typeof functions)["processPdfPage"] +>; diff --git a/visual-js/visual-snapshots/src/commands/options.ts b/visual-js/visual-snapshots/src/commands/options.ts new file mode 100644 index 00000000..84e13076 --- /dev/null +++ b/visual-js/visual-snapshots/src/commands/options.ts @@ -0,0 +1,93 @@ +import { Option } from "commander"; +import { EOL, cpus } from "os"; +import { parseInteger, parseUuid } from "./validate.js"; + +export const usernameOption = new Option( + "-u, --user ", + "Your Sauce Labs username. You can get this from the header of app.saucelabs.com." + + EOL + + "If not provided, SAUCE_USERNAME environment variable will be used." +) + .env("SAUCE_USERNAME") + .makeOptionMandatory(true); + +export const accessKeyOption = new Option( + "-k, --key ", + "Your Sauce Labs access key. You can get this from the header of app.saucelabs.com" + + EOL + + "If not provided, SAUCE_ACCESS_KEY environment variable will be used." +) + .env("SAUCE_ACCESS_KEY") + .makeOptionMandatory(true); + +export const regionOption = new Option( + "-r, --region ", + "The region you'd like to run your Visual tests in. Defaults to 'us-west-1' if not supplied. Can be one of the following: 'eu-central-1', 'us-west-1' or 'us-east-4'." + + EOL + + "If not provided, SAUCE_REGION environment variable will be used." +) + .env("SAUCE_REGION") + .default("us-west-1"); + +export const buildNameOption = new Option( + "-n, --build-name ", + "The name you would like to appear in the Sauce Visual dashboard." + + EOL + + "If not provided, SAUCE_VISUAL_BUILD_NAME environment variable will be used." +).env("SAUCE_VISUAL_BUILD_NAME"); + +export const branchOption = new Option( + "-b, --branch ", + "The branch name you would like to associate this build with. We recommend using your current VCS branch in CI." + + EOL + + "If not provided, SAUCE_VISUAL_BRANCH environment variable will be used." +).env("SAUCE_VISUAL_BRANCH"); + +export const defaultBranchOption = new Option( + "-d, --default-branch ", + " The main branch name you would like to associate this build with. Usually 'main' or 'master' or alternatively the branch name your current branch was derived from." + + EOL + + "If not provided, SAUCE_VISUAL_DEFAULT_BRANCH environment variable will be used." +).env("SAUCE_VISUAL_DEFAULT_BRANCH"); + +export const projectOption = new Option( + "-p, --project ", + "The label / project you would like to associate this build with." + + EOL + + "If not provided, SAUCE_VISUAL_PROJECT environment variable will be used." +).env("SAUCE_VISUAL_PROJECT"); + +export const buildIdOption = new Option( + "--build-id ", + "For advanced users, a user-supplied SauceLabs Visual build ID. Can be used to create builds in advance using the GraphQL API. This can be used to parallelize tests with multiple browsers, shard, or more." + + EOL + + "By default, this is not set and we create / finish a build during setup / teardown." + + EOL + + "If not provided, SAUCE_VISUAL_BUILD_ID environment variable will be used." +) + .env("SAUCE_VISUAL_BUILD_ID") + .argParser(parseUuid); + +export const customIdOption = new Option( + "--custom-id ", + "For advanced users, a user-supplied custom ID to identify this build. Can be used in CI to identify / check / re-check the status of a single build. Usage suggestions: CI pipeline ID." + + EOL + + "If not provided, SAUCE_VISUAL_CUSTOM_ID environment variable will be used." +).env("SAUCE_VISUAL_CUSTOM_ID"); + +export const suiteNameOption = new Option( + "--suite-name ", + "The name of the suite you would like to appear in the Sauce Visual dashboard." +); + +export const concurrencyOption = new Option( + "-j, --concurrency ", + "Maximum count of simultaneous uploads." +) + .default(cpus().length) + .argParser(parseInteger); + +export const loggerLevel = new Option( + "--log ", + "Logging level to use." +).choices(["trace", "debug", "info", "warn", "error", "fatal", "silent"]); diff --git a/visual-js/visual-snapshots/src/commands/pdf.ts b/visual-js/visual-snapshots/src/commands/pdf.ts new file mode 100644 index 00000000..453e0d48 --- /dev/null +++ b/visual-js/visual-snapshots/src/commands/pdf.ts @@ -0,0 +1,78 @@ +import { Command, Option } from "commander"; +import { + accessKeyOption, + branchOption, + buildIdOption, + buildNameOption, + concurrencyOption, + customIdOption, + defaultBranchOption, + projectOption, + regionOption, + suiteNameOption, + usernameOption, +} from "./options.js"; +import { PdfCommandHandler, PdfCommandParams } from "../app/pdf-handler.js"; +import { EOL } from "os"; +import { VisualSnapshotsApi } from "../api/visual-snapshots-api.js"; +import { initializeVisualApi } from "../api/visual-client.js"; +import { WorkerPoolPdfSnapshotUploader } from "../app/worker/worker-pool-pdf-snapshot-uploader.js"; +import { LibPdfFileLoader } from "../app/pdf-file-loader.js"; +import { logger } from "../logger.js"; + +export const testNameOption = new Option( + "--test-name ", + "The name of the test you would like to appear in the Sauce Visual dashboard." + + EOL + + "Supports the following parameters: {filename}" +); + +export const snapshotNameOption = new Option( + "--snapshot-name ", + "The name of the snapshot you would like to appear in the Sauce Visual dashboard." + + EOL + + " Supports the following parameters: {filename}, {page}" +); + +export const pdfCommand = (clientVersion: string) => { + return new Command() + .name("pdf") + .description("Create visual snapshots for each page of a PDF file") + .argument( + "", + "Paths to PDF files, glob patterns, or paths to directories containing PDF files." + ) + .addOption(usernameOption) + .addOption(accessKeyOption) + .addOption(regionOption) + .addOption(buildNameOption) + .addOption(branchOption) + .addOption(defaultBranchOption) + .addOption(projectOption) + .addOption(buildIdOption) + .addOption(customIdOption) + .addOption(suiteNameOption) + .addOption(testNameOption) + .addOption(snapshotNameOption) + .addOption(concurrencyOption) + .action((globsOrDirs: string[], params: PdfCommandParams) => { + const visualSnapshotsApi = new VisualSnapshotsApi( + initializeVisualApi(params, clientVersion) + ); + const pdfSnapshotUploader = new WorkerPoolPdfSnapshotUploader( + new LibPdfFileLoader(), + { + maxWorkers: params.concurrency, + } + ); + + new PdfCommandHandler(visualSnapshotsApi, pdfSnapshotUploader) + .handle(globsOrDirs, params) + .then(() => { + logger.info("Successfully created PDF snapshots."); + }) + .catch((err) => { + logger.error(err, "At least one PDF snapshot creation failed."); + }); + }); +}; diff --git a/visual-js/visual-snapshots/src/commands/validate.ts b/visual-js/visual-snapshots/src/commands/validate.ts new file mode 100644 index 00000000..69f58f48 --- /dev/null +++ b/visual-js/visual-snapshots/src/commands/validate.ts @@ -0,0 +1,33 @@ +import { InvalidArgumentError } from "commander"; + +const UUID_REGEX = + /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i; +const DASHLESS_UUID_REGEX = /^[a-f0-9]{32}$/i; + +export function parseUuid(input: string) { + if (UUID_REGEX.test(input)) { + return input; + } + + if (DASHLESS_UUID_REGEX.test(input)) { + return ( + `${input.substring(0, 8)}-` + + `${input.substring(8, 12)}-` + + `${input.substring(12, 16)}-` + + `${input.substring(16, 20)}-` + + `${input.substring(20, 32)}` + ); + } + + throw new InvalidArgumentError( + "Expected UUID in form of xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx or 32 hexadecimal characters." + ); +} + +export function parseInteger(input: string) { + const number = parseInt(input); + if (isNaN(number)) { + throw new InvalidArgumentError("Expected an integer."); + } + return number; +} diff --git a/visual-js/visual-snapshots/src/index.ts b/visual-js/visual-snapshots/src/index.ts new file mode 100644 index 00000000..686d4ccc --- /dev/null +++ b/visual-js/visual-snapshots/src/index.ts @@ -0,0 +1,24 @@ +#! /usr/bin/env node + +import { Command } from "commander"; +import { pdfCommand } from "./commands/pdf.js"; +import { clientVersion } from "./version.js"; +import { loggerLevel } from "./commands/options.js"; +import pino from "pino"; +import { logger } from "./logger.js"; + +const program = new Command(); + +program + .name("visual-snapshots") + .description("Create visual snapshots of a document.") + .version(clientVersion) + .addOption(loggerLevel); + +program.addCommand(pdfCommand(clientVersion)); + +program.on("option:log", (level: pino.Level) => { + logger.level = level; +}); + +program.parse(); diff --git a/visual-js/visual-snapshots/src/logger.ts b/visual-js/visual-snapshots/src/logger.ts new file mode 100644 index 00000000..b9817137 --- /dev/null +++ b/visual-js/visual-snapshots/src/logger.ts @@ -0,0 +1,7 @@ +import { pino } from "pino"; + +export const logger = pino({ + transport: { + target: "pino-pretty", + }, +}); diff --git a/visual-js/visual-snapshots/src/utils/format.ts b/visual-js/visual-snapshots/src/utils/format.ts new file mode 100644 index 00000000..cc11b1c6 --- /dev/null +++ b/visual-js/visual-snapshots/src/utils/format.ts @@ -0,0 +1,13 @@ +/** + * Replaces all occurrences of keys in format of `{key}` in `value` with `data[key]`. + * + * If `key` does not exist in data, it is left as it is. + */ +export function formatString( + value: string, + data: Record +) { + return Object.entries(data) + .map(([k, v]) => [k, v.toString()] as const) + .reduce((current, [k, v]) => current.replaceAll(`{${k}}`, v), value); +} diff --git a/visual-js/visual-snapshots/src/utils/glob.ts b/visual-js/visual-snapshots/src/utils/glob.ts new file mode 100644 index 00000000..3e9d9f59 --- /dev/null +++ b/visual-js/visual-snapshots/src/utils/glob.ts @@ -0,0 +1,28 @@ +import { glob } from "glob"; +import fs from "fs/promises"; +import path from "path"; + +/** + * Returns all files matched by globs, or if path is a directory, matched by `dirGlob`. + * @param globOrDirs Globs or dirs to get files from. + * @param dirGlob Glob to append to directory path. + * @returns Matched files. + */ +export async function getFiles(globOrDirs: string[], dirGlob: string) { + const globs = await Promise.all( + globOrDirs.map((g) => + isDirectory(g).then((result) => (result ? path.join(g, dirGlob) : g)) + ) + ); + + return await glob(globs); +} + +async function isDirectory(path: string) { + try { + const stat = await fs.stat(path); + return stat.isDirectory(); + } catch { + return false; + } +} diff --git a/visual-js/visual-snapshots/src/utils/helpers.ts b/visual-js/visual-snapshots/src/utils/helpers.ts new file mode 100644 index 00000000..99860f7f --- /dev/null +++ b/visual-js/visual-snapshots/src/utils/helpers.ts @@ -0,0 +1,16 @@ +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * ESM helper for getting __filename. Pass `import.meta` to this function. + * @param meta `import.meta` + * @returns __filename equivalent + */ +export const __filename = (meta: ImportMeta) => fileURLToPath(meta.url); + +/** + * ESM helper for getting __dirname. Pass `import.meta` to this function. + * @param meta `import.meta` + * @returns __dirname equivalent + */ +export const __dirname = (meta: ImportMeta) => dirname(__filename(meta)); diff --git a/visual-js/visual-snapshots/src/utils/pool.ts b/visual-js/visual-snapshots/src/utils/pool.ts new file mode 100644 index 00000000..92c2048c --- /dev/null +++ b/visual-js/visual-snapshots/src/utils/pool.ts @@ -0,0 +1,59 @@ +import AsyncLock from "async-lock"; +import os from "os"; +import workerpool from "workerpool"; + +export type WorkerMethod< + M extends string, + Fn extends (...args: any[]) => unknown +> = { + readonly method: M; + readonly args: Fn extends (...args: infer A) => unknown ? A : never; +}; + +export async function execAll>( + pool: workerpool.Pool, + iterator: Iterator | AsyncIterator +) { + type ResultType = M extends WorkerMethod + ? Fn extends (...args: infer __) => infer R + ? R + : never + : never; + + const concurrency = pool.maxWorkers ?? os.cpus().length; + + return new Promise(async (resolve, reject) => { + const lock = new AsyncLock(); + const tasks: Promise[] = []; + + const enqueueNext = async () => { + void lock.acquire("queue", async () => { + try { + const { done, value } = await iterator.next(); + if (done) { + return finalize(); + } + + const { method, args } = value; + const task = pool.exec(method, args); + tasks.push(task as never); + task.then(enqueueNext).catch(onError); + } catch (err) { + onError(err); + } + }); + }; + + const finalize = () => { + Promise.all(tasks).then(resolve).catch(onError); + }; + + const onError = (err: unknown) => { + reject(err); + }; + + for (let i = 0; i < concurrency; i++) { + await enqueueNext(); + } + }); +} diff --git a/visual-js/visual-snapshots/src/version.ts b/visual-js/visual-snapshots/src/version.ts new file mode 100644 index 00000000..d58ed625 --- /dev/null +++ b/visual-js/visual-snapshots/src/version.ts @@ -0,0 +1 @@ +export const clientVersion = "PKG_VERSION"; diff --git a/visual-js/visual-snapshots/test/api/__snapshots__/visual-api.spec.ts.snap b/visual-js/visual-snapshots/test/api/__snapshots__/visual-api.spec.ts.snap new file mode 100644 index 00000000..63f88dd1 --- /dev/null +++ b/visual-js/visual-snapshots/test/api/__snapshots__/visual-api.spec.ts.snap @@ -0,0 +1,66 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`VisualSnapshots createBuild log output 1`] = ` +[ + { + "buildId": "foo", + "level": 30, + "msg": "Build created.", + }, +] +`; + +exports[`VisualSnapshots finishBuild log output when build status resolves to Equal 1`] = ` +[ + { + "buildId": "buildId", + "buildStatus": "EQUAL", + "errorCount": 1, + "level": 30, + "msg": "Build finished.", + "unapprovedCount": 2, + }, +] +`; + +exports[`VisualSnapshots finishBuild log output when build status resolves to Queued 1`] = ` +[ + { + "buildId": "buildId", + "buildStatus": "QUEUED", + "level": 30, + "msg": "Build finished but snapshots haven't been compared yet. Check the build status in a few moments.", + }, +] +`; + +exports[`VisualSnapshots finishBuild log output when build status resolves to Running 1`] = ` +[ + { + "buildId": "buildId", + "buildStatus": "RUNNING", + "level": 30, + "msg": "Build finished but snapshots haven't been compared yet. Check the build status in a few moments.", + }, +] +`; + +exports[`VisualSnapshots uploadImageAndCreateSnapshot log output 1`] = ` +[ + { + "buildId": "testBuildId", + "level": 30, + "msg": "Uploaded image to build.", + "uploadId": "uploadId", + }, + { + "buildId": "testBuildId", + "level": 30, + "msg": "Created a snapshot for build.", + "snapshotName": "testSnapshotName", + "suiteName": "testSuiteName", + "testName": "testTestName", + "uploadId": "uploadId", + }, +] +`; diff --git a/visual-js/visual-snapshots/test/api/visual-api.spec.ts b/visual-js/visual-snapshots/test/api/visual-api.spec.ts new file mode 100644 index 00000000..26bb4661 --- /dev/null +++ b/visual-js/visual-snapshots/test/api/visual-api.spec.ts @@ -0,0 +1,344 @@ +import { BuildStatus, DiffingMethod, VisualApi } from "@saucelabs/visual"; +import { + CreateBuildParams, + UploadSnapshotParams, + VisualSnapshotsApi, +} from "../../src/api/visual-snapshots-api.js"; +import { mockLogger } from "../helpers.js"; + +describe("VisualSnapshots", () => { + const { logger, logged, reset: resetLogger } = mockLogger(); + + const createBuildMock = jest.fn< + ReturnType, + Parameters + >(); + const finishBuildMock = jest.fn< + ReturnType, + Parameters + >(); + const buildStatusMock = jest.fn< + ReturnType, + Parameters + >(); + const uploadSnapshotMock = jest.fn< + ReturnType, + Parameters + >(); + const createSnapshotMock = jest.fn< + ReturnType, + Parameters + >(); + const visualApi = { + createBuild: createBuildMock, + finishBuild: finishBuildMock, + buildStatus: buildStatusMock, + uploadSnapshot: uploadSnapshotMock, + createSnapshot: createSnapshotMock, + } as never as VisualApi; + + beforeEach(() => { + createBuildMock.mockReset(); + finishBuildMock.mockReset(); + buildStatusMock.mockReset(); + resetLogger(); + }); + + describe("createBuild", () => { + it("should execute createBuild API with passed params", async () => { + const buildId = "foo"; + + createBuildMock.mockResolvedValue({ + id: buildId, + } as never); + + const api = new VisualSnapshotsApi(visualApi); + + const params: CreateBuildParams = { + buildName: "testBuildName", + branch: "testBranch", + defaultBranch: "testDefaultBranch", + project: "testProject", + customId: "testCustomId", + logger, + }; + + await api.createBuild(params); + + expect(createBuildMock).toHaveBeenCalledWith({ + name: params.buildName, + branch: params.branch, + defaultBranch: params.defaultBranch, + project: params.project, + customId: params.customId, + }); + }); + + it("should return build id from createBuild API", async () => { + const buildId = "foo"; + + createBuildMock.mockResolvedValue({ + id: buildId, + } as never); + + const api = new VisualSnapshotsApi(visualApi); + + const actual = await api.createBuild({ logger }); + + expect(actual).toEqual(buildId); + }); + + test("log output", async () => { + const buildId = "foo"; + + createBuildMock.mockResolvedValue({ + id: buildId, + } as never); + + const api = new VisualSnapshotsApi(visualApi); + + await api.createBuild({ logger }); + + expect(logged).toMatchSnapshot(); + }); + }); + + describe("finishBuild", () => { + it("should execute finishBuild API with passed buildId", async () => { + const buildId = "buildId"; + finishBuildMock.mockResolvedValue({ + buildId, + status: BuildStatus.Equal, + } as never); + buildStatusMock.mockResolvedValue({ + status: BuildStatus.Equal, + errorCount: 0, + unapprovedCount: 0, + url: "", + }); + + const api = new VisualSnapshotsApi(visualApi); + + await api.finishBuild({ buildId, logger }); + + expect(finishBuildMock).toHaveBeenCalledWith({ + uuid: buildId, + }); + }); + + it("should call buildStatus API with passed buildId when build status resolves to other than Running or Queued", async () => { + const buildId = "buildId"; + finishBuildMock.mockResolvedValue({ + buildId, + status: BuildStatus.Equal, + } as never); + buildStatusMock.mockResolvedValue({ + status: BuildStatus.Equal, + errorCount: 0, + unapprovedCount: 0, + url: "", + }); + + const visualApi = { + finishBuild: finishBuildMock, + buildStatus: buildStatusMock, + } as unknown as VisualApi; + + const api = new VisualSnapshotsApi(visualApi); + + await api.finishBuild({ buildId, logger }); + + expect(buildStatusMock).toHaveBeenCalledWith(buildId); + }); + + it("should not call buildStatus API when build status resolves to Running", async () => { + const buildId = "buildId"; + finishBuildMock.mockResolvedValue({ + buildId, + status: BuildStatus.Running, + } as never); + buildStatusMock.mockResolvedValue({ + status: BuildStatus.Running, + errorCount: 0, + unapprovedCount: 0, + url: "", + }); + + const api = new VisualSnapshotsApi(visualApi); + + await api.finishBuild({ buildId, logger }); + + expect(buildStatusMock).not.toHaveBeenCalled(); + }); + + it("should not call buildStatus API when build status resolves to Queued", async () => { + const buildId = "buildId"; + finishBuildMock.mockResolvedValue({ + buildId, + status: BuildStatus.Queued, + } as never); + buildStatusMock.mockResolvedValue({ + status: BuildStatus.Queued, + errorCount: 0, + unapprovedCount: 0, + url: "", + }); + const api = new VisualSnapshotsApi(visualApi); + + await api.finishBuild({ buildId, logger }); + + expect(buildStatusMock).not.toHaveBeenCalled(); + }); + + test("log output when build status resolves to Equal", async () => { + const buildId = "buildId"; + finishBuildMock.mockResolvedValue({ + buildId, + status: BuildStatus.Equal, + } as never); + buildStatusMock.mockResolvedValue({ + status: BuildStatus.Equal, + errorCount: 1, + unapprovedCount: 2, + url: "", + }); + + const api = new VisualSnapshotsApi(visualApi); + + await api.finishBuild({ buildId, logger }); + + expect(logged).toMatchSnapshot(); + }); + + test("log output when build status resolves to Running", async () => { + const buildId = "buildId"; + finishBuildMock.mockResolvedValue({ + buildId, + status: BuildStatus.Running, + } as never); + buildStatusMock.mockResolvedValue({ + status: BuildStatus.Running, + errorCount: 0, + unapprovedCount: 0, + url: "", + }); + + const api = new VisualSnapshotsApi(visualApi); + + await api.finishBuild({ buildId, logger }); + + expect(logged).toMatchSnapshot(); + }); + + test("log output when build status resolves to Queued", async () => { + const buildId = "buildId"; + finishBuildMock.mockResolvedValue({ + buildId, + status: BuildStatus.Queued, + } as never); + buildStatusMock.mockResolvedValue({ + status: BuildStatus.Queued, + errorCount: 0, + unapprovedCount: 0, + url: "", + }); + + const api = new VisualSnapshotsApi(visualApi); + + await api.finishBuild({ buildId, logger }); + + expect(logged).toMatchSnapshot(); + }); + }); + + describe("uploadImageAndCreateSnapshot", () => { + it("should call uploadSnapshot API", async () => { + const uploadId = "uploadId"; + uploadSnapshotMock.mockResolvedValue(uploadId); + + const api = new VisualSnapshotsApi(visualApi); + + const params: UploadSnapshotParams = { + buildId: "testBuildId", + snapshot: Buffer.from("snapshot"), + snapshotName: "testSnapshotName", + suiteName: "testSuiteName", + testName: "testTestName", + logger, + }; + + await api.uploadImageAndCreateSnapshot(params); + + expect(uploadSnapshotMock).toHaveBeenCalledWith({ + buildId: params.buildId, + image: { data: params.snapshot }, + }); + }); + + it("should call createSnapshot API", async () => { + const uploadId = "uploadId"; + uploadSnapshotMock.mockResolvedValue(uploadId); + + const api = new VisualSnapshotsApi(visualApi); + + const params: UploadSnapshotParams = { + buildId: "testBuildId", + snapshot: Buffer.from("snapshot"), + snapshotName: "testSnapshotName", + suiteName: "testSuiteName", + testName: "testTestName", + logger, + }; + + await api.uploadImageAndCreateSnapshot(params); + + expect(createSnapshotMock).toHaveBeenCalledWith({ + buildId: params.buildId, + uploadId, + name: params.snapshotName, + diffingMethod: DiffingMethod.Balanced, + testName: params.testName, + suiteName: params.suiteName, + }); + }); + + it("should return uploadId from uploadSnapshot API", async () => { + const uploadId = "uploadId"; + uploadSnapshotMock.mockResolvedValue(uploadId); + + const api = new VisualSnapshotsApi(visualApi); + + const params: UploadSnapshotParams = { + buildId: "testBuildId", + snapshot: Buffer.from("snapshot"), + snapshotName: "testSnapshotName", + suiteName: "testSuiteName", + testName: "testTestName", + logger, + }; + + const actual = await api.uploadImageAndCreateSnapshot(params); + expect(actual).toEqual(uploadId); + }); + + test("log output", async () => { + const uploadId = "uploadId"; + uploadSnapshotMock.mockResolvedValue(uploadId); + + const api = new VisualSnapshotsApi(visualApi); + + const params: UploadSnapshotParams = { + buildId: "testBuildId", + snapshot: Buffer.from("snapshot"), + snapshotName: "testSnapshotName", + suiteName: "testSuiteName", + testName: "testTestName", + logger, + }; + + await api.uploadImageAndCreateSnapshot(params); + + expect(logged).toMatchSnapshot(); + }); + }); +}); diff --git a/visual-js/visual-snapshots/test/api/visual-client.spec.ts b/visual-js/visual-snapshots/test/api/visual-client.spec.ts new file mode 100644 index 00000000..f303d9c7 --- /dev/null +++ b/visual-js/visual-snapshots/test/api/visual-client.spec.ts @@ -0,0 +1,27 @@ +import { visualApiInitializer } from "../../src/api/visual-client.js"; +import * as sauceVisual from "@saucelabs/visual"; + +jest.mock("@saucelabs/visual", () => { + return { + getApi: jest.fn(), + }; +}); + +describe("visual api client", () => { + test("visualApiInitializer", async () => { + const getApiSpy = jest.fn(); + const initializeVisualApi = visualApiInitializer(getApiSpy); + + const pkgVersion = "0.1.0"; + const params = { + user: "fake-username", + key: "fake-access-key", + region: "us-west-1", + } as sauceVisual.VisualConfig; + initializeVisualApi(params, pkgVersion); + + expect(getApiSpy).toHaveBeenCalledWith(params, { + userAgent: `visual-snapshots/${pkgVersion}`, + }); + }); +}); diff --git a/visual-js/visual-snapshots/test/api/worker/pdf-page-snapshot-uploader.spec.ts b/visual-js/visual-snapshots/test/api/worker/pdf-page-snapshot-uploader.spec.ts new file mode 100644 index 00000000..d774fd8e --- /dev/null +++ b/visual-js/visual-snapshots/test/api/worker/pdf-page-snapshot-uploader.spec.ts @@ -0,0 +1,87 @@ +import { PdfPageSnapshotUploader } from "../../../src/app/worker/pdf-page-snapshot-uploader.js"; +import { PdfFile } from "../../../src/app/pdf-file.js"; +import { PdfFileLoader } from "../../../src/app/pdf-file-loader.js"; +import { VisualSnapshotsApi } from "../../../src/api/visual-snapshots-api.js"; + +class TestPdfFile implements PdfFile { + constructor(public readonly path: string, public readonly pages: number) {} + + public async getPage(page: number): Promise { + return Buffer.from(`fake-image-buffer-${page}`); + } +} + +function createUploadId(content: Buffer) { + return `upload-id:${content.toString("utf-8")}`; +} + +describe("PdfPageSnapshotUploader", () => { + describe("uploadPageSnapshot", () => { + const consoleInfoSpy = jest + .spyOn(console, "info") + .mockImplementation(() => undefined); + + const uploadImageAndCreateSnapshot = jest.fn< + ReturnType, + Parameters + >(); + + const visualApiMock = { + uploadImageAndCreateSnapshot, + } as never as VisualSnapshotsApi; + + const files = [ + new TestPdfFile("file1.pdf", 4), + new TestPdfFile("file2.pdf", 5), + ]; + + const loadPdfFileMock = jest + .fn< + ReturnType, + Parameters + >() + .mockImplementation(async (path) => { + const file = files.find((f) => f.path === path); + if (!file) { + throw new Error("file not found"); + } + return file; + }); + + const pdfLoaderMock: PdfFileLoader = { + loadPdfFile: loadPdfFileMock, + }; + + const uploader = new PdfPageSnapshotUploader(visualApiMock, pdfLoaderMock); + + beforeEach(() => { + uploadImageAndCreateSnapshot.mockReset(); + uploadImageAndCreateSnapshot.mockImplementation(({ snapshot }) => + Promise.resolve(createUploadId(snapshot)) + ); + + consoleInfoSpy.mockReset(); + }); + + it("should call uploadImageAndCreateSnapshot", async () => { + await uploader.uploadPageSnapshot( + "build-id", + files[0].path, + 1, + "suiteName", + "testName-{filename}", + "snapshotName-{filename}-{page}" + ); + + expect(uploadImageAndCreateSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + buildId: "build-id", + snapshot: await files[0].getPage(1), + snapshotName: `snapshotName-${files[0].path}-1`, + testName: `testName-${files[0].path}`, + suiteName: "suiteName", + }) + ); + }); + }); +}); diff --git a/visual-js/visual-snapshots/test/api/worker/single-cached-pdf-file-loader.spec.ts b/visual-js/visual-snapshots/test/api/worker/single-cached-pdf-file-loader.spec.ts new file mode 100644 index 00000000..0875e5d7 --- /dev/null +++ b/visual-js/visual-snapshots/test/api/worker/single-cached-pdf-file-loader.spec.ts @@ -0,0 +1,76 @@ +import { SingleCachedPdfFileLoader } from "../../../src/app/single-cached-pdf-file-loader.js"; +import { PdfFileLoader } from "../../../src/app/pdf-file-loader.js"; + +describe("SingleCachedPdfFileLoader", () => { + function createUnderlyingLoader() { + const loadPdfFile = jest + .fn< + ReturnType, + Parameters + >() + .mockResolvedValue({} as never); + + const underlyingLoader: PdfFileLoader = { + loadPdfFile, + }; + + return { loadPdfFile, underlyingLoader }; + } + + it("should call underlying loader with full path", async () => { + const { loadPdfFile, underlyingLoader } = createUnderlyingLoader(); + + const path = "a/b/c/file.pdf"; + + const loader = new SingleCachedPdfFileLoader(underlyingLoader); + await loader.loadPdfFile(path); + + expect(loadPdfFile).toHaveBeenCalledWith(path); + }); + + it("should load file once if loaded twice with the same path", async () => { + const { loadPdfFile, underlyingLoader } = createUnderlyingLoader(); + + const path = "file.pdf"; + + const loader = new SingleCachedPdfFileLoader(underlyingLoader); + + await loader.loadPdfFile(path); + await loader.loadPdfFile(path); + + expect(loadPdfFile).toHaveBeenCalledTimes(1); + }); + + it("should load other files if path changes", async () => { + const { loadPdfFile, underlyingLoader } = createUnderlyingLoader(); + + const path1 = "file1.pdf"; + const path2 = "file2.pdf"; + + const loader = new SingleCachedPdfFileLoader(underlyingLoader); + + await loader.loadPdfFile(path1); + await loader.loadPdfFile(path2); + + expect(loadPdfFile).toHaveBeenCalledWith(path1); + expect(loadPdfFile).toHaveBeenCalledWith(path2); + }); + + it("should reload first files if other file is loaded", async () => { + const { loadPdfFile, underlyingLoader } = createUnderlyingLoader(); + + const path1 = "file1.pdf"; + const path2 = "file2.pdf"; + + const loader = new SingleCachedPdfFileLoader(underlyingLoader); + + await loader.loadPdfFile(path1); + await loader.loadPdfFile(path1); + await loader.loadPdfFile(path2); + await loader.loadPdfFile(path2); + await loader.loadPdfFile(path1); + await loader.loadPdfFile(path1); + + expect(loadPdfFile.mock.calls).toEqual([[path1], [path2], [path1]]); + }); +}); diff --git a/visual-js/visual-snapshots/test/app/pdf-file-loader.spec.ts b/visual-js/visual-snapshots/test/app/pdf-file-loader.spec.ts new file mode 100644 index 00000000..a8c5b60f --- /dev/null +++ b/visual-js/visual-snapshots/test/app/pdf-file-loader.spec.ts @@ -0,0 +1,61 @@ +import { LibPdfFileLoader } from "../../src/app/pdf-file-loader.js"; +import { __dirname } from "../helpers.js"; + +describe("LibPdfFileLoader", () => { + it("should call library with path and scale", async () => { + const lib = jest.fn().mockResolvedValue({ + length: 10, + getPage: jest.fn(), + }); + + const loader = new LibPdfFileLoader(lib); + + await loader.loadPdfFile("file.pdf"); + + expect(lib).toHaveBeenCalledWith("file.pdf", { scale: 1 }); + }); + + it("should return LoadedPdfFile with same path", async () => { + const lib = jest.fn().mockResolvedValue({ + length: 10, + getPage: jest.fn(), + }); + const loader = new LibPdfFileLoader(lib); + const path = "path/to/file.pdf"; + + const file = await loader.loadPdfFile(path); + + expect(file.path).toEqual(path); + }); + + it("should return LoadedPdfFile with same length", async () => { + const length = 10; + + const lib = jest.fn().mockResolvedValue({ + length, + getPage: jest.fn(), + }); + + const loader = new LibPdfFileLoader(lib); + + const file = await loader.loadPdfFile("file.pdf"); + + expect(file.pages).toEqual(length); + }); + + it("should return LoadedPdfFile with getPage calling lib's getPage", async () => { + const getPage = jest.fn(); + + const lib = jest.fn().mockResolvedValue({ + length: 10, + getPage, + }); + + const loader = new LibPdfFileLoader(lib); + + const file = await loader.loadPdfFile("file.pdf"); + await file.getPage(5); + + expect(getPage).toHaveBeenCalledWith(5); + }); +}); diff --git a/visual-js/visual-snapshots/test/app/pdf-handler.spec.ts b/visual-js/visual-snapshots/test/app/pdf-handler.spec.ts new file mode 100644 index 00000000..be0738d9 --- /dev/null +++ b/visual-js/visual-snapshots/test/app/pdf-handler.spec.ts @@ -0,0 +1,171 @@ +import { VisualSnapshotsApi } from "../../src/api/visual-snapshots-api.js"; +import { PdfSnapshotUploader } from "../../src/app/pdf-files-snapshot-uploader.js"; +import { + PdfCommandHandler, + PdfCommandParams, +} from "../../src/app/pdf-handler.js"; +import { __dirname } from "../helpers.js"; +import path from "path"; + +describe("pdf-handler", () => { + const createBuildMock = jest.fn< + ReturnType, + Parameters + >(); + const finishBuildMock = jest.fn< + ReturnType, + Parameters + >(); + + const visualSnapshotsApi = { + createBuild: createBuildMock, + finishBuild: finishBuildMock, + } as never as VisualSnapshotsApi; + + const uploadSnapshotsMock = jest.fn< + ReturnType, + Parameters + >(); + + const pdfSnapshotUploaderMock = { + uploadSnapshots: uploadSnapshotsMock, + } as never as PdfSnapshotUploader; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe("creating build", () => { + it("should create a build when buildId is not passed", async () => { + const handler = new PdfCommandHandler( + visualSnapshotsApi, + pdfSnapshotUploaderMock + ); + + const params: PdfCommandParams = { + concurrency: 1, + }; + + await handler.handle( + [path.join(__dirname(import.meta), "../files/1.pdf")], + params + ); + + expect(createBuildMock).toHaveBeenCalledWith(params); + }); + + it("should not create a build when buildId is passed", async () => { + const handler = new PdfCommandHandler( + visualSnapshotsApi, + pdfSnapshotUploaderMock + ); + + const params: PdfCommandParams = { + buildId: "buildId", + concurrency: 1, + }; + + await handler.handle( + [path.join(__dirname(import.meta), "../files/1.pdf")], + params + ); + + expect(createBuildMock).not.toHaveBeenCalled(); + }); + }); + + describe("uploading snapshots", () => { + it("should call uploadSnapshots with created build ID", async () => { + const handler = new PdfCommandHandler( + visualSnapshotsApi, + pdfSnapshotUploaderMock + ); + + const buildId = "buildId"; + + const params: PdfCommandParams = { + concurrency: 1, + }; + + createBuildMock.mockResolvedValue(buildId); + + await handler.handle( + [path.join(__dirname(import.meta), "../files/1.pdf")], + params + ); + + expect(uploadSnapshotsMock).toHaveBeenCalledWith( + expect.objectContaining({ + buildId, + }) + ); + }); + + it("should call uploadSnapshots with provided build ID", async () => { + const handler = new PdfCommandHandler( + visualSnapshotsApi, + pdfSnapshotUploaderMock + ); + + const buildId = "buildId"; + + const params: PdfCommandParams = { + buildId, + concurrency: 1, + }; + + await handler.handle( + [path.join(__dirname(import.meta), "../files/1.pdf")], + params + ); + + expect(uploadSnapshotsMock).toHaveBeenCalledWith( + expect.objectContaining({ + buildId, + }) + ); + }); + }); + + describe("finishing build", () => { + it("should finish build when buildId is not passed, using created build ID", async () => { + const buildId = "buildId"; + createBuildMock.mockResolvedValue(buildId); + + const handler = new PdfCommandHandler( + visualSnapshotsApi, + pdfSnapshotUploaderMock + ); + + const params: PdfCommandParams = { + concurrency: 1, + }; + + await handler.handle( + [path.join(__dirname(import.meta), "../files/1.pdf")], + params + ); + + expect(finishBuildMock).toHaveBeenCalledWith({ buildId }); + }); + + it("should not finish build when buildId is passed", async () => { + const handler = new PdfCommandHandler( + visualSnapshotsApi, + pdfSnapshotUploaderMock + ); + + const params: PdfCommandParams = { + concurrency: 1, + buildId: "buildId", + }; + + await handler.handle( + [path.join(__dirname(import.meta), "../files/1.pdf")], + params + ); + + expect(finishBuildMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/visual-js/visual-snapshots/test/files/test.pdf b/visual-js/visual-snapshots/test/files/test.pdf new file mode 100644 index 00000000..580a9a8c Binary files /dev/null and b/visual-js/visual-snapshots/test/files/test.pdf differ diff --git a/visual-js/visual-snapshots/test/helpers.ts b/visual-js/visual-snapshots/test/helpers.ts new file mode 100644 index 00000000..7a1569d8 --- /dev/null +++ b/visual-js/visual-snapshots/test/helpers.ts @@ -0,0 +1,40 @@ +import { dirname } from "node:path"; +import { Writable } from "node:stream"; +import { fileURLToPath } from "node:url"; +import { pino } from "pino"; + +/** + * ESM helper for getting __filename. Pass `import.meta` to this function. + * @param meta `import.meta` + * @returns __filename equivalent + */ +export const __filename = (meta: ImportMeta) => fileURLToPath(meta.url); + +/** + * ESM helper for getting __dirname. Pass `import.meta` to this function. + * @param meta `import.meta` + * @returns __dirname equivalent + */ +export const __dirname = (meta: ImportMeta) => dirname(__filename(meta)); + +export function mockLogger() { + const logged: object[] = []; + + const stream = new Writable({ + write(chunk, _encoding, callback) { + const message = JSON.parse(chunk.toString("utf-8")); + delete message.time; + delete message.pid; + delete message.hostname; + logged.push(message); + callback(); + }, + }); + + function reset() { + logged.length = 0; + } + + const logger = pino(stream); + return { logger, logged, reset }; +} diff --git a/visual-js/visual-snapshots/test/utils/format.spec.ts b/visual-js/visual-snapshots/test/utils/format.spec.ts new file mode 100644 index 00000000..2c08769e --- /dev/null +++ b/visual-js/visual-snapshots/test/utils/format.spec.ts @@ -0,0 +1,19 @@ +import { formatString } from "../../src/utils/format.js"; + +describe("formatString", () => { + it("should replace all occurences of key with data from object", () => { + const value = "foo {foo} bar {foo} {bar}"; + const data = { foo: "xyz", bar: 123 }; + const expected = "foo xyz bar xyz 123"; + + expect(formatString(value, data)).toEqual(expected); + }); + + it("should not replace keys that do not exist in data", () => { + const value = "foo {foo}"; + const data = { bar: 123 }; + const expected = "foo {foo}"; + + expect(formatString(value, data)).toEqual(expected); + }); +}); diff --git a/visual-js/visual-snapshots/test/utils/glob.spec.ts b/visual-js/visual-snapshots/test/utils/glob.spec.ts new file mode 100644 index 00000000..15787e9a --- /dev/null +++ b/visual-js/visual-snapshots/test/utils/glob.spec.ts @@ -0,0 +1,60 @@ +import { getFiles } from "../../src/utils/glob.js"; +import path from "path"; +import { __dirname, __filename } from "../helpers.js"; + +describe("getFiles", () => { + function normalize(paths: string[]) { + return paths.map((p) => path.resolve(p)).sort((a, b) => a.localeCompare(b)); + } + + it("should return a file", async () => { + const input = ["./src/index.ts"]; + const expected = normalize(input); + + const result = await getFiles(input, "*"); + expect(normalize(result)).toEqual(expected); + }); + + it("should return multiple files", async () => { + const input = ["./src/index.ts", __filename(import.meta)]; + const expected = normalize(input); + + const actual = await getFiles(input, "*"); + expect(normalize(actual)).toEqual(expected); + }); + + it("should return files matched by glob", async () => { + const input = [path.join(__dirname(import.meta), "*.spec.ts")]; + const expected = normalize([__filename(import.meta)]); + + const actual = await getFiles(input, "*"); + expect(normalize(actual)).toEqual(expect.arrayContaining(expected)); + }); + + it("should return files in directory matched by dir glob", async () => { + const input = [__dirname(import.meta)]; + const expected = normalize([__filename(import.meta)]); + + const actual = await getFiles(input, "*.spec.ts"); + expect(normalize(actual)).toEqual(expect.arrayContaining(expected)); + }); + + it("should not return non-existing files", async () => { + const input = [ + __filename(import.meta), + __filename(import.meta) + ".not-existing", + ]; + const expected = normalize([__filename(import.meta)]); + + const result = await getFiles(input, "*"); + expect(normalize(result)).toEqual(expected); + }); + + it("should not return files from not existing dirs", async () => { + const input = [__dirname(import.meta) + ".not-existing"]; + const expected: string[] = []; + + const result = await getFiles(input, "*"); + expect(normalize(result)).toEqual(expected); + }); +}); diff --git a/visual-js/visual-snapshots/test/utils/pool.spec.ts b/visual-js/visual-snapshots/test/utils/pool.spec.ts new file mode 100644 index 00000000..e6a20c5a --- /dev/null +++ b/visual-js/visual-snapshots/test/utils/pool.spec.ts @@ -0,0 +1,242 @@ +import workerpool, { WorkerPoolOptions } from "workerpool"; +import path from "path"; +import { execAll, WorkerMethod } from "../../src/utils/pool.js"; +import { __dirname } from "../helpers.js"; + +function* workers( + elements: number[] +): Generator number>> { + for (const element of elements) { + yield { + method: "elementWorker", + args: [element], + }; + } +} + +function* workersThrowingGenerator( + elements: number[], + index: number, + message: string +): Generator number>> { + for (let i = 0; i < elements.length; i++) { + if (i === index) { + throw new Error(message); + } + + yield { + method: "elementWorker", + args: [elements[i]], + }; + } +} + +function* throwingWorkersGenerator( + elements: number[], + index: number, + message: string +): Generator< + WorkerMethod< + "elementWorker" | "throwingElementWorker", + ((i: number) => number) | ((message: string) => never) + > +> { + for (let i = 0; i < elements.length; i++) { + if (i === index) { + yield { + method: "throwingElementWorker", + args: [message], + }; + } else { + yield { + method: "elementWorker", + args: [elements[i]], + }; + } + } +} + +function createPool(opts?: WorkerPoolOptions) { + return workerpool.pool( + path.join(__dirname(import.meta), "./pool.worker.js"), + opts + ); +} + +async function usePool( + fn: (pool: workerpool.Pool) => Promise, + opts?: WorkerPoolOptions +) { + const pool = createPool(opts); + try { + return await fn(pool); + } finally { + pool.terminate(); + } +} + +function range(count: number) { + return [...new Array(count)].map((_, i) => i); +} + +describe("execAll", () => { + it("should enqueue and complete all elements with concurrency greater than element count", async () => { + const elements = range(10); + + await usePool( + async (pool) => { + const actual = await execAll(pool, workers(elements)); + expect(actual.sort((a, b) => a - b)).toEqual(elements); + }, + { maxWorkers: 20 } + ); + }); + + it("should enqueue and complete all elements with concurrency less than element count", async () => { + const elements = range(100); + + await usePool( + async (pool) => { + const actual = await execAll(pool, workers(elements)); + expect(actual.sort((a, b) => a - b)).toEqual(elements); + }, + { maxWorkers: 10 } + ); + }); + + it("should reject if first worker creator throws", async () => { + const elements = range(100); + + await usePool( + async (pool) => { + const error = new Error("test"); + const promise = execAll( + pool, + workersThrowingGenerator(elements, 0, "test") + ); + + await expect(promise).rejects.toThrow(error); + }, + { maxWorkers: 1 } + ); + }); + + it("should reject if n-th worker creator throws where n < concurrency", async () => { + const elements = range(100); + + await usePool( + async (pool) => { + const error = new Error("test"); + const promise = execAll( + pool, + workersThrowingGenerator(elements, 5, "test") + ); + + await expect(promise).rejects.toThrow(error); + }, + { maxWorkers: 10 } + ); + }); + + it("should reject if n-th worker creator throws where n > concurrency", async () => { + const elements = range(100); + + await usePool( + async (pool) => { + const error = new Error("test"); + const promise = execAll( + pool, + workersThrowingGenerator(elements, 90, "test") + ); + + await expect(promise).rejects.toThrow(error); + }, + { maxWorkers: 1 } + ); + }); + + it("should reject if last worker creator throws", async () => { + const elements = range(100); + + await usePool( + async (pool) => { + const error = new Error("test"); + const promise = execAll( + pool, + workersThrowingGenerator(elements, elements.length - 1, "test") + ); + + await expect(promise).rejects.toThrow(error); + }, + { maxWorkers: 1 } + ); + }); + + it("should reject if first worker throws", async () => { + const elements = range(100); + + await usePool( + async (pool) => { + const error = new Error("test"); + const promise = execAll( + pool, + workersThrowingGenerator(elements, 0, "test") + ); + + await expect(promise).rejects.toThrow(error); + }, + { maxWorkers: 1 } + ); + }); + + it("should reject if n-th worker throws where n < concurrency", async () => { + const elements = range(100); + + await usePool( + async (pool) => { + const error = new Error("test"); + const promise = execAll( + pool, + throwingWorkersGenerator(elements, 5, "test") + ); + + await expect(promise).rejects.toThrow(error); + }, + { maxWorkers: 10 } + ); + }); + + it("should reject if n-th worker throws where n > concurrency", async () => { + const elements = range(100); + + await usePool( + async (pool) => { + const error = new Error("test"); + const promise = execAll( + pool, + throwingWorkersGenerator(elements, 90, "test") + ); + + await expect(promise).rejects.toThrow(error); + }, + { maxWorkers: 1 } + ); + }); + + it("should reject if last worker throws", async () => { + const elements = range(100); + + await usePool( + async (pool) => { + const error = new Error("test"); + const promise = execAll( + pool, + throwingWorkersGenerator(elements, elements.length - 1, "test") + ); + + await expect(promise).rejects.toThrow(error); + }, + { maxWorkers: 1 } + ); + }); +}); diff --git a/visual-js/visual-snapshots/test/utils/pool.worker.js b/visual-js/visual-snapshots/test/utils/pool.worker.js new file mode 100644 index 00000000..a9fa9a65 --- /dev/null +++ b/visual-js/visual-snapshots/test/utils/pool.worker.js @@ -0,0 +1,11 @@ +import workerpool from "workerpool"; + +function elementWorker(i) { + return i; +} + +function throwingElementWorker(message) { + throw new Error(message); +} + +workerpool.worker({ elementWorker, throwingElementWorker }); diff --git a/visual-js/visual-snapshots/tsconfig.json b/visual-js/visual-snapshots/tsconfig.json new file mode 100644 index 00000000..7f8872a6 --- /dev/null +++ b/visual-js/visual-snapshots/tsconfig.json @@ -0,0 +1,12 @@ +// Builds source files and typechecks test files. +{ + "files": [], + "references": [ + { + "path": "./tsconfig.src.json" + }, + { + "path": "./tsconfig.test.json" + } + ] +} diff --git a/visual-js/visual-snapshots/tsconfig.src.json b/visual-js/visual-snapshots/tsconfig.src.json new file mode 100644 index 00000000..dcf5ddd9 --- /dev/null +++ b/visual-js/visual-snapshots/tsconfig.src.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + "module": "NodeNext", /* Specify what module code is generated. */ + "moduleResolution": "nodenext", /* Specify how TypeScript looks up a file from a given module specifier. */ + "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + "outDir": "./lib", /* Specify an output folder for all emitted files. */ + "removeComments": true, /* Disable emitting comments. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + "strict": true, /* Enable all strict type-checking options. */ + "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */, + "tsBuildInfoFile": "./tsconfig.src.tsbuildinfo" + }, + "include": ["./src"] +} diff --git a/visual-js/visual-snapshots/tsconfig.test.json b/visual-js/visual-snapshots/tsconfig.test.json new file mode 100644 index 00000000..61fc1172 --- /dev/null +++ b/visual-js/visual-snapshots/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.src.json", + "compilerOptions": { + "noEmit": true, // Do not emit built test files, only typecheck them + "composite": true // Required for reference in tsconfig.build.json + }, + "include": ["./src", "./test"] +}