diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..6668a1e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,41 @@ +name: Run Tests + +on: + push: + branches: ['*'] + pull_request: + branches: ['*'] + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: | + ./bun_modules + ./node_modules + ./bun.lock + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Install playwright dependencies + run: bun run install:playwright + + - name: Run vitest + run: bun run test:headless diff --git a/.gitignore b/.gitignore index 8a825ee..3d5c22d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ dist .env # Neutralino .tmp/ +.vitest-attachments/ +**/__screenshots__/* bin/ *.log .storage/ diff --git a/bun.lock b/bun.lock index 1efe8fd..a66978e 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "@kawarp/core": "^1.1.1", "@svta/common-media-library": "^0.18.1", "@uimaxbai/am-lyrics": "^1.1.4", + "@vitest/web-worker": "^4.1.2", "appwrite": "^23.0.0", "butterchurn": "^2.6.7", "butterchurn-presets": "^2.4.7", @@ -36,17 +37,21 @@ "svgo": "^4.0.1", "url-toolkit": "^2.2.5", "uuid": "^13.0.0", + "vitest": "^4.1.2", }, "devDependencies": { "@capacitor/assets": "^3.0.5", "@capacitor/cli": "^8.2.0", + "@testing-library/dom": "^10.4.1", "@types/node": "^25.3.5", + "@vitest/browser-playwright": "^4.1.2", "eslint": "^9.39.3", "eslint-config-prettier": "^10.1.8", "formidable": "^3.5.4", "globals": "^17.4.0", "htmlhint": "^1.9.2", "miniflare": "^4.20260301.1", + "playwright": "^1.58.2", "prettier": "^3.8.1", "stylelint": "^16.26.1", "stylelint-config-standard": "^39.0.1", @@ -245,6 +250,8 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@blazediff/core": ["@blazediff/core@1.9.1", "", {}, "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA=="], + "@cacheable/memory": ["@cacheable/memory@2.0.8", "", { "dependencies": { "@cacheable/utils": "^2.4.0", "@keyv/bigmap": "^1.3.1", "hookified": "^1.15.1", "keyv": "^5.6.0" } }, "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw=="], "@cacheable/utils": ["@cacheable/utils@2.4.1", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA=="], @@ -481,6 +488,8 @@ "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], @@ -553,10 +562,14 @@ "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@surma/rollup-plugin-off-main-thread": ["@surma/rollup-plugin-off-main-thread@2.2.3", "", { "dependencies": { "ejs": "^3.1.6", "json5": "^2.2.0", "magic-string": "^0.25.0", "string.prototype.matchall": "^4.0.6" } }, "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ=="], "@svta/common-media-library": ["@svta/common-media-library@0.18.1", "", {}, "sha512-VMj1jI8OWphurcozF+dezABUm9Mht6iAsSiKsFUKVT35fddOowvLoGz23Gx6lEHaAHkDy9o/aVi5s9DSp3K15Q=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + "@trapezedev/gradle-parse": ["@trapezedev/gradle-parse@7.1.3", "", {}, "sha512-WQVF5pEJ5o/mUyvfGTG9nBKx9Te/ilKM3r2IT69GlbaooItT5ao7RyF1MUTBNjHLPk/xpGUY3c6PyVnjDlz0Vw=="], "@trapezedev/project": ["@trapezedev/project@7.1.3", "", { "dependencies": { "@ionic/utils-fs": "^3.1.5", "@ionic/utils-subprocess": "^2.1.8", "@prettier/plugin-xml": "^2.2.0", "@trapezedev/gradle-parse": "7.1.3", "@xmldom/xmldom": "^0.7.5", "conventional-changelog": "^3.1.4", "cross-spawn": "^7.0.3", "diff": "^5.1.0", "env-paths": "^3.0.0", "gradle-to-js": "^2.0.0", "ini": "^2.0.0", "kleur": "^4.1.5", "lodash": "^4.17.21", "mergexml": "^1.2.3", "plist": "^3.0.4", "prettier": "^2.7.1", "prompts": "^2.4.2", "replace": "^1.1.0", "tempy": "^1.0.1", "tmp": "^0.2.1", "ts-node": "^10.2.1", "xcode": "^3.0.1", "xml-js": "^1.6.11", "xpath": "^0.0.32", "yargs": "^17.2.1" } }, "sha512-GANh8Ey73MechZrryfJoILY9hBnWqzS6AdB53zuWBCBbaiImyblXT41fWdN6pB2f5+cNI2FAUxGfVhl+LeEVbQ=="], @@ -569,6 +582,12 @@ "@tsconfig/node16": ["@tsconfig/node16@1.0.4", "", {}, "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/fs-extra": ["@types/fs-extra@8.1.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ=="], @@ -591,6 +610,26 @@ "@uimaxbai/am-lyrics": ["@uimaxbai/am-lyrics@1.1.4", "", { "dependencies": { "@babel/runtime": "^7.27.6", "lit": "^3.1.4" }, "peerDependencies": { "@lit/react": "^1.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@lit/react", "react"] }, "sha512-LEwvbfgz6o71kYTq1vMlfou/powr8q4CJQWuyL2H48Dwo1/vH59SKiB3nz/WOEQ1S69uaSmfqf8Prtx6+ZNIrQ=="], + "@vitest/browser": ["@vitest/browser@4.1.2", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.2" } }, "sha512-CwdIf90LNf1Zitgqy63ciMAzmyb4oIGs8WZ40VGYrWkssQKeEKr32EzO8MKUrDPPcPVHFI9oQ5ni2Hp24NaNRQ=="], + + "@vitest/browser-playwright": ["@vitest/browser-playwright@4.1.2", "", { "dependencies": { "@vitest/browser": "4.1.2", "@vitest/mocker": "4.1.2", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "playwright": "*", "vitest": "4.1.2" } }, "sha512-N0Z2HzMLvMR6k/tWPTS6Q/DaRscrkax/f2f9DIbNQr+Cd1l4W4wTf/I6S983PAMr0tNqqoTL+xNkLh9M5vbkLg=="], + + "@vitest/expect": ["@vitest/expect@4.1.2", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.2", "", { "dependencies": { "@vitest/spy": "4.1.2", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.2", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA=="], + + "@vitest/runner": ["@vitest/runner@4.1.2", "", { "dependencies": { "@vitest/utils": "4.1.2", "pathe": "^2.0.3" } }, "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A=="], + + "@vitest/spy": ["@vitest/spy@4.1.2", "", {}, "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA=="], + + "@vitest/utils": ["@vitest/utils@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ=="], + + "@vitest/web-worker": ["@vitest/web-worker@4.1.2", "", { "dependencies": { "obug": "^2.1.1" }, "peerDependencies": { "vitest": "4.1.2" } }, "sha512-P5XxkZuiVcN8NGePi53VS3gJUHu3J0luyPPuWBirZB3d6Tjfqy4+AW+3vCQTMr5KnEfZXBmEXWh3o9K58bSoAw=="], + "@xml-tools/parser": ["@xml-tools/parser@1.0.11", "", { "dependencies": { "chevrotain": "7.1.1" } }, "sha512-aKqQ077XnR+oQtHJlrAflaZaL7qZsulWc/i/ZEooar5JiWj1eLt0+Wg28cpa+XLney107wXqneC+oG1IZvxkTA=="], "@xmldom/xmldom": ["@xmldom/xmldom@0.7.13", "", {}, "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g=="], @@ -611,7 +650,7 @@ "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "appwrite": ["appwrite@23.0.0", "", { "dependencies": { "json-bigint": "1.0.0" } }, "sha512-K11a597npl3jsnxWKzjw163n4GguH4+/zBCOiU15yc1u+7QF0nP9mxsY4JxKrBU6bmQRtgtMTPv/6YOLSwp/QQ=="], @@ -619,6 +658,8 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], "array-ify": ["array-ify@1.0.0", "", {}, "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng=="], @@ -631,6 +672,8 @@ "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], @@ -715,6 +758,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001781", "", {}, "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chevrotain": ["chevrotain@7.1.1", "", { "dependencies": { "regexp-to-ast": "0.5.0" } }, "sha512-wy3mC1x4ye+O+QkEinVJkPf5u2vsrDIYW9G7ZuwFl6v/Yu0LwUuT2POsb+NUWApebyxfkQq6+yDfRExbnI5rcw=="], @@ -843,6 +888,8 @@ "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "dezalgo": ["dezalgo@1.0.4", "", { "dependencies": { "asap": "^2.0.0", "wrappy": "1" } }, "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig=="], @@ -851,6 +898,8 @@ "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -889,6 +938,8 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], @@ -917,7 +968,7 @@ "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], - "estree-walker": ["estree-walker@1.0.1", "", {}, "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], @@ -929,6 +980,8 @@ "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], @@ -975,7 +1028,7 @@ "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -1241,7 +1294,9 @@ "lucide-static": ["lucide-static@0.577.0", "", {}, "sha512-hx39J5Tq4JWF2ALY+5YRg+SxQLpeAmLJDXNcqiBJH/UuVwp43it9fyki/onZO7AVFgG5ZbB+fWwZR9mwGHE2XQ=="], - "magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "make-error": ["make-error@1.3.6", "", {}, "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="], @@ -1285,6 +1340,8 @@ "modify-values": ["modify-values@1.0.1", "", {}, "sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw=="], + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + "ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], @@ -1325,6 +1382,8 @@ "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -1361,6 +1420,8 @@ "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -1369,8 +1430,14 @@ "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], + + "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "pocketbase": ["pocketbase@0.26.8", "", {}, "sha512-aQ/ewvS7ncvAE8wxoW10iAZu6ElgbeFpBhKPnCfvRovNzm2gW8u/sQNPGN6vNgVEagz44kK//C61oKjfa+7Low=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], @@ -1397,6 +1464,8 @@ "pretty-bytes": ["pretty-bytes@6.1.1", "", {}, "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], @@ -1415,6 +1484,8 @@ "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "read-pkg": ["read-pkg@3.0.0", "", { "dependencies": { "load-json-file": "^4.0.0", "normalize-package-data": "^2.3.2", "path-type": "^3.0.0" } }, "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA=="], "read-pkg-up": ["read-pkg-up@3.0.0", "", { "dependencies": { "find-up": "^2.0.0", "read-pkg": "^3.0.0" } }, "sha512-YFzFrVvpC6frF1sz8psoHDBGF7fLPc+llq/8NB43oagqWkx8ar5zYtsTORtOjw9W2RHLpWP+zTWwBvf1bCmcSw=="], @@ -1499,6 +1570,8 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], @@ -1511,6 +1584,8 @@ "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], @@ -1539,6 +1614,10 @@ "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], "stream-buffers": ["stream-buffers@2.2.0", "", {}, "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg=="], @@ -1615,12 +1694,20 @@ "through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], @@ -1693,6 +1780,8 @@ "vite-plugin-pwa": ["vite-plugin-pwa@1.2.0", "", { "dependencies": { "debug": "^4.3.6", "pretty-bytes": "^6.1.1", "tinyglobby": "^0.2.10", "workbox-build": "^7.4.0", "workbox-window": "^7.4.0" }, "peerDependencies": { "@vite-pwa/assets-generator": "^1.0.0", "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@vite-pwa/assets-generator"] }, "sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw=="], + "vitest": ["vitest@4.1.2", "", { "dependencies": { "@vitest/expect": "4.1.2", "@vitest/mocker": "4.1.2", "@vitest/pretty-format": "4.1.2", "@vitest/runner": "4.1.2", "@vitest/snapshot": "4.1.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "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" }, "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.2", "@vitest/browser-preview": "4.1.2", "@vitest/browser-webdriverio": "4.1.2", "@vitest/ui": "4.1.2", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], @@ -1709,6 +1798,8 @@ "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], @@ -1881,26 +1972,36 @@ "@rollup/plugin-node-resolve/@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], + "@rollup/plugin-replace/magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="], + "@rollup/plugin-replace/rollup": ["rollup@2.80.0", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ=="], "@rollup/pluginutils/@types/estree": ["@types/estree@0.0.39", "", {}, "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@1.0.1", "", {}, "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg=="], + "@rollup/pluginutils/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "@rollup/pluginutils/rollup": ["rollup@2.80.0", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ=="], + "@surma/rollup-plugin-off-main-thread/magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="], + "@trapezedev/project/@ionic/utils-subprocess": ["@ionic/utils-subprocess@2.1.14", "", { "dependencies": { "@ionic/utils-array": "2.1.6", "@ionic/utils-fs": "3.1.7", "@ionic/utils-process": "2.1.11", "@ionic/utils-stream": "3.1.6", "@ionic/utils-terminal": "2.3.4", "cross-spawn": "^7.0.3", "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-nGYvyGVjU0kjPUcSRFr4ROTraT3w/7r502f5QJEsMRKTqa4eEzCshtwRk+/mpASm0kgBN5rrjYA5A/OZg8ahqg=="], "@trapezedev/project/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], "@trapezedev/project/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], + "@vitest/browser/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "cacheable/keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], + "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "conventional-changelog-writer/meow": ["meow@8.1.2", "", { "dependencies": { "@types/minimist": "^1.2.0", "camelcase-keys": "^6.2.2", "decamelize-keys": "^1.1.0", "hard-rejection": "^2.1.0", "minimist-options": "4.1.0", "normalize-package-data": "^3.0.0", "read-pkg-up": "^7.0.1", "redent": "^3.0.0", "trim-newlines": "^3.0.0", "type-fest": "^0.18.0", "yargs-parser": "^20.2.3" } }, "sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q=="], "conventional-changelog-writer/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -2281,10 +2382,14 @@ "replace/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="], "simple-swizzle/is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], + "slice-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "stylelint/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "stylelint/file-entry-cache": ["file-entry-cache@11.1.2", "", { "dependencies": { "flat-cache": "^6.1.20" } }, "sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log=="], @@ -2299,6 +2404,8 @@ "ts-node/diff": ["diff@4.0.4", "", {}, "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ=="], + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "vite-plugin-pwa/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "workbox-build/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], @@ -2313,6 +2420,8 @@ "workbox-build/tempy": ["tempy@0.6.0", "", { "dependencies": { "is-stream": "^2.0.0", "temp-dir": "^2.0.0", "type-fest": "^0.16.0", "unique-string": "^2.0.0" } }, "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw=="], + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "write-file-atomic/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "xcode/uuid": ["uuid@7.0.3", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="], @@ -2365,8 +2474,14 @@ "@ionic/utils-terminal/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "@rollup/plugin-babel/rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "@rollup/plugin-node-resolve/@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@rollup/plugin-replace/rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "@rollup/pluginutils/rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "@trapezedev/project/@ionic/utils-subprocess/@ionic/utils-process": ["@ionic/utils-process@2.1.11", "", { "dependencies": { "@ionic/utils-object": "2.1.6", "@ionic/utils-terminal": "2.3.4", "debug": "^4.0.0", "signal-exit": "^3.0.3", "tree-kill": "^1.2.2", "tslib": "^2.0.1" } }, "sha512-Uavxn+x8j3rDlZEk1X7YnaN6wCgbCwYQOeIjv/m94i1dzslqWhqIHEqxEyeE8HsT5Negboagg7GtQiABy+BLbA=="], "@trapezedev/project/@ionic/utils-subprocess/@ionic/utils-stream": ["@ionic/utils-stream@3.1.6", "", { "dependencies": { "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-4+Kitey1lTA1yGtnigeYNhV/0tggI3lWBMjC7tBs1K9GXa/q7q4CtOISppdh8QgtOhrhAXS2Igp8rbko/Cj+lA=="], @@ -2471,6 +2586,8 @@ "workbox-build/glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + "workbox-build/rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "@capacitor/assets/@capacitor/cli/@ionic/utils-subprocess/@ionic/utils-process": ["@ionic/utils-process@2.1.11", "", { "dependencies": { "@ionic/utils-object": "2.1.6", "@ionic/utils-terminal": "2.3.4", "debug": "^4.0.0", "signal-exit": "^3.0.3", "tree-kill": "^1.2.2", "tslib": "^2.0.1" } }, "sha512-Uavxn+x8j3rDlZEk1X7YnaN6wCgbCwYQOeIjv/m94i1dzslqWhqIHEqxEyeE8HsT5Negboagg7GtQiABy+BLbA=="], "@capacitor/assets/@capacitor/cli/@ionic/utils-subprocess/@ionic/utils-stream": ["@ionic/utils-stream@3.1.6", "", { "dependencies": { "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-4+Kitey1lTA1yGtnigeYNhV/0tggI3lWBMjC7tBs1K9GXa/q7q4CtOISppdh8QgtOhrhAXS2Igp8rbko/Cj+lA=="], @@ -2585,6 +2702,8 @@ "replace/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + "replace/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "replace/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "workbox-build/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], diff --git a/js/HiFi.test.ts b/js/HiFi.test.ts new file mode 100644 index 0000000..7cf99f9 --- /dev/null +++ b/js/HiFi.test.ts @@ -0,0 +1,199 @@ +import { expect, suite, test } from 'vitest'; +import { HiFiClient, TidalResponse } from './HiFi'; + +const ARTIST_ID = 3523908; // deadmau5 +const ALBUM_ID = 433360012; // deadmau5 - 4x4=12 +const ALBUM_ATMOS = 463900719; // Taylor Swift - The Life of a Showgirl +const TRACK_ATMOS = 463900720; // Taylor Swift - The Fate of Ophelia +const TRACK_NO_LOSSLESS = 31097959; // deadmau5 - while(1<2) +const TRACK_VIDEO = 466464180; // Taylow Swift - The Fate of Ophelia +const TRACK_LOSSLESS = 31097949; // deadmau5 - Avaritia +const PLAYLIST_ID = '36ea71a8-445e-41a4-82ab-6628c581535d'; // Pop Hits + +const instance = new HiFiClient(); +await instance.fetchToken(); + +function checkVersion({ version }: { version?: string }) { + expect(version).toBeTypeOf('string'); + expect(version).not.equals(''); + expect(version).equals(HiFiClient.API_VERSION); +} + +async function getJson(res: Response | Promise) { + res = await res; + expect(res).toBeInstanceOf(Response); + expect(res.ok).toBeTruthy(); + return await res.json(); +} + +async function checkRoute( + route: string, + routeResult: () => Promise, + checks: (data: any) => Promise, + mainKey: string | null = 'data' +) { + const routeData = await instance.query(route); + const routeRes = await routeResult(); + expect(routeData).toBeInstanceOf(TidalResponse); + expect(routeData).toEqual(routeRes); + + const json = await routeData.json(); + checkVersion(json); + + if (mainKey != null) { + expect(json).toHaveProperty(mainKey); + expect(json[mainKey]).not.toBeUndefined(); + } + + await checks(json); +} + +test('Get token', async () => { + const instance = new HiFiClient(); + + const token = await instance.fetchToken(); + expect(token).toBeTypeOf('string'); + expect(token).not.toBeUndefined(); + expect(token).not.length(0); + expect(token).equals(instance.token); + + const token2 = await instance.fetchToken(true); + expect(token2).toBeTypeOf('string'); + expect(token2).not.toBeUndefined(); + expect(token2).not.length(0); + expect(token2).equals(instance.token); + expect(token2).not.equals(token); + + expect(instance.appTokenExpiry).toBeGreaterThan(Date.now()); +}); + +test('Fetch atmos track info', async () => { + await checkRoute( + `/info/?id=${TRACK_ATMOS}`, + () => instance.getInfo(TRACK_ATMOS), + async (info) => { + expect(info.data.audioModes).toContain('DOLBY_ATMOS'); + } + ); +}); + +test('Fetch track', async () => { + await checkRoute( + `/track/?id=${TRACK_LOSSLESS}`, + () => instance.getTrack(TRACK_LOSSLESS), + async (track) => { + expect(track.data.trackId).toBe(TRACK_LOSSLESS); + expect(track.data.assetPresentation).toBeTypeOf('string'); + expect(track.data.audioQuality).toBeTypeOf('string'); + expect(track.data.manifestMimeType).toBeTypeOf('string'); + expect(track.data.manifestHash).toBeTypeOf('string'); + expect(track.data.manifest).toBeTypeOf('string'); + expect(track.data.albumReplayGain).toBeTypeOf('number'); + expect(track.data.albumPeakAmplitude).toBeTypeOf('number'); + expect(track.data.trackReplayGain).toBeTypeOf('number'); + expect(track.data.trackPeakAmplitude).toBeTypeOf('number'); + expect(track.data.bitDepth).toBeTypeOf('number'); + expect(track.data.sampleRate).toBeTypeOf('number'); + } + ); +}); + +test.skipIf(!instance.refreshToken)('Fetch recommendations', async () => { + await checkRoute( + `/recommendations/?id=${ARTIST_ID}`, + () => instance.getRecommendations(ARTIST_ID), + async (rec) => {} + ); +}); + +test('Fetch similar artists', async () => { + await checkRoute( + `/artist/similar/?id=${ARTIST_ID}`, + () => instance.getSimilarArtists(ARTIST_ID), + async (rec) => {}, + 'artists' + ); +}); + +test('Fetch similar albums', async () => { + await checkRoute( + `/album/similar/?id=${ALBUM_ID}`, + () => instance.getSimilarAlbums(ALBUM_ID), + async (rec) => {}, + 'albums' + ); +}); + +test('Fetch artist info', async () => { + await checkRoute( + `/artist/?id=${ARTIST_ID}`, + () => instance.getArtist(ARTIST_ID), + async (info) => { + expect(info).toHaveProperty('cover'); + expect(info.cover).not.toBeUndefined(); + }, + 'artist' + ); +}); + +test('Search', async () => { + const query = 'deadmau5'; + await checkRoute( + `/search/?q=${encodeURIComponent(query)}`, + () => + instance.search({ + q: query, + }), + async (res) => {} + ); +}); + +test('Fetch album info', async () => { + await checkRoute( + `/album/?id=${ALBUM_ID}`, + () => instance.getAlbum(ALBUM_ID), + async (info) => { + expect(info.data).toHaveProperty('cover'); + expect(info.data.cover).not.toBeUndefined(); + } + ); +}); + +test('Fetch playlist info', async () => { + await checkRoute( + `/playlist/?id=${PLAYLIST_ID}`, + () => instance.getPlaylist(PLAYLIST_ID), + async (info) => { + expect(info.playlist).toHaveProperty('image'); + expect(info.playlist.image).not.toBeUndefined(); + }, + 'playlist' + ); +}); + +test.skipIf(!instance.refreshToken)('Fetch lyrics ', async () => { + await checkRoute( + `/lyrics/?id=${TRACK_ATMOS}`, + () => instance.getLyrics(TRACK_ATMOS), + async (info) => {}, + 'lyrics' + ); +}); + +test('Fetch video ', async () => { + await checkRoute( + `/video/?id=${TRACK_VIDEO}`, + () => instance.getVideo(TRACK_VIDEO), + async (info) => {}, + 'video' + ); +}); + +test('Fetch track manifests ', async () => { + await checkRoute( + `/trackManifests/?id=${TRACK_LOSSLESS}`, + () => instance.getTrackManifest(TRACK_LOSSLESS), + async (info) => {}, + 'data' + ); +}); diff --git a/js/HiFi.ts b/js/HiFi.ts index 85dced6..d87a7ae 100644 --- a/js/HiFi.ts +++ b/js/HiFi.ts @@ -1,9 +1,5 @@ import { EventEmitter } from 'events'; -const API_VERSION = '2.7'; -const BROWSER_CLIENT_ID = 'txNoH4kkV41MfH25'; -const BROWSER_CLIENT_SECRET = 'dQjy0MinCEvxi1O4UmxvxWnDjt4cgHBPw8ll6nYBk98='; - type Params = Record; class ResponseError extends Error { @@ -37,6 +33,10 @@ export enum HiFiClientEvents { } class HiFiClient { + static readonly API_VERSION = '2.7'; + static readonly BROWSER_CLIENT_ID = 'txNoH4kkV41MfH25'; + static readonly BROWSER_CLIENT_SECRET = 'dQjy0MinCEvxi1O4UmxvxWnDjt4cgHBPw8ll6nYBk98='; + static #instance: HiFiClient | null = null; static get instance() { if (!HiFiClient.#instance) { @@ -158,8 +158,8 @@ class HiFiClient { } async #fetchAppToken({ - clientId = BROWSER_CLIENT_ID, - clientSecret = BROWSER_CLIENT_SECRET, + clientId = HiFiClient.BROWSER_CLIENT_ID, + clientSecret = HiFiClient.BROWSER_CLIENT_SECRET, refreshToken, scope = 'r_usr+w_usr+w_sub', signal = new AbortController().signal, @@ -218,8 +218,8 @@ class HiFiClient { static #getOptions({ countryCode = 'US', baseUrl = null, - clientId = BROWSER_CLIENT_ID, - clientSecret = BROWSER_CLIENT_SECRET, + clientId = HiFiClient.BROWSER_CLIENT_ID, + clientSecret = HiFiClient.BROWSER_CLIENT_SECRET, token, tokenExpiry, refreshToken: tokenRefresh, @@ -228,6 +228,16 @@ class HiFiClient { return { countryCode, baseUrl, clientId, clientSecret, token, tokenExpiry, tokenRefresh, storage }; } + async fetchToken(force: boolean = false, signal: AbortSignal | undefined = undefined) { + return await this.#fetchAppToken({ + clientId: this.#clientId, + clientSecret: this.#clientSecret, + signal, + refreshToken: this.refreshToken || undefined, + force: !!force, + }); + } + async #fetchAuthenticated( url: string, params?: Params | URLSearchParams, @@ -334,7 +344,7 @@ class HiFiClient { async getInfo(id: number, signal?: AbortSignal) { const url = `https://api.tidal.com/v1/tracks/${id}/`; const data = await this.#fetchJson(url, { countryCode: this.#countryCode }, signal); - return HiFiClient.#jsonResponse({ version: API_VERSION, data }); + return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data }); } async getTrack(id: number, quality = 'HI_RES_LOSSLESS', immersiveAudio: boolean = false, signal?: AbortSignal) { @@ -347,7 +357,7 @@ class HiFiClient { immersiveAudio: String(immersiveAudio), }; const data = await this.#fetchJson(url, params, signal); - return HiFiClient.#jsonResponse({ version: API_VERSION, data }); + return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data }); } async getTrackManifest( @@ -382,7 +392,7 @@ class HiFiClient { drmData.certificateUrl = url; } - return HiFiClient.#jsonResponse({ version: API_VERSION, data: res }); + return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: res }); } async getWidevine() { @@ -392,7 +402,7 @@ class HiFiClient { async getRecommendations(id: number, signal?: AbortSignal) { const url = `https://api.tidal.com/v1/tracks/${id}/recommendations`; const data = await this.#fetchJson(url, { limit: '20', countryCode: this.#countryCode }, signal); - return HiFiClient.#jsonResponse({ version: API_VERSION, data }); + return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data }); } async getSimilarArtists(id: number, cursor?: string | number | null, signal?: AbortSignal) { @@ -436,7 +446,10 @@ class HiFiClient { }; }; - return HiFiClient.#jsonResponse({ version: API_VERSION, artists: (payload?.data || []).map(resolveArtist) }); + return HiFiClient.#jsonResponse({ + version: HiFiClient.API_VERSION, + artists: (payload?.data || []).map(resolveArtist), + }); } async getSimilarAlbums(id: number, cursor?: string | number | null, signal?: AbortSignal) { @@ -497,7 +510,10 @@ class HiFiClient { }; }; - return HiFiClient.#jsonResponse({ version: API_VERSION, albums: (payload?.data || []).map(resolveAlbum) }); + return HiFiClient.#jsonResponse({ + version: HiFiClient.API_VERSION, + albums: (payload?.data || []).map(resolveAlbum), + }); } async getArtist( @@ -530,7 +546,7 @@ class HiFiClient { }; } - return HiFiClient.#jsonResponse({ version: API_VERSION, artist: artist_data, cover }); + return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, artist: artist_data, cover }); } // f provided -> gather albums and optionally tracks @@ -584,10 +600,11 @@ class HiFiClient { } } - return HiFiClient.#jsonResponse({ version: API_VERSION, albums: page_data, tracks: top_tracks }); + return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, albums: page_data, tracks: top_tracks }); } - if (!album_ids.length) return HiFiClient.#jsonResponse({ version: API_VERSION, albums: page_data, tracks: [] }); + if (!album_ids.length) + return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, albums: page_data, tracks: [] }); const fetchAlbumTracks = async (album_id: number) => { return await this.#withAlbumTrackSlot(async () => { @@ -613,7 +630,7 @@ class HiFiClient { if (Array.isArray(t)) tracks.push(...t); } - return HiFiClient.#jsonResponse({ version: API_VERSION, albums: page_data, tracks }); + return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, albums: page_data, tracks }); } #buildCoverEntry(cover_slug: string, name?: string | null, track_id?: number | null) { @@ -640,7 +657,7 @@ class HiFiClient { const cover_slug = album.cover; if (!cover_slug) throw new ResponseError(404, 'Cover not found'); const entry = this.#buildCoverEntry(cover_slug, album.title || track_data.title, album.id || id); - return HiFiClient.#jsonResponse({ version: API_VERSION, covers: [entry] }); + return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, covers: [entry] }); } const search_data = await this.#fetchJson( @@ -658,7 +675,7 @@ class HiFiClient { covers.push(this.#buildCoverEntry(cover_slug, track.title, track.id)); } if (!covers.length) throw new ResponseError(404, 'Cover not found'); - return HiFiClient.#jsonResponse({ version: API_VERSION, covers }); + return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, covers }); } async search( @@ -690,7 +707,7 @@ class HiFiClient { }, signal ); - return HiFiClient.#jsonResponse({ version: API_VERSION, data: res }); + return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: res }); } catch (err: any) { if (err.status && ![400, 404].includes(err.status)) throw err; // fallback to text search @@ -705,7 +722,7 @@ class HiFiClient { }, signal ); - return HiFiClient.#jsonResponse({ version: API_VERSION, data: fallback }); + return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: fallback }); } const mapping: Array<[string | undefined, string, Params]> = [ @@ -746,7 +763,7 @@ class HiFiClient { for (const [val, url, params] of mapping) { if (val) { const data = await this.#fetchJson(url, params, signal); - return HiFiClient.#jsonResponse({ version: API_VERSION, data }); + return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data }); } } @@ -783,7 +800,7 @@ class HiFiClient { if (Array.isArray(pageItems)) allItems.push(...pageItems); } albumData.items = allItems; - return HiFiClient.#jsonResponse({ version: API_VERSION, data: albumData }); + return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: albumData }); } async getMix(id: string, signal?: AbortSignal) { @@ -803,7 +820,7 @@ class HiFiClient { } } return HiFiClient.#jsonResponse({ - version: API_VERSION, + version: HiFiClient.API_VERSION, mix: header, items: items.map((it: any) => (it.item ? it.item : it)), }); @@ -817,7 +834,7 @@ class HiFiClient { this.#fetchJson(itemsUrl, { countryCode: this.#countryCode, limit, offset }, signal), ]); const items = (itemsData && itemsData.items) || itemsData; - return HiFiClient.#jsonResponse({ version: API_VERSION, playlist: playlistData, items }); + return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, playlist: playlistData, items }); } // simplified artist/cover/lyrics/video/topvideos/similar methods (same pattern) @@ -833,7 +850,7 @@ class HiFiClient { err.status = 404; throw err; } - return HiFiClient.#jsonResponse({ version: API_VERSION, lyrics: data }); + return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, lyrics: data }); } async getVideo(id: number, quality = 'HIGH', mode = 'STREAM', presentation = 'FULL', signal?: AbortSignal) { @@ -843,7 +860,7 @@ class HiFiClient { { videoquality: quality, playbackmode: mode, assetpresentation: presentation }, signal ); - return HiFiClient.#jsonResponse({ version: API_VERSION, video: data }); + return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, video: data }); } async getTopVideos( @@ -867,7 +884,7 @@ class HiFiClient { } } return HiFiClient.#jsonResponse({ - version: API_VERSION, + version: HiFiClient.API_VERSION, videos: videos.slice(offset, offset + limit), total: videos.length, }); @@ -886,7 +903,10 @@ class HiFiClient { switch (pathname) { case '/': return new TidalResponse( - HiFiClient.#jsonResponse({ version: API_VERSION, Repo: 'https://github.com/binimum/hifi-api' }) + HiFiClient.#jsonResponse({ + version: HiFiClient.API_VERSION, + Repo: 'https://github.com/binimum/hifi-api', + }) ); case '/info': return new TidalResponse(await this.getInfo(Number(qp.id))); diff --git a/js/api.test.ts b/js/api.test.ts new file mode 100644 index 0000000..2320b5f --- /dev/null +++ b/js/api.test.ts @@ -0,0 +1,457 @@ +import { expect, test, suite, vi } from 'vitest'; +import { apiSettings, preferDolbyAtmosSettings, losslessContainerSettings } from './storage.js'; +import { MusicAPI } from './music-api.js'; +import { LyricsManager } from './lyrics.js'; +import type { LosslessAPI } from './api.js'; +import { HiFiClient } from './HiFi.js'; +import { FileRef } from '!/@dantheman827/taglib-ts/src/fileRef.js'; +import { Mp4File } from '!/@dantheman827/taglib-ts/src/mp4/mp4File.js'; +import { MpegFile } from '!/@dantheman827/taglib-ts/src/mpeg/mpegFile.js'; +import { FlacFile } from '!/@dantheman827/taglib-ts/src/flac/flacFile.js'; +import { Mp4Atom, Mp4Atoms } from '!/@dantheman827/taglib-ts/src/mp4/mp4Atoms.js'; +import { ByteVector, StringType } from '!/@dantheman827/taglib-ts/src/byteVector.js'; +import { Mp4Codec } from '!/@dantheman827/taglib-ts/src/mp4/mp4Properties.js'; +import { OggFile } from '!/@dantheman827/taglib-ts/src/ogg/oggFile.js'; +import { ffmpeg } from './ffmpeg.js'; + +vi.mock(import('./storage.js'), async (importOriginal) => { + const mod = await importOriginal(); + + return { + ...mod, + preferDolbyAtmosSettings: { + ...mod.preferDolbyAtmosSettings, + isEnabled: vi.fn(), + setEnabled: vi.fn(), + }, + losslessContainerSettings: { + ...mod.losslessContainerSettings, + getContainer: vi.fn(), + setContainer: vi.fn(), + }, + }; +}); + +vi.mock(import('./ffmpeg.js'), async (importOriginal) => { + const mod = await importOriginal(); + + return { + ...mod, + ffmpeg: vi.fn(mod.ffmpeg), + }; +}); + +vi.mock(import('./doTimed.js'), async (importOriginal) => { + const mod = await importOriginal(); + + return { + ...mod, + doTimed: (label: string, fn: () => any) => { + return fn() as any; + }, + doTimedAsync ? Promise : T>( + message: string, + callback: () => R, + throwError: boolean = false + ): R { + return new Promise(async (resolve, reject) => { + try { + const ret = await callback(); + resolve(ret); + } catch (err) { + if (throwError) { + reject(err); + return; + } + + resolve(undefined); + } + }) as R; + }, + } satisfies typeof import('./doTimed.js'); +}); + +vi.spyOn(console, 'error').mockImplementation(() => {}); +vi.spyOn(console, 'log').mockImplementation(() => {}); +vi.spyOn(console, 'warn').mockImplementation(() => {}); + +enum Detection { + DolbyAtmos, + FlacHD, + FlacLossless, + AlacHD, + AlacLossless, + Mp4Flac, + AacLow, + AacReallyLow, + AacHigh, + AAC_256, + MP3_320, + MP3_256, + MP3_128, + OGG_320, + OGG_256, + OGG_128, +} + +suite('Track Downloads', async () => { + const SILENCE_TRACK = 46022548; + const TRACK_ATMOS = 463900720; // Taylor Swift - The Fate of Ophelia + const TRACK_NO_LOSSLESS = 31097959; // deadmau5 - while(1<2) + + const { LosslessAPI } = await import('./api.js'); + await MusicAPI.initialize(apiSettings); + await LyricsManager.initialize(apiSettings); + await HiFiClient.initialize(); + + const api: LosslessAPI = MusicAPI.instance.tidalAPI; + + async function downloadTrack(trackId: number, quality: string) { + const track = await (await HiFiClient.instance.getInfo(trackId)).json(); + return await api.downloadTrack(trackId.toString(), quality, undefined, { + track: track.data, + triggerDownload: false, + }); + } + + test.beforeEach(() => { + vi.clearAllMocks(); + }); + + test.each([ + { + display_quality: 'Dolby Atmos', + quality: 'HI_RES_LOSSLESS', + container: 'flac', + preferDolbyAtmos: true, + trackId: TRACK_ATMOS, + detection: Detection.DolbyAtmos, + ffmpegCalls: 0, + }, + { + display_quality: 'HD Lossless (FLAC)', + quality: 'HI_RES_LOSSLESS', + container: 'flac', + preferDolbyAtmos: false, + trackId: SILENCE_TRACK, + detection: Detection.FlacHD, + ffmpegCalls: 1, + }, + { + display_quality: 'Lossless (FLAC)', + quality: 'LOSSLESS', + container: 'flac', + preferDolbyAtmos: false, + trackId: SILENCE_TRACK, + detection: Detection.FlacLossless, + ffmpegCalls: 0, + }, + { + display_quality: 'HD Lossless (ALAC)', + quality: 'HI_RES_LOSSLESS', + container: 'alac', + preferDolbyAtmos: false, + trackId: SILENCE_TRACK, + detection: Detection.AlacHD, + ffmpegCalls: 1, + }, + { + display_quality: 'Lossless (ALAC)', + quality: 'LOSSLESS', + container: 'alac', + preferDolbyAtmos: false, + trackId: SILENCE_TRACK, + detection: Detection.AlacLossless, + ffmpegCalls: 1, + }, + { + display_quality: 'HD Lossless (Unchanged)', + quality: 'HI_RES_LOSSLESS', + container: 'nochange', + preferDolbyAtmos: false, + trackId: SILENCE_TRACK, + detection: Detection.Mp4Flac, + ffmpegCalls: 0, + }, + { + display_quality: 'Lossless (Unchanged)', + quality: 'LOSSLESS', + container: 'nochange', + preferDolbyAtmos: false, + trackId: SILENCE_TRACK, + detection: Detection.FlacLossless, + ffmpegCalls: 0, + }, + { + display_quality: 'Lossless, but not really', + quality: 'HI_RES_LOSSLESS', + container: 'flac', + preferDolbyAtmos: false, + trackId: TRACK_NO_LOSSLESS, + detection: Detection.AacReallyLow, + ffmpegCalls: 0, + }, + { + display_quality: 'High', + quality: 'HIGH', + container: 'flac', + preferDolbyAtmos: false, + trackId: SILENCE_TRACK, + detection: Detection.AacHigh, + ffmpegCalls: 0, + }, + { + display_quality: 'Low', + quality: 'LOW', + container: 'flac', + preferDolbyAtmos: false, + trackId: SILENCE_TRACK, + detection: Detection.AacLow, + ffmpegCalls: 0, + }, + + { + display_quality: 'AAC 256', + quality: 'FFMPEG_AAC_256', + container: 'flac', + preferDolbyAtmos: false, + trackId: TRACK_ATMOS, + detection: Detection.AAC_256, + ffmpegCalls: 1, + }, + + { + display_quality: 'MP3 320', + quality: 'FFMPEG_MP3_320', + container: 'flac', + preferDolbyAtmos: false, + trackId: SILENCE_TRACK, + detection: Detection.MP3_320, + ffmpegCalls: 1, + }, + { + display_quality: 'MP3 256', + quality: 'FFMPEG_MP3_256', + container: 'flac', + preferDolbyAtmos: false, + trackId: SILENCE_TRACK, + detection: Detection.MP3_256, + ffmpegCalls: 1, + }, + { + display_quality: 'MP3 128', + quality: 'FFMPEG_MP3_128', + container: 'flac', + preferDolbyAtmos: false, + trackId: SILENCE_TRACK, + detection: Detection.MP3_128, + ffmpegCalls: 1, + }, + + { + display_quality: 'OGG 320', + quality: 'FFMPEG_OGG_320', + container: 'flac', + preferDolbyAtmos: false, + trackId: SILENCE_TRACK, + detection: Detection.OGG_320, + ffmpegCalls: 1, + }, + { + display_quality: 'OGG 256', + quality: 'FFMPEG_OGG_256', + container: 'flac', + preferDolbyAtmos: false, + trackId: SILENCE_TRACK, + detection: Detection.OGG_256, + ffmpegCalls: 1, + }, + { + display_quality: 'OGG 128', + quality: 'FFMPEG_OGG_128', + container: 'flac', + preferDolbyAtmos: false, + trackId: SILENCE_TRACK, + detection: Detection.OGG_128, + ffmpegCalls: 1, + }, + ])('$display_quality', async ({ quality, container, preferDolbyAtmos, trackId, detection, ffmpegCalls }) => { + vi.mocked(preferDolbyAtmosSettings.isEnabled).mockReturnValue(preferDolbyAtmos); + vi.mocked(losslessContainerSettings.getContainer).mockReturnValue(container); + + const blob = await downloadTrack(trackId, quality); + expect(ffmpeg).toHaveBeenCalledTimes(ffmpegCalls); + const file = await FileRef.fromBlob(blob); + const stream = file.file().stream(); + + expect(file.isValid).toBe(true); + + let trak: Mp4Atom | null = null; + let stsd: Mp4Atom | null = null; + let stsdData: ByteVector | null = null; + + const streamPosition = await stream.tell(); + + if (file.file() instanceof Mp4File) { + const atoms = await Mp4Atoms.create(stream); + const moov = atoms.find('moov'); + expect(moov).not.toBeNull(); + + let trak: Mp4Atom | null = null; + let data: ByteVector; + + const trakList = moov.findAll('trak'); + for (const track of trakList) { + const hdlr = track.find('mdia', 'hdlr'); + if (!hdlr) continue; + trak = track; + await stream.seek(hdlr.offset); + data = await stream.readBlock(hdlr.length); + if (data.containsAt(ByteVector.fromString('soun', StringType.Latin1), 16)) { + break; + } + trak = null; + } + expect(trak).toBeInstanceOf(Mp4Atom); + stsd = trak!.find('mdia', 'minf', 'stbl', 'stsd'); + expect(stsd).toBeInstanceOf(Mp4Atom); + await stream.seek(stsd.offset); + stsdData = await stream.readBlock(stsd.length); + } + + stream.seek(streamPosition); + + switch (detection) { + case Detection.DolbyAtmos: { + expect(file.file()).toBeInstanceOf(Mp4File); + const codec = stsdData.toString().substring(20, 24); + expect(codec).toBe('ec-3'); + break; + } + case Detection.FlacHD: { + expect(file.file()).toBeInstanceOf(FlacFile); + const flac = file.file() as FlacFile; + expect(flac.audioProperties().bitsPerSample).toBe(24); + expect(flac.audioProperties().sampleRate).toBe(176400); + break; + } + case Detection.FlacLossless: { + expect(file.file()).toBeInstanceOf(FlacFile); + const flac = file.file() as FlacFile; + expect(flac.audioProperties().bitsPerSample).toBe(16); + expect(flac.audioProperties().sampleRate).toBe(44100); + break; + } + case Detection.Mp4Flac: { + expect(file.file()).toBeInstanceOf(Mp4File); + const codec = stsdData.toString().substring(20, 24); + expect(codec).toBe('fLaC'); + break; + } + case Detection.AlacHD: { + expect(file.file()).toBeInstanceOf(Mp4File); + const mp4 = file.file() as Mp4File; + expect(mp4.audioProperties().codec).toBe(Mp4Codec.ALAC); + expect(mp4.audioProperties().bitsPerSample).toBe(24); + expect(mp4.audioProperties().sampleRate).toBe(176400); + break; + } + case Detection.AlacLossless: { + expect(file.file()).toBeInstanceOf(Mp4File); + const mp4 = file.file() as Mp4File; + expect(mp4.audioProperties().codec).toBe(Mp4Codec.ALAC); + expect(mp4.audioProperties().bitsPerSample).toBe(16); + expect(mp4.audioProperties().sampleRate).toBe(44100); + break; + } + case Detection.AacLow: { + expect(file.file()).toBeInstanceOf(Mp4File); + const mp4 = file.file() as Mp4File; + expect(mp4.audioProperties().codec).toBe(Mp4Codec.AAC); + expect(mp4.audioProperties().bitsPerSample).toBe(16); + expect(mp4.audioProperties().sampleRate).toBe(44100); + expect(mp4.audioProperties().bitrate).toBe(97); + break; + } + case Detection.AacReallyLow: { + expect(file.file()).toBeInstanceOf(Mp4File); + const mp4 = file.file() as Mp4File; + expect(mp4.audioProperties().codec).toBe(Mp4Codec.AAC); + expect(mp4.audioProperties().bitsPerSample).toBe(16); + expect(mp4.audioProperties().sampleRate).toBe(22050); + expect(mp4.audioProperties().bitrate).toBe(97); + break; + } + case Detection.AacHigh: { + expect(file.file()).toBeInstanceOf(Mp4File); + const mp4 = file.file() as Mp4File; + expect(mp4.audioProperties().codec).toBe(Mp4Codec.AAC); + expect(mp4.audioProperties().bitsPerSample).toBe(16); + expect(mp4.audioProperties().sampleRate).toBe(44100); + expect(mp4.audioProperties().bitrate).toBe(322); + break; + } + + case Detection.AAC_256: { + expect(file.file()).toBeInstanceOf(Mp4File); + const mp4 = file.file() as Mp4File; + expect(mp4.audioProperties().codec).toBe(Mp4Codec.AAC); + expect(mp4.audioProperties().bitsPerSample).toBe(16); + expect(mp4.audioProperties().sampleRate).toBe(44100); + expect(mp4.audioProperties().bitrate).toBe(263); + break; + } + + case Detection.MP3_320: { + expect(file.file()).toBeInstanceOf(MpegFile); + const mp3 = file.file() as MpegFile; + expect(mp3.audioProperties().sampleRate).toBe(44100); + expect(mp3.audioProperties().bitrate).toBe(322); + break; + } + + case Detection.MP3_256: { + expect(file.file()).toBeInstanceOf(MpegFile); + const mp3 = file.file() as MpegFile; + expect(mp3.audioProperties().sampleRate).toBe(44100); + expect(mp3.audioProperties().bitrate).toBe(258); + break; + } + + case Detection.MP3_128: { + expect(file.file()).toBeInstanceOf(MpegFile); + const mp3 = file.file() as MpegFile; + expect(mp3.audioProperties().sampleRate).toBe(44100); + expect(mp3.audioProperties().bitrate).toBe(129); + break; + } + + case Detection.OGG_320: { + expect(file.file()).toBeInstanceOf(OggFile); + const ogg = file.file() as OggFile; + expect(ogg.audioProperties().sampleRate).toBe(44100); + //expect(ogg.audioProperties().bitrate).toBe(320); + break; + } + + case Detection.OGG_256: { + expect(file.file()).toBeInstanceOf(OggFile); + const ogg = file.file() as OggFile; + expect(ogg.audioProperties().sampleRate).toBe(44100); + //expect(ogg.audioProperties().bitrate).toBe(256); + break; + } + + case Detection.OGG_128: { + expect(file.file()).toBeInstanceOf(OggFile); + const ogg = file.file() as OggFile; + expect(ogg.audioProperties().sampleRate).toBe(44100); + //expect(ogg.audioProperties().bitrate).toBe(128); + break; + } + + default: + throw new Error('Unknown detection type'); + } + }); +}); diff --git a/js/ffmpeg.js b/js/ffmpeg.js index 80f1012..e01157c 100644 --- a/js/ffmpeg.js +++ b/js/ffmpeg.js @@ -49,7 +49,8 @@ async function ffmpegWorker( onProgress = null, signal = null, extraFiles = [], - logConsole = true + logConsole = true, + rawArgs = false ) { const audioData = audioBlob ? await audioBlob.arrayBuffer() : null; const assets = loadFfmpeg(); @@ -128,7 +129,7 @@ async function ffmpegWorker( { audioData, extraFiles, - args, + ...(rawArgs ? { rawArgs: args } : { args }), output: { name: outputName, mime: outputMime, @@ -153,6 +154,7 @@ async function ffmpegWorker( * @param {AbortSignal|null} [opts.signal=null] - Optional abort signal to cancel encoding * @param {Array} [opts.extraFiles=[]] - Additional files to provide to FFmpeg * @param {Boolean} [opts.logConsole=true] - Whether to log FFmpeg output to the console + * @param {string[]} [opts.rawArgs=[]] - Whether to pass args as raw command line (without default input/output) * @returns {Promise} Encoded audio blob * @throws {FfmpegError} If Web Workers are not available * @throws {Error} If FFmpeg encoding fails @@ -167,6 +169,7 @@ export async function ffmpeg( signal = null, extraFiles = [], logConsole = true, + rawArgs = null, } = {} ) { try { @@ -174,13 +177,14 @@ export async function ffmpeg( if (typeof Worker !== 'undefined') { return await ffmpegWorker( audioBlob, - args, + rawArgs || args, outputName, outputMime, onProgress, signal, extraFiles, - logConsole + logConsole, + !!rawArgs ); } diff --git a/js/ffmpeg.test.ts b/js/ffmpeg.test.ts new file mode 100644 index 0000000..547b513 --- /dev/null +++ b/js/ffmpeg.test.ts @@ -0,0 +1,19 @@ +import { expect, test, suite } from 'vitest'; +import { ffmpeg } from './ffmpeg'; + +test('Run `ffmpeg --help`', async () => { + const lines: string[] = []; + const info = await ffmpeg(null, { + rawArgs: ['--help'], + logConsole: false, + outputName: null, + onProgress: (progress) => { + if (progress.stage == 'stdout') { + lines.push(progress.message); + } + }, + }); + + expect(lines).length.greaterThan(0); + expect(lines[0]).matches(/ffmpeg version/i); +}); diff --git a/js/ffmpeg.worker.js b/js/ffmpeg.worker.js index 8da739d..8276cd0 100644 --- a/js/ffmpeg.worker.js +++ b/js/ffmpeg.worker.js @@ -1,4 +1,4 @@ -import { FFmpeg } from '@ffmpeg/ffmpeg'; +import { FFmpeg } from '!/@ffmpeg/ffmpeg/dist/esm/classes.js'; let ffmpeg = null; let loadingPromise = null; @@ -99,6 +99,7 @@ self.onmessage = async (e) => { const { audioData, extraFiles = [], + rawArgs, args = [], output = { name: 'output', @@ -123,7 +124,7 @@ self.onmessage = async (e) => { await ffmpeg.writeFile(file.name, new Uint8Array(file.data)); } - const ffmpegArgs = ['-i', 'input', ...args, ...(output.name ? [output.name] : [])]; + const ffmpegArgs = rawArgs || ['-i', 'input', ...args, ...(output.name ? [output.name] : [])]; self.postMessage({ type: 'command', command: ffmpegArgs }); const exitCode = await ffmpeg.exec(ffmpegArgs); diff --git a/package.json b/package.json index b6ced34..be101bb 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,11 @@ "description": "[\"Monochrome](https://monochrome.tf)", "scripts": { "dev": "vite", + "test": "vitest run --config=vite.config.ts", + "test:headless": "HEADLESS=true vitest run --config=vite.config.ts", + "test:watch": "vitest --config=vite.config.ts", + "test:watch:headless": "HEADLESS=true vitest --config=vite.config.ts", + "install:playwright": "playwright install chromium", "build": "vite build", "postbuild": "node -e \"const fs = require('fs'); const path = require('path'); const src = 'extensions'; const dest = path.join('dist', 'Monochrome', 'extensions'); if (fs.existsSync(src)) { fs.mkdirSync(dest, { recursive: true }); fs.cpSync(src, dest, { recursive: true }); console.log('Extensions manually copied to ' + dest); }\"", "preview": "vite preview", @@ -28,13 +33,16 @@ "devDependencies": { "@capacitor/assets": "^3.0.5", "@capacitor/cli": "^8.2.0", + "@testing-library/dom": "^10.4.1", "@types/node": "^25.3.5", + "@vitest/browser-playwright": "^4.1.2", "eslint": "^9.39.3", "eslint-config-prettier": "^10.1.8", "formidable": "^3.5.4", "globals": "^17.4.0", "htmlhint": "^1.9.2", "miniflare": "^4.20260301.1", + "playwright": "^1.58.2", "prettier": "^3.8.1", "stylelint": "^16.26.1", "stylelint-config-standard": "^39.0.1", @@ -61,6 +69,7 @@ "@kawarp/core": "^1.1.1", "@svta/common-media-library": "^0.18.1", "@uimaxbai/am-lyrics": "^1.1.4", + "@vitest/web-worker": "^4.1.2", "appwrite": "^23.0.0", "butterchurn": "^2.6.7", "butterchurn-presets": "^2.4.7", @@ -79,6 +88,7 @@ "simple-icons": "^16.12.0", "svgo": "^4.0.1", "url-toolkit": "^2.2.5", - "uuid": "^13.0.0" + "uuid": "^13.0.0", + "vitest": "^4.1.2" } } diff --git a/tsconfig.json b/tsconfig.json index ff42e5e..8c26866 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "module": "ESNext", "moduleResolution": "Bundler", "lib": ["ESNext", "DOM", "DOM.Iterable", "webworker"], - "types": ["vite/client", "node"], + "types": ["vite/client", "node", "./js/global.d.ts"], "baseUrl": ".", "paths": { "!/*": ["node_modules/*"] diff --git a/vite-plugin-blob.ts b/vite-plugin-blob.ts index 069ac5e..47dc25e 100644 --- a/vite-plugin-blob.ts +++ b/vite-plugin-blob.ts @@ -1,7 +1,7 @@ import fs from 'fs/promises'; import path from 'path'; import { gzipSync, constants as zlibConstants } from 'zlib'; -import type { Plugin } from 'vite'; +import type { Plugin, ResolvedConfig } from 'vite'; import mime from 'mime'; import { createHash } from 'crypto'; @@ -26,10 +26,14 @@ function hashString(input: string, algorithm = 'sha256'): string { */ export default function blobAssetPlugin(): Plugin { const devAssets = new Map(); + let resolvedConfig: ResolvedConfig | null = null; return { name: 'vite-blob-asset', + async configResolved(config: ResolvedConfig) { + resolvedConfig = config; + }, async load(id) { if (!id.includes('?blob-url')) return; @@ -45,7 +49,7 @@ export default function blobAssetPlugin(): Plugin { let assetUrl: string; - if (this.meta.watchMode) { + if (resolvedConfig?.command === 'serve') { /** dev server path */ assetUrl = `/@blob/${hashString(absPath)}/${path.basename(filepath)}.gz`; devAssets.set(assetUrl, compressed); diff --git a/vite.config.ts b/vite.config.ts index bd7d0a0..23f4158 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,7 @@ import uploadPlugin from './vite-plugin-upload.js'; import blobAssetPlugin from './vite-plugin-blob.js'; import svgUse from './vite-plugin-svg-use.js'; import { execSync } from 'child_process'; +import { playwright } from '@vitest/browser-playwright'; function getGitCommitHash() { try { @@ -19,9 +20,19 @@ export default defineConfig(({ mode }) => { const commitHash = getGitCommitHash(); return { + test: { + // https://vitest.dev/guide/browser/ + browser: { + enabled: true, + provider: playwright(), + headless: !!process.env.HEADLESS, + instances: [{ browser: 'chromium' }], + }, + }, base: './', define: { __COMMIT_HASH__: JSON.stringify(commitHash), + __VITEST__: !!process.env.VITEST, }, worker: { format: 'es',