diff --git a/kits/.gitignore b/kits/.gitignore index a0f26bba1..4d9946868 100644 --- a/kits/.gitignore +++ b/kits/.gitignore @@ -9,6 +9,8 @@ dist/ # Env / secrets .env .env.* +# Emulator test apps commit their params: demo values, never secrets. +!*/tests/emulator/app/.env # Test / coverage coverage/ diff --git a/kits/storage-resize-images/firebase.json b/kits/storage-resize-images/firebase.json new file mode 100644 index 000000000..33e58a436 --- /dev/null +++ b/kits/storage-resize-images/firebase.json @@ -0,0 +1,12 @@ +{ + "functions": [ + { "source": "tests/emulator/app", "codebase": "storage-resize-images" } + ], + "storage": { "rules": "tests/emulator/storage.rules" }, + "emulators": { + "storage": { "port": 9199 }, + "functions": { "port": 5001 }, + "eventarc": { "port": 9299 }, + "ui": { "enabled": false } + } +} diff --git a/kits/storage-resize-images/package-lock.json b/kits/storage-resize-images/package-lock.json index e39865549..8a29ddc8f 100644 --- a/kits/storage-resize-images/package-lock.json +++ b/kits/storage-resize-images/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@firebase/storage-resize-images", - "version": "0.1.0", + "name": "@firebase-function-kits/storage-resize-images", + "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@firebase/storage-resize-images", - "version": "0.1.0", + "name": "@firebase-function-kits/storage-resize-images", + "version": "0.0.0", "license": "Apache-2.0", "dependencies": { "@genkit-ai/vertexai": "^1.2.0", @@ -19,6 +19,10 @@ "sharp": "^0.34.5", "uuid": "^11.0.5" }, + "devDependencies": { + "@genkit-ai/core": "^1.2.0", + "vitest": "^4.1.10" + }, "engines": { "node": ">=22" } @@ -2079,6 +2083,13 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@js-sdsl/ordered-map": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", @@ -3484,6 +3495,16 @@ "@opentelemetry/api": "^1.1.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3551,6 +3572,269 @@ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", "license": "BSD-3-Clause" }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@so-ric/colorspace": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", @@ -3562,6 +3846,13 @@ "text-hex": "1.0.x" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tootallnate/once": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", @@ -3604,6 +3895,17 @@ "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -3622,6 +3924,20 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", @@ -3856,6 +4172,119 @@ "license": "MIT", "optional": true }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -4025,8 +4454,18 @@ "node": ">=8" } }, - "node_modules/async": { - "version": "3.2.6", + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async": { + "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT", @@ -4197,6 +4636,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cjs-module-lexer": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", @@ -4307,6 +4756,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -4543,6 +4999,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -4585,6 +5048,16 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -4609,6 +5082,16 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -4740,6 +5223,24 @@ "node": ">=0.8.0" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", @@ -5023,6 +5524,21 @@ "node": ">= 0.8" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5771,6 +6287,279 @@ "license": "MIT", "optional": true }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/limiter": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", @@ -5889,6 +6678,16 @@ "lru-cache": "6.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6031,6 +6830,25 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -6114,6 +6932,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -6362,6 +7194,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", @@ -6396,6 +7235,55 @@ "node": ">=4" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -6678,6 +7566,39 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rolldown": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.144.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -6938,6 +7859,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -6960,6 +7888,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -6970,6 +7908,13 @@ "node": "*" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -6979,6 +7924,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-events": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", @@ -7174,6 +8126,50 @@ "license": "MIT", "optional": true }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -7319,6 +8315,174 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -7383,6 +8547,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/winston": { "version": "3.19.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", diff --git a/kits/storage-resize-images/package.json b/kits/storage-resize-images/package.json index efbd6d18a..da51a4b56 100644 --- a/kits/storage-resize-images/package.json +++ b/kits/storage-resize-images/package.json @@ -21,7 +21,8 @@ "scripts": { "build": "tsc -b && node -e \"require('fs').copyFileSync('src/placeholder.png','lib/placeholder.png')\"", "clean": "tsc -b --clean", - "test": "echo \"Warning: tests are not implemented for this package\"", + "test": "vitest run", + "test:emulator": "npm run build && firebase emulators:exec --only functions,storage --project demo-test \"vitest run --config vitest.emulator.config.ts\"", "deploy": "pnpm build && firebase deploy --only functions", "serve": "firebase emulators:start --only functions" }, @@ -35,5 +36,9 @@ "p-queue": "^6.6.2", "sharp": "^0.34.5", "uuid": "^11.0.5" + }, + "devDependencies": { + "@genkit-ai/core": "^1.2.0", + "vitest": "^4.1.10" } } \ No newline at end of file diff --git a/kits/storage-resize-images/tests/config.test.ts b/kits/storage-resize-images/tests/config.test.ts new file mode 100644 index 000000000..bd6c49958 --- /dev/null +++ b/kits/storage-resize-images/tests/config.test.ts @@ -0,0 +1,120 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Expression } from "firebase-functions/params"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { + CONFIG_EXPRESSIONS, + configFromEnv, + validatePathListsFromEnv, +} from "../src/config"; +import { DELETE_IMAGE, resolveResizeImagesConfig } from "../src/export-config"; + +const cel = (value: unknown): string => + value instanceof Expression ? value.toCEL() : String(value); + +describe("configFromEnv", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + test("maps the params a deployed instance sets", () => { + vi.stubEnv("IMG_BUCKET", "demo.appspot.com"); + vi.stubEnv("IMG_SIZES", "200x200,400x400"); + vi.stubEnv("DELETE_ORIGINAL_FILE", "on_success"); + // Firebase writes list params into the environment as JSON. + vi.stubEnv("IMAGE_TYPE", '["jpeg","webp"]'); + vi.stubEnv("RESIZED_IMAGES_PATH", "thumbs"); + vi.stubEnv("CONTENT_FILTER_LEVEL", "BLOCK_ONLY_HIGH"); + vi.stubEnv("FUNCTION_MEMORY", "2048"); + vi.stubEnv("FUNCTION_REGION", "europe-west1"); + + const config = configFromEnv(); + + expect(config.bucket).toBe("demo.appspot.com"); + expect(config.sizes).toBe("200x200,400x400"); + expect(config.deleteOriginal).toBe("on_success"); + expect(config.imageTypes).toEqual(["jpeg", "webp"]); + expect(config.resizedImagesPath).toBe("thumbs"); + expect(config.contentFilterLevel).toBe("BLOCK_ONLY_HIGH"); + expect(config.memory).toBe(2048); + expect(config.region).toBe("europe-west1"); + }); + + test("resolves into the config the handlers use", () => { + vi.stubEnv("IMG_BUCKET", "demo.appspot.com"); + vi.stubEnv("IMG_SIZES", "200x200"); + vi.stubEnv("DELETE_ORIGINAL_FILE", "true"); + vi.stubEnv("IMAGE_TYPE", '["false"]'); + vi.stubEnv("CONTENT_FILTER_LEVEL", "OFF"); + vi.stubEnv("FUNCTION_MEMORY", "1024"); + + const resolved = resolveResizeImagesConfig(configFromEnv()); + + expect(resolved.imageSizes).toEqual(["200x200"]); + expect(resolved.deleteOriginalFile).toBe(DELETE_IMAGE.always); + expect(resolved.contentFilterLevel).toBeNull(); + expect(resolved.memory).toBe("1GiB"); + }); + + test("leaves unset optional strings undefined rather than empty", () => { + vi.stubEnv("IMG_BUCKET", "demo.appspot.com"); + vi.stubEnv("IMAGE_TYPE", '["false"]'); + + const config = configFromEnv(); + + expect(config.resizedImagesPath).toBeUndefined(); + expect(config.failedImagesPath).toBeUndefined(); + expect(config.includePathList).toBeUndefined(); + expect(config.excludePathList).toBeUndefined(); + expect(config.cacheControlHeader).toBeUndefined(); + expect(config.customFilterPrompt).toBeUndefined(); + expect(config.placeholderImagePath).toBeUndefined(); + }); +}); + +describe("validatePathListsFromEnv", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + test("rejects a relative include path before the function deploys", () => { + vi.stubEnv("INCLUDE_PATH_LIST", "relative/path"); + + expect(() => validatePathListsFromEnv()).toThrow("Invalid includePathList"); + }); + + test("rejects a relative exclude path before the function deploys", () => { + vi.stubEnv("EXCLUDE_PATH_LIST", "relative/path"); + + expect(() => validatePathListsFromEnv()).toThrow("Invalid excludePathList"); + }); + + test("accepts absolute paths", () => { + vi.stubEnv("INCLUDE_PATH_LIST", "/users/avatars,/design/pictures"); + vi.stubEnv("EXCLUDE_PATH_LIST", "/users/avatars/thumbs"); + + expect(() => validatePathListsFromEnv()).not.toThrow(); + }); +}); + +describe("CONFIG_EXPRESSIONS", () => { + test("keeps the bucket and memory as params for the discovery manifest", () => { + expect(cel(CONFIG_EXPRESSIONS.bucket)).toBe("{{ params.IMG_BUCKET }}"); + expect(cel(CONFIG_EXPRESSIONS.memory)).toBe("{{ params.FUNCTION_MEMORY }}"); + }); +}); diff --git a/kits/storage-resize-images/tests/content-filter.test.ts b/kits/storage-resize-images/tests/content-filter.test.ts new file mode 100644 index 000000000..c787bb7c9 --- /dev/null +++ b/kits/storage-resize-images/tests/content-filter.test.ts @@ -0,0 +1,280 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as path from "node:path"; +import { ValidationError } from "@genkit-ai/core/schema"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const generate = vi.fn(); + +vi.mock("genkit", () => ({ + genkit: vi.fn(() => ({ generate })), + z: { + object: vi.fn().mockReturnThis(), + string: vi.fn().mockReturnThis(), + }, +})); + +vi.mock("@genkit-ai/vertexai", () => ({ + default: vi.fn(), + gemini: vi.fn((version: string) => ({ name: `vertexai/${version}` })), +})); + +vi.mock("../src/logs"); + +import { genkit } from "genkit"; +import { checkImageContent } from "../src/content-filter"; +import * as log from "../src/logs"; + +const imagePath = path.join(__dirname, "test-image.png"); +const LOCATION = "us-central1"; + +/** The JSON schema genkit emits for the custom-prompt output shape. */ +const moderationSchema = { + type: "object", + properties: { response: { type: "string" } }, + required: ["response"], + additionalProperties: true, + $schema: "http://json-schema.org/draft-07/schema#", +}; + +describe("checkImageContent", () => { + beforeEach(() => { + vi.clearAllMocks(); + generate.mockResolvedValue({ output: { response: "yes" } }); + }); + + test("returns true when the filter level is off and there is no custom prompt", async () => { + const result = await checkImageContent( + imagePath, + null, + null, + "image/png", + LOCATION + ); + + expect(genkit).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + + test("throws without a function region to reach Vertex AI in", async () => { + await expect( + checkImageContent( + imagePath, + "BLOCK_MEDIUM_AND_ABOVE", + null, + "image/png", + undefined + ) + ).rejects.toThrow("FUNCTION_REGION is required"); + }); + + test("returns true when the API response is positive", async () => { + const result = await checkImageContent( + imagePath, + "BLOCK_MEDIUM_AND_ABOVE", + null, + "image/png", + LOCATION + ); + + expect(genkit).toHaveBeenCalled(); + expect(generate).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + test("returns false when a custom prompt answers yes", async () => { + const result = await checkImageContent( + imagePath, + "BLOCK_LOW_AND_ABOVE", + "Is this image containing inappropriate content?", + "image/png", + LOCATION + ); + + expect(log.customFilterBlocked).toHaveBeenCalled(); + expect(result).toBe(false); + }); + + test("returns false when the API throws a blocked finish reason", async () => { + generate.mockRejectedValue({ + detail: { response: { finishReason: "blocked" } }, + }); + + const result = await checkImageContent( + imagePath, + "BLOCK_MEDIUM_AND_ABOVE", + null, + "image/png", + LOCATION, + 1 + ); + + expect(log.contentFilterBlocked).toHaveBeenCalled(); + expect(result).toBe(false); + }); + + test("rethrows when a generic error occurs", async () => { + generate.mockRejectedValue(new Error("API failure")); + + await expect( + checkImageContent( + imagePath, + "BLOCK_MEDIUM_AND_ABOVE", + null, + "image/png", + LOCATION, + 1 + ) + ).rejects.toThrow("API failure"); + }); + + test("passes the default prompt and the configured threshold to generate", async () => { + await checkImageContent( + imagePath, + "BLOCK_MEDIUM_AND_ABOVE", + null, + "image/png", + LOCATION + ); + + const [callArgs] = generate.mock.calls[0]; + expect(callArgs.model?.name ?? callArgs.model).toBe( + "vertexai/gemini-2.5-flash" + ); + expect(callArgs.messages[0].role).toBe("user"); + expect(callArgs.messages[0].content[0].text).toBe( + "Is this image appropriate?" + ); + expect(callArgs.messages[0].content[1].media).toBeDefined(); + expect(callArgs.config.temperature).toBe(0.1); + expect(callArgs.config.maxOutputTokens).toBe(1); + expect(callArgs.config.safetySettings).toEqual( + expect.arrayContaining([ + { + category: "HARM_CATEGORY_HATE_SPEECH", + threshold: "BLOCK_MEDIUM_AND_ABOVE", + }, + ]) + ); + }); + + test("passes a custom prompt and a larger token budget to generate", async () => { + const customPrompt = "Does this image contain violent content?"; + + await checkImageContent( + imagePath, + "BLOCK_MEDIUM_AND_ABOVE", + customPrompt, + "image/png", + LOCATION + ); + + const [callArgs] = generate.mock.calls[0]; + expect(callArgs.messages[0].content[0].text).toContain(customPrompt); + expect(callArgs.config.maxOutputTokens).toBe(100); + }); + + test("applies the requested threshold in both directions", async () => { + generate.mockRejectedValueOnce({ + detail: { response: { finishReason: "blocked" } }, + }); + + expect( + await checkImageContent( + imagePath, + "BLOCK_LOW_AND_ABOVE", + null, + "image/png", + LOCATION + ) + ).toBe(false); + + expect( + await checkImageContent( + imagePath, + "BLOCK_ONLY_HIGH", + null, + "image/png", + LOCATION + ) + ).toBe(true); + }); + + test.each([ + [ + "null content", + { data: null, errors: [{ path: "(root)", message: "must be object" }] }, + ], + [ + "empty object content", + { + data: {}, + errors: [ + { path: "(root)", message: "must have required property 'response'" }, + ], + }, + ], + [ + "a type mismatch", + { + data: { response: 42 }, + errors: [{ path: "response", message: "must be string" }], + }, + ], + ])( + "blocks when genkit rejects the moderation schema with %s", + async (_desc, details) => { + // A schema failure means the model produced no usable verdict, which on + // borderline imagery is almost always an input-side safety refusal. + // Failing open is worse than a false-positive block. + generate.mockRejectedValue( + new ValidationError({ ...details, schema: moderationSchema }) + ); + + const result = await checkImageContent( + imagePath, + "BLOCK_LOW_AND_ABOVE", + "Is this image inappropriate?", + "image/png", + LOCATION + ); + + expect(result).toBe(false); + expect(log.contentFilterBlocked).toHaveBeenCalled(); + // A deterministic refusal must not burn retries. + expect(generate).toHaveBeenCalledTimes(1); + } + ); + + test("retries and rethrows errors that are not schema refusals", async () => { + generate.mockRejectedValue(new Error("ECONNRESET")); + + await expect( + checkImageContent( + imagePath, + "BLOCK_LOW_AND_ABOVE", + "prompt", + "image/png", + LOCATION, + 3 + ) + ).rejects.toThrow("ECONNRESET"); + + expect(generate).toHaveBeenCalledTimes(3); + expect(log.contentFilterBlocked).not.toHaveBeenCalled(); + }, 30000); +}); diff --git a/kits/storage-resize-images/tests/convert-image.test.ts b/kits/storage-resize-images/tests/convert-image.test.ts new file mode 100644 index 000000000..39cb0c436 --- /dev/null +++ b/kits/storage-resize-images/tests/convert-image.test.ts @@ -0,0 +1,85 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import sharp from "sharp"; +import { beforeAll, describe, expect, test } from "vitest"; + +import { resolveResizeImagesConfig } from "../src/export-config"; +import { convertType } from "../src/util"; + +const config = resolveResizeImagesConfig({ + bucket: "extensions-testing.appspot.com", + sizes: "200x200", + imageTypes: "jpeg", +}); + +const fixture = (name: string) => fs.readFileSync(path.join(__dirname, name)); + +const formatOf = async (buffer: Buffer) => + (await sharp(buffer).metadata()).format; + +let bufferJPG: Buffer; +let bufferPNG: Buffer; +let bufferGIF: Buffer; + +beforeAll(() => { + bufferJPG = fixture("test-image.jpeg"); + bufferPNG = fixture("test-image.png"); + bufferGIF = fixture("test-image.gif"); +}); + +describe("convertType", () => { + test.each([ + ["png", () => bufferJPG, "png"], + ["jpeg", () => bufferPNG, "jpeg"], + ["webp", () => bufferPNG, "webp"], + ["tiff", () => bufferPNG, "tiff"], + ["gif", () => bufferGIF, "gif"], + ])("converts to %s image type", async (format, source, expected) => { + const buffer = await convertType( + source(), + format, + config.outputOptions, + config.animated + ); + + expect(await formatOf(buffer)).toBe(expected); + }); + + test("remains jpeg image type when the target type is not supported", async () => { + const buffer = await convertType( + bufferJPG, + "raw", + config.outputOptions, + config.animated + ); + + expect(await formatOf(buffer)).toBe("jpeg"); + }); + + test("remains gif image type when the target type is not supported", async () => { + const buffer = await convertType( + bufferGIF, + "raw", + config.outputOptions, + config.animated + ); + + expect(await formatOf(buffer)).toBe("gif"); + }); +}); diff --git a/kits/storage-resize-images/tests/emulator/app/.env b/kits/storage-resize-images/tests/emulator/app/.env new file mode 100644 index 000000000..e69791b9f --- /dev/null +++ b/kits/storage-resize-images/tests/emulator/app/.env @@ -0,0 +1,8 @@ +IMG_BUCKET=demo-test.appspot.com +IMG_SIZES=200x200 +DELETE_ORIGINAL_FILE=false +IMAGE_TYPE=["false"] +CONTENT_FILTER_LEVEL=OFF +FUNCTION_MEMORY=1024 +RESIZED_IMAGES_PATH=thumbs +FAILED_IMAGES_PATH=failed diff --git a/kits/storage-resize-images/tests/emulator/app/index.js b/kits/storage-resize-images/tests/emulator/app/index.js new file mode 100644 index 000000000..66310fcfb --- /dev/null +++ b/kits/storage-resize-images/tests/emulator/app/index.js @@ -0,0 +1,17 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module.exports = require("../../../lib/index.js"); diff --git a/kits/storage-resize-images/tests/emulator/app/package.json b/kits/storage-resize-images/tests/emulator/app/package.json new file mode 100644 index 000000000..d65f0756b --- /dev/null +++ b/kits/storage-resize-images/tests/emulator/app/package.json @@ -0,0 +1,6 @@ +{ + "name": "storage-resize-images-emulator-app", + "private": true, + "main": "index.js", + "engines": { "node": "24" } +} diff --git a/kits/storage-resize-images/tests/emulator/e2e.test.ts b/kits/storage-resize-images/tests/emulator/e2e.test.ts new file mode 100644 index 000000000..12354a56c --- /dev/null +++ b/kits/storage-resize-images/tests/emulator/e2e.test.ts @@ -0,0 +1,96 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as path from "node:path"; +import * as admin from "firebase-admin"; +import { beforeAll, describe, expect, test } from "vitest"; + +process.env.FIREBASE_STORAGE_EMULATOR_HOST = "127.0.0.1:9199"; +process.env.STORAGE_EMULATOR_HOST = "http://127.0.0.1:9199"; +process.env.GOOGLE_CLOUD_PROJECT = "demo-test"; + +const BUCKET = "demo-test.appspot.com"; +const RESIZED_PATH = "thumbs"; +const FAILED_PATH = "failed"; +const SIZE = "200x200"; + +let storage: admin.storage.Storage; + +const fixture = (name: string) => path.join(__dirname, "..", name); + +/** Polls until the trigger has written the file, or gives up. */ +async function waitForFile( + filePath: string, + timeout = 30_000 +): Promise { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const [exists] = await storage.bucket(BUCKET).file(filePath).exists(); + if (exists) return true; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + return false; +} + +describe("generateResizedImage in the emulator", () => { + beforeAll(async () => { + if (!admin.apps.length) { + admin.initializeApp({ projectId: "demo-test", storageBucket: BUCKET }); + } + storage = admin.storage(); + + const bucket = storage.bucket(BUCKET); + await bucket.upload(fixture("test-image.jpeg"), { + destination: "test-image.jpeg", + metadata: { contentType: "image/jpeg" }, + }); + await bucket.upload(fixture("not-an-image.jpeg"), { + destination: "not-an-image.jpeg", + metadata: { contentType: "image/jpeg" }, + }); + await bucket.upload(fixture("test-img.jfif"), { + destination: "test-img.jfif", + metadata: { contentType: "image/jpeg" }, + }); + }); + + test("resizes test-image.jpeg", async () => { + expect(await waitForFile(`${RESIZED_PATH}/test-image_${SIZE}.jpeg`)).toBe( + true + ); + }); + + test("copies a file that is not an image to the failed directory", async () => { + expect(await waitForFile(`${FAILED_PATH}/not-an-image.jpeg`)).toBe(true); + }); + + test("resizes test-img.jfif", async () => { + expect(await waitForFile(`${RESIZED_PATH}/test-img_${SIZE}.jfif`)).toBe( + true + ); + }); + + test("resizes an image uploaded with a jpg content type", async () => { + await storage.bucket(BUCKET).upload(fixture("test-jpg.jpg"), { + destination: "test-jpg.jpg", + metadata: { contentType: "image/jpg" }, + }); + + expect(await waitForFile(`${RESIZED_PATH}/test-jpg_${SIZE}.jpg`)).toBe( + true + ); + }); +}); diff --git a/kits/storage-resize-images/tests/emulator/storage.rules b/kits/storage-resize-images/tests/emulator/storage.rules new file mode 100644 index 000000000..10ee8060b --- /dev/null +++ b/kits/storage-resize-images/tests/emulator/storage.rules @@ -0,0 +1,6 @@ +rules_version = '2'; +service firebase.storage { + match /b/{bucket}/o { + match /{allPaths=**} { allow read, write: if true; } + } +} diff --git a/kits/storage-resize-images/tests/export-config.test.ts b/kits/storage-resize-images/tests/export-config.test.ts new file mode 100644 index 000000000..a27757d0d --- /dev/null +++ b/kits/storage-resize-images/tests/export-config.test.ts @@ -0,0 +1,136 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, test } from "vitest"; + +import { + convertHarmBlockThreshold, + DELETE_IMAGE, + resolveResizeImagesConfig, + validateAbsolutePathList, +} from "../src/export-config"; + +const base = { bucket: "extensions-testing.appspot.com", sizes: "200x200" }; + +describe("resolveResizeImagesConfig", () => { + test("applies defaults for optional fields", () => { + const resolved = resolveResizeImagesConfig(base); + + expect(resolved).toMatchObject({ + bucket: "extensions-testing.appspot.com", + imageSizes: ["200x200"], + deleteOriginalFile: DELETE_IMAGE.never, + makePublic: false, + imageTypes: ["false"], + sharpOptions: "{}", + animated: true, + memory: "1GiB", + regenerateToken: true, + contentFilterLevel: null, + customFilterPrompt: null, + placeholderImagePath: null, + }); + }); + + test("splits comma-separated sizes and image types", () => { + const resolved = resolveResizeImagesConfig({ + ...base, + sizes: "200x200,400x400", + imageTypes: "jpeg,webp", + }); + + expect(resolved.imageSizes).toEqual(["200x200", "400x400"]); + expect(resolved.imageTypes).toEqual(["jpeg", "webp"]); + }); + + test.each([ + ["true", DELETE_IMAGE.always], + [true, DELETE_IMAGE.always], + ["false", DELETE_IMAGE.never], + [false, DELETE_IMAGE.never], + [undefined, DELETE_IMAGE.never], + ["on_success", DELETE_IMAGE.onSuccess], + ] as const)("maps deleteOriginal %s", (deleteOriginal, expected) => { + expect( + resolveResizeImagesConfig({ ...base, deleteOriginal }).deleteOriginalFile + ).toBe(expected); + }); + + test("maps the memory param onto a function memory option", () => { + expect(resolveResizeImagesConfig({ ...base, memory: 512 }).memory).toBe( + "512MiB" + ); + expect(resolveResizeImagesConfig({ ...base, memory: 8192 }).memory).toBe( + "8GiB" + ); + }); + + test("treats animated sharp options as animated even when the param is off", () => { + expect( + resolveResizeImagesConfig({ + ...base, + isAnimated: false, + sharpOptions: '{"animated":true}', + }).animated + ).toBe(true); + expect( + resolveResizeImagesConfig({ ...base, isAnimated: false }).animated + ).toBe(false); + }); + + test("rejects a path list that is not absolute", () => { + expect(() => + resolveResizeImagesConfig({ ...base, includePathList: "relative/path" }) + ).toThrow("Invalid includePathList"); + expect(() => + resolveResizeImagesConfig({ ...base, excludePathList: "relative/path" }) + ).toThrow("Invalid excludePathList"); + }); +}); + +describe("validateAbsolutePathList", () => { + test("accepts a comma-separated list of absolute paths", () => { + expect( + validateAbsolutePathList( + "/users/avatars,/design/pictures", + "includePathList" + ) + ).toEqual(["/users/avatars", "/design/pictures"]); + }); + + test("treats an empty value as unset", () => { + expect(validateAbsolutePathList("", "includePathList")).toBeUndefined(); + expect( + validateAbsolutePathList(undefined, "includePathList") + ).toBeUndefined(); + }); +}); + +describe("convertHarmBlockThreshold", () => { + test("maps OFF to no filtering", () => { + expect(convertHarmBlockThreshold("OFF")).toBeNull(); + expect(convertHarmBlockThreshold(undefined)).toBeNull(); + }); + + test("passes the Vertex AI thresholds through", () => { + expect(convertHarmBlockThreshold("BLOCK_ONLY_HIGH")).toBe( + "BLOCK_ONLY_HIGH" + ); + expect(convertHarmBlockThreshold("BLOCK_LOW_AND_ABOVE")).toBe( + "BLOCK_LOW_AND_ABOVE" + ); + }); +}); diff --git a/kits/storage-resize-images/tests/file-operations.test.ts b/kits/storage-resize-images/tests/file-operations.test.ts new file mode 100644 index 000000000..1a82e549b --- /dev/null +++ b/kits/storage-resize-images/tests/file-operations.test.ts @@ -0,0 +1,131 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as path from "node:path"; +import type { Bucket } from "@google-cloud/storage"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("../src/logs"); + +import { + type ResizeImagesConfig, + resolveResizeImagesConfig, +} from "../src/export-config"; +import { handleFailedImage } from "../src/file-operations"; +import * as logs from "../src/logs"; +import { + countNegativeTraversals, + type StorageObjectMetadata, +} from "../src/util"; + +const object = { + bucket: "test-bucket", + name: "users/uid1/images/photo.jpg", + contentType: "image/jpeg", +} as StorageObjectMetadata; + +function makeConfig(overrides: Partial = {}) { + return resolveResizeImagesConfig({ + bucket: "extensions-testing.appspot.com", + sizes: "200x200", + ...overrides, + }); +} + +function fakeBucket() { + const upload = vi.fn().mockResolvedValue([{}]); + return { upload, bucket: { upload } as unknown as Bucket }; +} + +const run = ( + failedImagesPath: string | undefined, + contentFilterFailed = false +) => { + const { upload, bucket } = fakeBucket(); + return handleFailedImage( + bucket, + "/tmp/local.jpg", + object, + path.parse(object.name), + contentFilterFailed, + makeConfig({ failedImagesPath }) + ).then(() => upload); +}; + +describe("countNegativeTraversals", () => { + test("is non-zero for a path that walks up a directory", () => { + // Matches do not overlap, so consecutive hops share a slash and count + // once. Callers only branch on zero versus non-zero. + expect(countNegativeTraversals("images/../config.json")).toBe(1); + expect(countNegativeTraversals("images/../../config.json")).toBe(1); + expect(countNegativeTraversals("images/thumbs")).toBe(0); + }); +}); + +describe("handleFailedImage", () => { + beforeEach(() => vi.clearAllMocks()); + + test("does nothing when no failed path is configured", async () => { + expect(await run(undefined)).not.toHaveBeenCalled(); + }); + + test("uploads the original under the failed path", async () => { + const upload = await run("failed"); + + expect(upload).toHaveBeenCalledWith("/tmp/local.jpg", { + destination: "users/uid1/images/failed/photo.jpg", + metadata: { metadata: { resizeFailed: "true" } }, + }); + }); + + test("flags images the content filter rejected", async () => { + const upload = await run("failed", true); + + expect(upload).toHaveBeenCalledWith("/tmp/local.jpg", { + destination: "users/uid1/images/failed/photo.jpg", + metadata: { + metadata: { resizeFailed: "true", contentFilterFailed: "true" }, + }, + }); + }); + + test("refuses a failed path that traverses out of the original directory", async () => { + const upload = await run("../../.."); + + expect(upload).not.toHaveBeenCalled(); + expect(logs.invalidFailedResizePath).toHaveBeenCalled(); + }); + + test("refuses a single parent-directory hop", async () => { + // No "/../" to count, so this only stays inside the original prefix + // because the normalized destination is checked as well. + const upload = await run(".."); + + expect(upload).not.toHaveBeenCalled(); + expect(logs.invalidFailedResizePath).toHaveBeenCalled(); + }); + + test("allows a nested failed path", async () => { + const upload = await run("failed/thumbs"); + + expect(upload).toHaveBeenCalledWith( + "/tmp/local.jpg", + expect.objectContaining({ + destination: "users/uid1/images/failed/thumbs/photo.jpg", + }) + ); + }); +}); diff --git a/kits/storage-resize-images/tests/filters.test.ts b/kits/storage-resize-images/tests/filters.test.ts new file mode 100644 index 000000000..1d3b59c8b --- /dev/null +++ b/kits/storage-resize-images/tests/filters.test.ts @@ -0,0 +1,129 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("../src/logs"); + +import { + type ResizeImagesConfig, + resolveResizeImagesConfig, +} from "../src/export-config"; +import { shouldResize } from "../src/filters"; +import * as logs from "../src/logs"; +import type { StorageObjectMetadata } from "../src/util"; + +const defaultMetadata = { + bucket: "test-bucket", + name: "path/to/image.jpg", + contentType: "image/jpeg", + metadata: {}, +}; + +function makeConfig(overrides: Partial = {}) { + return resolveResizeImagesConfig({ + bucket: "extensions-testing.appspot.com", + sizes: "200x200", + deleteOriginal: "true", + ...overrides, + }); +} + +function check( + object: Partial, + overrides: Partial = {} +) { + return shouldResize( + { ...defaultMetadata, ...object } as StorageObjectMetadata, + makeConfig(overrides) + ); +} + +describe("shouldResize", () => { + beforeEach(() => vi.clearAllMocks()); + + describe("content type checks", () => { + test.each([ + ["no contentType", undefined, () => logs.noContentType], + ["non-image contentType", "text/plain", () => logs.contentTypeInvalid], + ["unsupported image format", "image/foo", () => logs.unsupportedType], + ])("%s", (_desc, contentType, logFunction) => { + expect(check({ contentType })).toBe(false); + expect(logFunction()).toHaveBeenCalled(); + }); + }); + + test("gzip", () => { + expect(check({ contentEncoding: "gzip" })).toBe(false); + expect(logs.gzipContentEncoding).toHaveBeenCalled(); + }); + + describe("path validations", () => { + test("returns false if image is outside of allowed paths", () => { + expect(check({}, { includePathList: "/allowed" })).toBe(false); + + const calls = vi.mocked(logs.imageOutsideOfPaths).mock.calls; + expect(calls).toHaveLength(1); + expect(calls[0][0][0]).toBe("/allowed"); + }); + + test("returns false if image is inside of excluded paths", () => { + expect( + check( + { name: "path/not-allowed/image.jpg" }, + { excludePathList: "/path/not-allowed" } + ) + ).toBe(false); + + const calls = vi.mocked(logs.imageInsideOfExcludedPaths).mock.calls; + expect(calls).toHaveLength(1); + expect(calls[0][0][0]).toBe("/path/not-allowed"); + }); + }); + + describe("metadata checks", () => { + test.each([ + [ + "already resized", + { resizedImage: "true" }, + () => logs.imageAlreadyResized, + ], + [ + "resizing failed previously", + { resizeFailed: "true" }, + () => logs.imageFailedAttempt, + ], + ])( + "returns false if image metadata indicates %s", + (_desc, metadata, logFn) => { + expect(check({ metadata })).toBe(false); + expect(logFn()).toHaveBeenCalled(); + } + ); + }); + + test("returns true if all conditions are met", () => { + expect( + check( + { name: "path/allowed/image.jpg" }, + { + includePathList: "/path/allowed", + excludePathList: "/path/not-allowed", + } + ) + ).toBe(true); + }); +}); diff --git a/kits/storage-resize-images/tests/handlers.test.ts b/kits/storage-resize-images/tests/handlers.test.ts new file mode 100644 index 000000000..102a7218f --- /dev/null +++ b/kits/storage-resize-images/tests/handlers.test.ts @@ -0,0 +1,273 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fs from "node:fs"; +import type * as admin from "firebase-admin"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("node:fs", async (importOriginal) => ({ + ...(await importOriginal()), + copyFileSync: vi.fn(), +})); +vi.mock("../src/filters"); +vi.mock("../src/file-operations"); +vi.mock("../src/content-filter"); +vi.mock("../src/placeholder"); +vi.mock("../src/resize-image"); +vi.mock("../src/events"); +vi.mock("../src/logs"); + +import { checkImageContent } from "../src/content-filter"; +import { + type ResizeImagesConfig, + resolveResizeImagesConfig, +} from "../src/export-config"; +import { + deleteRemoteFile, + downloadOriginalFile, + handleFailedImage, +} from "../src/file-operations"; +import { shouldResize } from "../src/filters"; +import { + generateResizedImageHandler, + type HandlerContext, +} from "../src/handlers"; +import * as logs from "../src/logs"; +import { replacePlaceholder } from "../src/placeholder"; +import { resizeImages } from "../src/resize-image"; +import type { StorageObjectMetadata } from "../src/util"; + +const mockObject = { + bucket: "demo-bucket", + name: "images/test.jpg", + contentType: "image/jpeg", +} as StorageObjectMetadata; + +const parsedPathMatcher = expect.objectContaining({ + dir: "images", + base: "test.jpg", + name: "test", + ext: ".jpg", +}); + +const remoteFile = { name: "images/test.jpg" }; + +function makeCtx(configOverrides: Partial = {}) { + const config = resolveResizeImagesConfig({ + bucket: "demo-bucket", + sizes: "200x200", + contentFilterLevel: "BLOCK_MEDIUM_AND_ABOVE", + region: "us-central1", + ...configOverrides, + }); + return { + config, + storage: { + bucket: vi.fn(() => ({})), + } as unknown as admin.storage.Storage, + } satisfies HandlerContext as HandlerContext; +} + +describe("generateResizedImageHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(shouldResize).mockReturnValue(true); + vi.mocked(downloadOriginalFile).mockResolvedValue([ + "/tmp/test.jpg", + remoteFile as never, + ]); + vi.mocked(checkImageContent).mockResolvedValue(true); + vi.mocked(replacePlaceholder).mockResolvedValue(undefined); + vi.mocked(resizeImages).mockResolvedValue([ + { + status: "fulfilled", + value: { + size: "200x200", + outputFilePath: "images/test_200x200.jpg", + success: true, + }, + }, + ]); + }); + + test("does nothing for an object that should not be resized", async () => { + vi.mocked(shouldResize).mockReturnValue(false); + + await generateResizedImageHandler(mockObject, makeCtx(), false); + + expect(downloadOriginalFile).not.toHaveBeenCalled(); + expect(resizeImages).not.toHaveBeenCalled(); + }); + + test("routes blocked-by-filter images to the failed-image path", async () => { + vi.mocked(checkImageContent).mockResolvedValue(false); + + await generateResizedImageHandler(mockObject, makeCtx(), false); + + expect(handleFailedImage).toHaveBeenCalledTimes(1); + expect(handleFailedImage).toHaveBeenCalledWith( + expect.anything(), + "/tmp/test.jpg", + mockObject, + parsedPathMatcher, + true, + expect.anything() + ); + expect(fs.copyFileSync).toHaveBeenCalledWith( + "/tmp/test.jpg", + "/tmp/test.jpg-placeholder" + ); + expect(replacePlaceholder).toHaveBeenCalledWith( + "/tmp/test.jpg-placeholder", + expect.anything(), + null + ); + expect(resizeImages).toHaveBeenCalledWith( + expect.anything(), + "/tmp/test.jpg-placeholder", + parsedPathMatcher, + mockObject, + expect.anything() + ); + }); + + test("resizes when the content filter passes", async () => { + await generateResizedImageHandler(mockObject, makeCtx(), false); + + expect(replacePlaceholder).not.toHaveBeenCalled(); + expect(resizeImages).toHaveBeenCalledWith( + expect.anything(), + "/tmp/test.jpg", + parsedPathMatcher, + mockObject, + expect.anything() + ); + expect(handleFailedImage).not.toHaveBeenCalled(); + }); + + test("treats filter errors as failures and skips resizing", async () => { + vi.mocked(checkImageContent).mockRejectedValue(new Error("filter boom")); + + await generateResizedImageHandler(mockObject, makeCtx(), false); + + expect(replacePlaceholder).not.toHaveBeenCalled(); + expect(resizeImages).not.toHaveBeenCalled(); + expect(handleFailedImage).toHaveBeenCalledWith( + expect.anything(), + "/tmp/test.jpg", + mockObject, + parsedPathMatcher, + false, + expect.anything() + ); + }); + + test("still routes blocked images to the failed path when the placeholder swap errors", async () => { + vi.mocked(checkImageContent).mockResolvedValue(false); + const swapErr = new Error("swap boom"); + vi.mocked(replacePlaceholder).mockRejectedValue(swapErr); + + await generateResizedImageHandler(mockObject, makeCtx(), false); + + expect(handleFailedImage).toHaveBeenCalledTimes(1); + expect(handleFailedImage).toHaveBeenCalledWith( + expect.anything(), + "/tmp/test.jpg", + mockObject, + parsedPathMatcher, + true, + expect.anything() + ); + expect(logs.placeholderReplaceError).toHaveBeenCalledWith(swapErr); + expect(logs.contentFilterErrored).not.toHaveBeenCalled(); + expect(resizeImages).not.toHaveBeenCalled(); + }); + + test("stores the original when a resize fails", async () => { + vi.mocked(resizeImages).mockResolvedValue([ + { + status: "fulfilled", + value: { + size: "200x200", + outputFilePath: "images/test_200x200.jpg", + success: false, + }, + }, + ]); + + await generateResizedImageHandler(mockObject, makeCtx(), false); + + expect(handleFailedImage).toHaveBeenCalledWith( + expect.anything(), + "/tmp/test.jpg", + mockObject, + parsedPathMatcher, + false, + expect.anything() + ); + expect(logs.failed).toHaveBeenCalled(); + }); + + test("deletes the original on success only in on_success mode", async () => { + await generateResizedImageHandler( + mockObject, + makeCtx({ deleteOriginal: "on_success" }), + false + ); + + expect(deleteRemoteFile).toHaveBeenCalledWith( + remoteFile, + "images/test.jpg" + ); + }); + + test("keeps the original when a resize fails in on_success mode", async () => { + vi.mocked(resizeImages).mockResolvedValue([ + { status: "rejected", reason: new Error("resize boom") }, + ]); + + await generateResizedImageHandler( + mockObject, + makeCtx({ deleteOriginal: "on_success" }), + false + ); + + expect(deleteRemoteFile).not.toHaveBeenCalled(); + }); + + test("deletes the original on any attempt in always mode", async () => { + vi.mocked(resizeImages).mockResolvedValue([ + { status: "rejected", reason: new Error("resize boom") }, + ]); + + await generateResizedImageHandler( + mockObject, + makeCtx({ deleteOriginal: "true" }), + false + ); + + expect(deleteRemoteFile).toHaveBeenCalledWith( + remoteFile, + "images/test.jpg" + ); + }); + + test("never deletes the original by default", async () => { + await generateResizedImageHandler(mockObject, makeCtx(), false); + + expect(deleteRemoteFile).not.toHaveBeenCalled(); + }); +}); diff --git a/kits/storage-resize-images/tests/lib.test.ts b/kits/storage-resize-images/tests/lib.test.ts new file mode 100644 index 000000000..b63dafeef --- /dev/null +++ b/kits/storage-resize-images/tests/lib.test.ts @@ -0,0 +1,42 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, test, vi } from "vitest"; + +vi.mock("firebase-functions", () => ({ + logger: { + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + }, +})); + +vi.mock("firebase-functions/params", () => { + throw new Error("./lib must not import firebase-functions/params"); +}); + +describe("./lib", () => { + test("imports without declaring Firebase params", async () => { + const lib = await import("../src/lib"); + + expect(lib.generateResizedImageHandler).toBeTypeOf("function"); + expect(lib.resolveResizeImagesConfig).toBeTypeOf("function"); + expect(lib.shouldResize).toBeTypeOf("function"); + expect(lib.resize).toBeTypeOf("function"); + }, 15000); +}); diff --git a/kits/storage-resize-images/tests/not-an-image.jpeg b/kits/storage-resize-images/tests/not-an-image.jpeg new file mode 100644 index 000000000..e69de29bb diff --git a/kits/storage-resize-images/tests/resize-image.test.ts b/kits/storage-resize-images/tests/resize-image.test.ts new file mode 100644 index 000000000..435a09d27 --- /dev/null +++ b/kits/storage-resize-images/tests/resize-image.test.ts @@ -0,0 +1,245 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { Bucket } from "@google-cloud/storage"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("../src/logs"); + +import { + type ResizeImagesConfig, + resolveResizeImagesConfig, +} from "../src/export-config"; +import { + constructMetadata, + getModifiedFilePath, + modifyImage, +} from "../src/resize-image"; +import type { StorageObjectMetadata } from "../src/util"; + +const TEST_IMAGE = path.join(__dirname, "test-image.png"); + +function makeConfig(overrides: Partial = {}) { + return resolveResizeImagesConfig({ + bucket: "extensions-testing.appspot.com", + sizes: "200x200", + ...overrides, + }); +} + +describe("getModifiedFilePath", () => { + test("windows path handling", () => { + const parsedPath = { + ext: ".jpg", + dir: "C:\\Users\\user\\Desktop\\storage-resize-images\\images", + name: "test", + }; + const modifiedFileName = `${parsedPath.name}_200x200${parsedPath.ext}`; + + expect( + getModifiedFilePath(parsedPath.dir, "thumbnails", modifiedFileName) + ).toBe( + "C:/Users/user/Desktop/storage-resize-images/images/thumbnails/test_200x200.jpg" + ); + }); + + test("writes alongside the original when no resized path is set", () => { + expect(getModifiedFilePath("images", undefined, "test_200x200.jpg")).toBe( + "images/test_200x200.jpg" + ); + }); +}); + +describe("constructMetadata", () => { + const objectMetadata = { + bucket: "test-bucket", + name: "images/test.jpg", + contentType: "image/jpeg", + cacheControl: "public, max-age=60", + contentDisposition: "inline; filename*=utf-8''test.jpg", + metadata: { firebaseStorageDownloadTokens: "original-token" }, + } as StorageObjectMetadata; + + test("marks the output as a resized image", () => { + const metadata = constructMetadata( + "test_200x200.jpeg", + "image/jpeg", + objectMetadata, + makeConfig() + ); + + expect(metadata.metadata).toMatchObject({ resizedImage: "true" }); + expect(metadata.contentType).toBe("image/jpeg"); + }); + + test("prefers the configured cache-control header", () => { + const metadata = constructMetadata( + "test_200x200.jpeg", + "image/jpeg", + objectMetadata, + makeConfig({ cacheControlHeader: "public, max-age=31536000" }) + ); + + expect(metadata.cacheControl).toBe("public, max-age=31536000"); + }); + + test("falls back to the original cache-control header", () => { + const metadata = constructMetadata( + "test_200x200.jpeg", + "image/jpeg", + objectMetadata, + makeConfig() + ); + + expect(metadata.cacheControl).toBe("public, max-age=60"); + }); + + test("regenerates the download token unless it is turned off", () => { + const regenerated = constructMetadata( + "test_200x200.jpeg", + "image/jpeg", + objectMetadata, + makeConfig() + ).metadata as Record; + const kept = constructMetadata( + "test_200x200.jpeg", + "image/jpeg", + objectMetadata, + makeConfig({ regenerateToken: false }) + ).metadata as Record; + + expect(regenerated.firebaseStorageDownloadTokens).not.toBe( + "original-token" + ); + expect(kept.firebaseStorageDownloadTokens).toBe("original-token"); + }); + + test("rewrites the filename in the content disposition", () => { + const metadata = constructMetadata( + "test_200x200.jpeg", + "image/jpeg", + objectMetadata, + makeConfig() + ); + + expect(metadata.contentDisposition).toBe( + "inline; filename*=utf-8''test_200x200.jpeg" + ); + }); +}); + +describe("modifyImage", () => { + const uploaded: { source: string; destination: string }[] = []; + const makePublic = vi.fn().mockResolvedValue(undefined); + const bucket = { + upload: vi.fn(async (source: string, options: { destination: string }) => { + uploaded.push({ source, destination: options.destination }); + return [{ makePublic }]; + }), + } as unknown as Bucket; + const objectMetadata = { + bucket: "test-bucket", + name: "images/test.png", + contentType: "image/png", + metadata: {}, + } as StorageObjectMetadata; + + beforeEach(() => { + uploaded.length = 0; + vi.clearAllMocks(); + }); + + afterEach(() => { + // modifyImage writes to the OS temp dir and cleans up after itself. + expect(uploaded.every(({ source }) => !fs.existsSync(source))).toBe(true); + }); + + test("uploads the resized image next to the original", async () => { + const result = await modifyImage({ + bucket, + originalFile: TEST_IMAGE, + parsedPath: path.parse("images/test.png"), + contentType: "image/png", + size: "75x75", + objectMetadata, + format: "false", + config: makeConfig(), + }); + + expect(result).toMatchObject({ + size: "75x75", + outputFilePath: "images/test_75x75.png", + success: true, + }); + expect(uploaded[0].destination).toBe("images/test_75x75.png"); + expect(uploaded[0].source.startsWith(os.tmpdir())).toBe(true); + }); + + test("converts the output format and extension when one is configured", async () => { + const result = await modifyImage({ + bucket, + originalFile: TEST_IMAGE, + parsedPath: path.parse("images/test.png"), + contentType: "image/png", + size: "75x75", + objectMetadata, + format: "webp", + config: makeConfig({ resizedImagesPath: "thumbs" }), + }); + + expect(result.outputFilePath).toBe("images/thumbs/test_75x75.webp"); + const [, options] = vi.mocked(bucket.upload).mock.calls[0] as unknown as [ + string, + { metadata: { contentType: string } } + ]; + expect(options.metadata.contentType).toBe("image/webp"); + }); + + test("makes the upload public only when configured", async () => { + await modifyImage({ + bucket, + originalFile: TEST_IMAGE, + parsedPath: path.parse("images/test.png"), + contentType: "image/png", + size: "75x75", + objectMetadata, + format: "false", + config: makeConfig({ makePublic: true }), + }); + + expect(makePublic).toHaveBeenCalledTimes(1); + }); + + test("reports failure instead of throwing when the upload fails", async () => { + vi.mocked(bucket.upload).mockRejectedValueOnce(new Error("upload boom")); + + const result = await modifyImage({ + bucket, + originalFile: TEST_IMAGE, + parsedPath: path.parse("images/test.png"), + contentType: "image/png", + size: "75x75", + objectMetadata, + format: "false", + config: makeConfig(), + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/kits/storage-resize-images/tests/resize.test.ts b/kits/storage-resize-images/tests/resize.test.ts new file mode 100644 index 000000000..ea9c8750a --- /dev/null +++ b/kits/storage-resize-images/tests/resize.test.ts @@ -0,0 +1,91 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as path from "node:path"; +import sharp from "sharp"; +import { describe, expect, test } from "vitest"; + +import { resolveResizeImagesConfig } from "../src/export-config"; +import { + SUPPORTED_CONTENT_TYPES, + SUPPORTED_IMAGE_CONTENT_TYPE_MAP, +} from "../src/global"; +import { resize } from "../src/resize-image"; + +const TEST_IMAGE = path.join(__dirname, "test-image.png"); + +const config = resolveResizeImagesConfig({ + bucket: "extensions-testing.appspot.com", + sizes: "200x200", + deleteOriginal: "true", +}); + +describe("resize", () => { + test("throws if the wrong delimiter is used for the resize string", () => { + const errorMessage = "height and width are not delimited by a ',' or a 'x'"; + + expect(() => resize("/file/path", "200200", config)).toThrow(errorMessage); + expect(() => resize("/file/path", "200 200", config)).toThrow(errorMessage); + }); + + test("accepts either delimiter", async () => { + for (const size of ["75x75", "75,75"]) { + const metadata = await sharp( + await resize(TEST_IMAGE, size, config) + ).metadata(); + + expect(metadata.width).toBe(75); + expect(metadata.height).toBe(75); + } + }); + + test("resizes the image correctly", async () => { + const metadata = await sharp( + await resize(TEST_IMAGE, "75x75", config) + ).metadata(); + + expect(metadata.width).toBe(75); + expect(metadata.height).toBe(75); + }); + + test("does not enlarge an image smaller than the requested size", async () => { + const original = await sharp(TEST_IMAGE).metadata(); + const metadata = await sharp( + await resize(TEST_IMAGE, "5000x5000", config) + ).metadata(); + + expect(metadata.width).toBe(original.width); + expect(metadata.height).toBe(original.height); + }); + + test("image types supported", () => { + expect(SUPPORTED_CONTENT_TYPES).toEqual( + expect.arrayContaining([ + "image/jpeg", + "image/png", + "image/tiff", + "image/webp", + ]) + ); + expect(SUPPORTED_IMAGE_CONTENT_TYPE_MAP).toMatchObject({ + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + tiff: "image/tiff", + webp: "image/webp", + }); + }); +}); diff --git a/kits/storage-resize-images/tests/retry-queue.test.ts b/kits/storage-resize-images/tests/retry-queue.test.ts new file mode 100644 index 000000000..500ae5780 --- /dev/null +++ b/kits/storage-resize-images/tests/retry-queue.test.ts @@ -0,0 +1,161 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import PQueue from "p-queue"; +import { describe, expect, test, vi } from "vitest"; + +import { GLOBAL_RETRY_QUEUE } from "../src/global"; + +describe("retry queue", () => { + test("processes tasks in order of priority", async () => { + const queue = new PQueue({ concurrency: 1, autoStart: false }); + const executionOrder: number[] = []; + const task = (id: number) => + vi.fn(async () => { + executionOrder.push(id); + return `result${id}`; + }); + const [task1, task2, task3] = [task(1), task(2), task(3)]; + + const promises = [ + queue.add(task2, { priority: 2 }), + queue.add(task3, { priority: 1 }), + queue.add(task1, { priority: 3 }), + ]; + + queue.start(); + await Promise.all(promises); + + expect(executionOrder).toEqual([1, 2, 3]); + expect(task1).toHaveBeenCalledTimes(1); + expect(task2).toHaveBeenCalledTimes(1); + expect(task3).toHaveBeenCalledTimes(1); + }); + + test("handles task failures correctly", async () => { + const queue = new PQueue({ concurrency: 3, autoStart: false }); + const successTask = vi.fn().mockResolvedValue("success"); + const failureTask = vi.fn().mockRejectedValue(new Error("Task failed")); + + const successPromise = queue.add(successTask, { priority: 2 }); + const failurePromise = queue.add(failureTask, { priority: 1 }); + + queue.start(); + + await expect(successPromise).resolves.toBe("success"); + await expect(failurePromise).rejects.toThrow("Task failed"); + expect(successTask).toHaveBeenCalledTimes(1); + expect(failureTask).toHaveBeenCalledTimes(1); + }); + + test("respects the concurrency limit", async () => { + const queue = new PQueue({ concurrency: 3, autoStart: false }); + let concurrentCount = 0; + let maxConcurrentCount = 0; + const createTask = (delay: number) => async () => { + concurrentCount++; + maxConcurrentCount = Math.max(maxConcurrentCount, concurrentCount); + await new Promise((resolve) => setTimeout(resolve, delay)); + concurrentCount--; + return delay; + }; + + const promises = Array.from({ length: 10 }, (_, i) => + queue.add(createTask(10), { priority: i + 1 }) + ); + + queue.start(); + await Promise.all(promises); + + expect(maxConcurrentCount).toBeLessThanOrEqual(3); + }); + + test("continues processing the queue after a task completes", async () => { + const queue = new PQueue({ concurrency: 1, autoStart: false }); + const executionOrder: number[] = []; + const slowTask = async () => { + executionOrder.push(1); + await new Promise((resolve) => setTimeout(resolve, 20)); + return "slow done"; + }; + const fastTask = (id: number) => async () => { + executionOrder.push(id); + return `fast${id} done`; + }; + + const promises = [ + queue.add(slowTask, { priority: 3 }), + queue.add(fastTask(2), { priority: 2 }), + queue.add(fastTask(3), { priority: 1 }), + ]; + + queue.start(); + await Promise.all(promises); + + expect(executionOrder).toEqual([1, 2, 3]); + }); + + test("handles an empty queue gracefully", () => { + const queue = new PQueue({ concurrency: 3 }); + + expect(() => queue.start()).not.toThrow(); + }); + + test("runs tasks of equal priority in the order they were added", async () => { + const queue = new PQueue({ concurrency: 1, autoStart: false }); + const executionOrder: number[] = []; + + const promises = Array.from({ length: 5 }, (_, i) => + queue.add( + async () => { + executionOrder.push(i + 1); + return `result${i + 1}`; + }, + { priority: 1 } + ) + ); + + queue.start(); + await Promise.all(promises); + + expect(executionOrder).toEqual([1, 2, 3, 4, 5]); + }); + + test("handles a large number of tasks", async () => { + const queue = new PQueue({ concurrency: 3, autoStart: false }); + const taskCount = 50; + const completedTasks: number[] = []; + + const promises = Array.from({ length: taskCount }, (_, i) => + queue.add( + async () => { + completedTasks.push(i); + return i; + }, + { priority: i } + ) + ); + + queue.start(); + await Promise.all(promises); + + expect(completedTasks).toHaveLength(taskCount); + }); + + test("the shared content-filter queue caps concurrency at three", () => { + expect(GLOBAL_RETRY_QUEUE.concurrency).toBe(3); + }); +}); diff --git a/kits/storage-resize-images/tests/test-image.gif b/kits/storage-resize-images/tests/test-image.gif new file mode 100644 index 000000000..71dfb6517 Binary files /dev/null and b/kits/storage-resize-images/tests/test-image.gif differ diff --git a/kits/storage-resize-images/tests/test-image.jpeg b/kits/storage-resize-images/tests/test-image.jpeg new file mode 100644 index 000000000..271df1666 Binary files /dev/null and b/kits/storage-resize-images/tests/test-image.jpeg differ diff --git a/kits/storage-resize-images/tests/test-image.png b/kits/storage-resize-images/tests/test-image.png new file mode 100644 index 000000000..73ad235b2 Binary files /dev/null and b/kits/storage-resize-images/tests/test-image.png differ diff --git a/kits/storage-resize-images/tests/test-img.jfif b/kits/storage-resize-images/tests/test-img.jfif new file mode 100644 index 000000000..46e576e90 Binary files /dev/null and b/kits/storage-resize-images/tests/test-img.jfif differ diff --git a/kits/storage-resize-images/tests/test-jpg.jpg b/kits/storage-resize-images/tests/test-jpg.jpg new file mode 100644 index 000000000..ad6a79056 Binary files /dev/null and b/kits/storage-resize-images/tests/test-jpg.jpg differ diff --git a/kits/storage-resize-images/tests/util.test.ts b/kits/storage-resize-images/tests/util.test.ts new file mode 100644 index 000000000..5d4084a8b --- /dev/null +++ b/kits/storage-resize-images/tests/util.test.ts @@ -0,0 +1,121 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, test } from "vitest"; + +import { convertPathToPosix, startsWithArray } from "../src/util"; + +const imagePath = ["/test/picture"]; + +describe("startsWithArray", () => { + test("allowed paths", () => { + const allowed = ["/test/picture", "/test/picture/directory"]; + + for (const path of allowed) { + expect(startsWithArray(imagePath, path)).toBe(true); + } + }); + + test("blocked paths", () => { + const notAllowed = [ + "/test", + "/test/pict", + "/test/pictures", + "/test/picturesssssss", + ]; + + for (const path of notAllowed) { + expect(startsWithArray(imagePath, path)).toBe(false); + } + }); + + test("can handle allowed globbed paths", () => { + const imagePaths = ["/test/picture", "/test/*/picture"]; + const allowed = [ + "/test/picture", + "/test/something/picture", + "/test/folder1/folder2/picture", + ]; + + for (const path of allowed) { + expect(startsWithArray(imagePaths, path)).toBe(true); + } + }); + + test("can handle not allowed globbed paths", () => { + const imagePaths = ["/test/picture", "/test/*/picture"]; + const notAllowed = ["/test/*/pictures", "/test/*/folder2/pictures"]; + + for (const path of notAllowed) { + expect(startsWithArray(imagePaths, path)).toBe(false); + } + }); + + test("can handle '+' when replacing '*' in globbed paths", () => { + const allowed = ["/test/picture", "/test/*/picture"]; + const imagePaths = ["/test/picture", "/test/+/picture"]; + + for (const path of imagePaths) { + expect(startsWithArray(allowed, path)).toBe(true); + } + }); +}); + +describe("convertPathToPosix", () => { + const windowsPaths = [ + "C:\\Users\\test\\image.jpg", + "D:\\Users\\test\\image.jpg", + "E:\\Users\\test\\image.jpg", + "Z:\\Users\\test\\image.jpg", + "C:\\Users\\test:user\\image.jpg", + ]; + + test("converts windows path to posix without drive", () => { + const expected = [ + "/Users/test/image.jpg", + "/Users/test/image.jpg", + "/Users/test/image.jpg", + "/Users/test/image.jpg", + "/Users/test:user/image.jpg", + ]; + + windowsPaths.forEach((windowsPath, index) => { + expect(convertPathToPosix(windowsPath, true)).toBe(expected[index]); + }); + }); + + test("converts windows path to posix with drive", () => { + const expected = [ + "C:/Users/test/image.jpg", + "D:/Users/test/image.jpg", + "E:/Users/test/image.jpg", + "Z:/Users/test/image.jpg", + "C:/Users/test:user/image.jpg", + ]; + + windowsPaths.forEach((windowsPath, index) => { + expect(convertPathToPosix(windowsPath, false)).toBe(expected[index]); + }); + }); + + test("converts posix path to posix (no change)", () => { + const posixPaths = ["/Users/test/image.jpg", "/Users/test:user/image.jpg"]; + + for (const posixPath of posixPaths) { + expect(convertPathToPosix(posixPath)).toBe(posixPath); + } + }); +}); diff --git a/kits/storage-resize-images/vitest.config.ts b/kits/storage-resize-images/vitest.config.ts new file mode 100644 index 000000000..dbf395464 --- /dev/null +++ b/kits/storage-resize-images/vitest.config.ts @@ -0,0 +1,23 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + exclude: ["**/node_modules/**", "tests/emulator/**"], + }, +}); diff --git a/kits/storage-resize-images/vitest.emulator.config.ts b/kits/storage-resize-images/vitest.emulator.config.ts new file mode 100644 index 000000000..e2c289bdc --- /dev/null +++ b/kits/storage-resize-images/vitest.emulator.config.ts @@ -0,0 +1,28 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/emulator/**/*.test.ts"], + testTimeout: 60_000, + hookTimeout: 60_000, + // The trigger runs in the Functions emulator, so the files land one after + // another rather than in parallel with the uploads. + fileParallelism: false, + }, +});