Compare commits
1 commit
main
...
studio-fro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2ab0dda30 |
6 changed files with 909 additions and 2 deletions
387
studio/frontend/package-lock.json
generated
387
studio/frontend/package-lock.json
generated
|
|
@ -82,9 +82,11 @@
|
|||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"happy-dom": "^18.0.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.55.0",
|
||||
"vite": "^8.0.1"
|
||||
"vite": "^8.0.1",
|
||||
"vitest": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
|
|
@ -6326,6 +6328,17 @@
|
|||
"dev": true,
|
||||
"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/d3": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
||||
|
|
@ -6588,6 +6601,13 @@
|
|||
"@types/ms": "*"
|
||||
}
|
||||
},
|
||||
"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.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
|
|
@ -6732,6 +6752,13 @@
|
|||
"integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/whatwg-mimetype": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz",
|
||||
|
|
@ -7069,6 +7096,119 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
|
||||
"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.5",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
|
||||
"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.5",
|
||||
"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.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
|
||||
"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
|
||||
"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.5",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
|
||||
"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.5",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
|
||||
"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
|
||||
"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.13",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
|
||||
|
|
@ -7268,6 +7408,16 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"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/assistant-cloud": {
|
||||
"version": "0.1.27",
|
||||
"resolved": "https://registry.npmjs.org/assistant-cloud/-/assistant-cloud-0.1.27.tgz",
|
||||
|
|
@ -7553,6 +7703,16 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"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/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
|
|
@ -8850,6 +9010,13 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
|
||||
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
|
|
@ -9097,6 +9264,16 @@
|
|||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"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/esutils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
|
|
@ -9163,6 +9340,16 @@
|
|||
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||
"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",
|
||||
|
|
@ -9715,6 +9902,38 @@
|
|||
"integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/happy-dom": {
|
||||
"version": "18.0.1",
|
||||
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-18.0.1.tgz",
|
||||
"integrity": "sha512-qn+rKOW7KWpVTtgIUi6RVmTBZJSe2k0Db0vh1f7CWrWclkkc7/Q+FrOfkZIb2eiErLyqu5AXEzE7XthO9JVxRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/whatwg-mimetype": "^3.0.2",
|
||||
"whatwg-mimetype": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/happy-dom/node_modules/@types/node": {
|
||||
"version": "20.19.40",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.40.tgz",
|
||||
"integrity": "sha512-xxx6M2IpSTnnKcR0cMvIiohkiCx20/oRPtWGbenFygKCGl3zqUzdNjQ/1V4solq1LU+dgv0nQzeGOuqkqZGg0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/happy-dom/node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
|
|
@ -12414,6 +12633,17 @@
|
|||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
|
|
@ -14132,6 +14362,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",
|
||||
|
|
@ -14194,6 +14431,13 @@
|
|||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"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",
|
||||
|
|
@ -14203,6 +14447,13 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stdin-discarder": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
|
||||
|
|
@ -14469,6 +14720,13 @@
|
|||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
|
||||
|
|
@ -14494,6 +14752,16 @@
|
|||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.0.30",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz",
|
||||
|
|
@ -15285,6 +15553,96 @@
|
|||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
|
||||
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.5",
|
||||
"@vitest/mocker": "4.1.5",
|
||||
"@vitest/pretty-format": "4.1.5",
|
||||
"@vitest/runner": "4.1.5",
|
||||
"@vitest/snapshot": "4.1.5",
|
||||
"@vitest/spy": "4.1.5",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"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.5",
|
||||
"@vitest/browser-preview": "4.1.5",
|
||||
"@vitest/browser-webdriverio": "4.1.5",
|
||||
"@vitest/coverage-istanbul": "4.1.5",
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"@vitest/ui": "4.1.5",
|
||||
"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/vscode-jsonrpc": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
||||
|
|
@ -15353,6 +15711,16 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
|
@ -15368,6 +15736,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/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --pretty false",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"biome:check": "biome check .",
|
||||
"biome:fix": "biome check . --write"
|
||||
},
|
||||
|
|
@ -95,8 +97,10 @@
|
|||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"happy-dom": "^18.0.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.55.0",
|
||||
"vite": "^8.0.1"
|
||||
"vite": "^8.0.1",
|
||||
"vitest": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
243
studio/frontend/src/features/auth/__tests__/api.test.ts
Normal file
243
studio/frontend/src/features/auth/__tests__/api.test.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
AUTH_MUST_CHANGE_PASSWORD_KEY,
|
||||
AUTH_REFRESH_TOKEN_KEY,
|
||||
AUTH_TOKEN_KEY,
|
||||
} from "@/features/auth/session";
|
||||
|
||||
type FetchMock = ReturnType<typeof vi.fn>;
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (v: T) => void;
|
||||
reject: (e: unknown) => void;
|
||||
} {
|
||||
let resolve!: (v: T) => void;
|
||||
let reject!: (e: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function loadApi() {
|
||||
// Reset module-level singleflight + generation state between tests.
|
||||
vi.resetModules();
|
||||
return await import("@/features/auth/api");
|
||||
}
|
||||
|
||||
let fetchMock: FetchMock;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("refreshSession singleflight", () => {
|
||||
it("issues exactly one /api/auth/refresh request when N callers race", async () => {
|
||||
localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, "rt-original");
|
||||
const refreshDeferred = deferred<Response>();
|
||||
fetchMock.mockImplementation(async (input: RequestInfo) => {
|
||||
const url = typeof input === "string" ? input : (input as Request).url;
|
||||
if (url.endsWith("/api/auth/refresh")) {
|
||||
return refreshDeferred.promise;
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
const { refreshSession } = await loadApi();
|
||||
// Kick off all callers BEFORE we resolve the in-flight fetch, so they
|
||||
// all subscribe to the same singleflight promise.
|
||||
const callersPromise = Promise.all([
|
||||
refreshSession(),
|
||||
refreshSession(),
|
||||
refreshSession(),
|
||||
refreshSession(),
|
||||
refreshSession(),
|
||||
]);
|
||||
// Yield once so the refresh IIFE has reached its fetch call.
|
||||
await Promise.resolve();
|
||||
refreshDeferred.resolve(
|
||||
jsonResponse(200, {
|
||||
access_token: "new-access",
|
||||
refresh_token: "new-refresh",
|
||||
must_change_password: false,
|
||||
}),
|
||||
);
|
||||
const results = await callersPromise;
|
||||
|
||||
expect(results).toEqual([true, true, true, true, true]);
|
||||
const refreshCalls = fetchMock.mock.calls.filter(([input]) => {
|
||||
const url = typeof input === "string" ? input : (input as Request).url;
|
||||
return url.endsWith("/api/auth/refresh");
|
||||
});
|
||||
expect(refreshCalls).toHaveLength(1);
|
||||
expect(localStorage.getItem(AUTH_TOKEN_KEY)).toBe("new-access");
|
||||
expect(localStorage.getItem(AUTH_REFRESH_TOKEN_KEY)).toBe("new-refresh");
|
||||
});
|
||||
|
||||
it("clears the in-flight slot after resolution so the next call refreshes again", async () => {
|
||||
localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, "rt-1");
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(200, {
|
||||
access_token: "a",
|
||||
refresh_token: "b",
|
||||
must_change_password: false,
|
||||
}),
|
||||
);
|
||||
const { refreshSession } = await loadApi();
|
||||
await refreshSession();
|
||||
await refreshSession();
|
||||
const refreshCalls = fetchMock.mock.calls.filter(([input]) => {
|
||||
const url = typeof input === "string" ? input : (input as Request).url;
|
||||
return url.endsWith("/api/auth/refresh");
|
||||
});
|
||||
expect(refreshCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns false and clears tokens on non-OK refresh response", async () => {
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, "old-access");
|
||||
localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, "rt-expired");
|
||||
fetchMock.mockResolvedValue(jsonResponse(401, { detail: "invalid" }));
|
||||
|
||||
const { refreshSession } = await loadApi();
|
||||
const ok = await refreshSession();
|
||||
expect(ok).toBe(false);
|
||||
expect(localStorage.getItem(AUTH_TOKEN_KEY)).toBeNull();
|
||||
expect(localStorage.getItem(AUTH_REFRESH_TOKEN_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns false (no refresh fired) when there is no refresh token", async () => {
|
||||
const { refreshSession } = await loadApi();
|
||||
const ok = await refreshSession();
|
||||
expect(ok).toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("logoutGeneration invalidates a mid-flight refresh", () => {
|
||||
it("drops the new token pair when logout() runs while refresh is in flight", async () => {
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, "access-old");
|
||||
localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, "rt-pre-logout");
|
||||
const refreshDeferred = deferred<Response>();
|
||||
const logoutDeferred = deferred<Response>();
|
||||
|
||||
fetchMock.mockImplementation(async (input: RequestInfo) => {
|
||||
const url = typeof input === "string" ? input : (input as Request).url;
|
||||
if (url.endsWith("/api/auth/refresh")) return refreshDeferred.promise;
|
||||
if (url.endsWith("/api/auth/logout")) return logoutDeferred.promise;
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
const { refreshSession, logout } = await loadApi();
|
||||
// Start the refresh, do NOT await yet.
|
||||
const refreshPromise = refreshSession();
|
||||
// Yield once so the IIFE inside refreshSession starts executing.
|
||||
await Promise.resolve();
|
||||
// Run logout to completion; this should bump logoutGeneration before
|
||||
// the refresh has the chance to write tokens back.
|
||||
const logoutPromise = logout();
|
||||
logoutDeferred.resolve(new Response(null, { status: 204 }));
|
||||
await logoutPromise;
|
||||
// Now resolve the refresh with what would otherwise be a success.
|
||||
refreshDeferred.resolve(
|
||||
jsonResponse(200, {
|
||||
access_token: "access-NEW",
|
||||
refresh_token: "rt-NEW",
|
||||
must_change_password: false,
|
||||
}),
|
||||
);
|
||||
const refreshed = await refreshPromise;
|
||||
|
||||
// The generation mismatch must have made refreshSession() drop the
|
||||
// new pair on the floor; the SPA must stay logged out.
|
||||
expect(refreshed).toBe(false);
|
||||
expect(localStorage.getItem(AUTH_TOKEN_KEY)).toBeNull();
|
||||
expect(localStorage.getItem(AUTH_REFRESH_TOKEN_KEY)).toBeNull();
|
||||
expect(localStorage.getItem(AUTH_MUST_CHANGE_PASSWORD_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("logout()", () => {
|
||||
it("clears local state on a 204 even if the network is fine", async () => {
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, "a");
|
||||
localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, "b");
|
||||
localStorage.setItem(AUTH_MUST_CHANGE_PASSWORD_KEY, "1");
|
||||
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));
|
||||
|
||||
const { logout } = await loadApi();
|
||||
await logout();
|
||||
expect(localStorage.getItem(AUTH_TOKEN_KEY)).toBeNull();
|
||||
expect(localStorage.getItem(AUTH_REFRESH_TOKEN_KEY)).toBeNull();
|
||||
expect(localStorage.getItem(AUTH_MUST_CHANGE_PASSWORD_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("retries with a fresh access token when the first POST hits 401", async () => {
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, "stale-access");
|
||||
localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, "rt-1");
|
||||
|
||||
const calls: { url: string; auth: string | null }[] = [];
|
||||
fetchMock.mockImplementation(async (input: RequestInfo, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : (input as Request).url;
|
||||
const headers = new Headers(init?.headers);
|
||||
calls.push({ url, auth: headers.get("authorization") });
|
||||
if (url.endsWith("/api/auth/logout")) {
|
||||
// First logout sees the stale token and 401s; second sees the
|
||||
// post-refresh token and 204s.
|
||||
const seen = calls.filter((c) => c.url.endsWith("/api/auth/logout")).length;
|
||||
return seen === 1
|
||||
? new Response(null, { status: 401 })
|
||||
: new Response(null, { status: 204 });
|
||||
}
|
||||
if (url.endsWith("/api/auth/refresh")) {
|
||||
return jsonResponse(200, {
|
||||
access_token: "rotated-access",
|
||||
refresh_token: "rt-2",
|
||||
must_change_password: false,
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
const { logout } = await loadApi();
|
||||
await logout();
|
||||
|
||||
const logoutCalls = calls.filter((c) => c.url.endsWith("/api/auth/logout"));
|
||||
const refreshCalls = calls.filter((c) => c.url.endsWith("/api/auth/refresh"));
|
||||
expect(logoutCalls).toHaveLength(2);
|
||||
expect(refreshCalls).toHaveLength(1);
|
||||
expect(logoutCalls[0].auth).toBe("Bearer stale-access");
|
||||
expect(logoutCalls[1].auth).toBe("Bearer rotated-access");
|
||||
expect(localStorage.getItem(AUTH_TOKEN_KEY)).toBeNull();
|
||||
expect(localStorage.getItem(AUTH_REFRESH_TOKEN_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("still clears local state when the network throws", async () => {
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, "a");
|
||||
localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, "b");
|
||||
fetchMock.mockRejectedValue(new TypeError("offline"));
|
||||
|
||||
const { logout } = await loadApi();
|
||||
await logout();
|
||||
expect(localStorage.getItem(AUTH_TOKEN_KEY)).toBeNull();
|
||||
expect(localStorage.getItem(AUTH_REFRESH_TOKEN_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
117
studio/frontend/src/features/auth/__tests__/session.test.ts
Normal file
117
studio/frontend/src/features/auth/__tests__/session.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
AUTH_MUST_CHANGE_PASSWORD_KEY,
|
||||
AUTH_REFRESH_TOKEN_KEY,
|
||||
AUTH_TOKEN_KEY,
|
||||
ONBOARDING_DONE_KEY,
|
||||
clearAuthTokens,
|
||||
getAuthToken,
|
||||
getRefreshToken,
|
||||
hasAuthToken,
|
||||
hasRefreshToken,
|
||||
isOnboardingDone,
|
||||
markOnboardingDone,
|
||||
mustChangePassword,
|
||||
resetOnboardingDone,
|
||||
setMustChangePassword,
|
||||
storeAuthTokens,
|
||||
} from "@/features/auth/session";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("storeAuthTokens", () => {
|
||||
it("writes exactly the access + refresh keys, never the must-change-password key", () => {
|
||||
storeAuthTokens("access-x", "refresh-y");
|
||||
expect(localStorage.getItem(AUTH_TOKEN_KEY)).toBe("access-x");
|
||||
expect(localStorage.getItem(AUTH_REFRESH_TOKEN_KEY)).toBe("refresh-y");
|
||||
// The must-change-password flag stays out of this function (CodeQL data-flow pin).
|
||||
expect(localStorage.getItem(AUTH_MUST_CHANGE_PASSWORD_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearAuthTokens", () => {
|
||||
it("removes the access, refresh, and must-change keys", () => {
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, "a");
|
||||
localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, "b");
|
||||
localStorage.setItem(AUTH_MUST_CHANGE_PASSWORD_KEY, "1");
|
||||
clearAuthTokens();
|
||||
expect(localStorage.getItem(AUTH_TOKEN_KEY)).toBeNull();
|
||||
expect(localStorage.getItem(AUTH_REFRESH_TOKEN_KEY)).toBeNull();
|
||||
expect(localStorage.getItem(AUTH_MUST_CHANGE_PASSWORD_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasAuthToken / getAuthToken", () => {
|
||||
it("reads back what storeAuthTokens wrote", () => {
|
||||
expect(hasAuthToken()).toBe(false);
|
||||
expect(getAuthToken()).toBeNull();
|
||||
storeAuthTokens("x", "y");
|
||||
expect(hasAuthToken()).toBe(true);
|
||||
expect(getAuthToken()).toBe("x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasRefreshToken / getRefreshToken", () => {
|
||||
it("reads back what storeAuthTokens wrote", () => {
|
||||
expect(hasRefreshToken()).toBe(false);
|
||||
expect(getRefreshToken()).toBeNull();
|
||||
storeAuthTokens("x", "rt");
|
||||
expect(hasRefreshToken()).toBe(true);
|
||||
expect(getRefreshToken()).toBe("rt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mustChangePassword / setMustChangePassword (key-presence encoding)", () => {
|
||||
it("starts false when nothing is stored", () => {
|
||||
expect(mustChangePassword()).toBe(false);
|
||||
});
|
||||
|
||||
it("setMustChangePassword(true) writes the literal '1' and the read returns true", () => {
|
||||
setMustChangePassword(true);
|
||||
// The stored value is a literal constant, not a derived boolean.
|
||||
// The CodeQL data-flow break depends on this remaining a constant.
|
||||
expect(localStorage.getItem(AUTH_MUST_CHANGE_PASSWORD_KEY)).toBe("1");
|
||||
expect(mustChangePassword()).toBe(true);
|
||||
});
|
||||
|
||||
it("setMustChangePassword(false) removes the key and the read returns false", () => {
|
||||
setMustChangePassword(true);
|
||||
setMustChangePassword(false);
|
||||
expect(localStorage.getItem(AUTH_MUST_CHANGE_PASSWORD_KEY)).toBeNull();
|
||||
expect(mustChangePassword()).toBe(false);
|
||||
});
|
||||
|
||||
it("treats any present value as truthy (presence is the signal)", () => {
|
||||
// Defensive: even if some other code wrote a different value, presence wins.
|
||||
localStorage.setItem(AUTH_MUST_CHANGE_PASSWORD_KEY, "true");
|
||||
expect(mustChangePassword()).toBe(true);
|
||||
localStorage.setItem(AUTH_MUST_CHANGE_PASSWORD_KEY, "");
|
||||
// Empty string is still a present key.
|
||||
expect(mustChangePassword()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onboarding flag", () => {
|
||||
it("starts unset and round-trips through mark/reset", () => {
|
||||
expect(isOnboardingDone()).toBe(false);
|
||||
markOnboardingDone();
|
||||
expect(localStorage.getItem(ONBOARDING_DONE_KEY)).toBe("true");
|
||||
expect(isOnboardingDone()).toBe(true);
|
||||
resetOnboardingDone();
|
||||
expect(localStorage.getItem(ONBOARDING_DONE_KEY)).toBeNull();
|
||||
expect(isOnboardingDone()).toBe(false);
|
||||
});
|
||||
|
||||
it("only treats the literal 'true' as done (regression pin: not just truthy)", () => {
|
||||
localStorage.setItem(ONBOARDING_DONE_KEY, "1");
|
||||
expect(isOnboardingDone()).toBe(false);
|
||||
localStorage.setItem(ONBOARDING_DONE_KEY, "TRUE");
|
||||
expect(isOnboardingDone()).toBe(false);
|
||||
});
|
||||
});
|
||||
140
studio/frontend/src/lib/__tests__/format-fastapi-error.test.ts
Normal file
140
studio/frontend/src/lib/__tests__/format-fastapi-error.test.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
formatFastApiDetail,
|
||||
readFastApiError,
|
||||
} from "@/lib/format-fastapi-error";
|
||||
|
||||
function fakeResponse(
|
||||
status: number,
|
||||
body: unknown,
|
||||
): Response {
|
||||
return {
|
||||
status,
|
||||
async json() {
|
||||
if (typeof body === "string") throw new SyntaxError("bad json");
|
||||
return body;
|
||||
},
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe("formatFastApiDetail", () => {
|
||||
it("returns string detail verbatim", () => {
|
||||
expect(formatFastApiDetail("password too short")).toBe(
|
||||
"password too short",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats empty / nullish / non-array shapes as no message", () => {
|
||||
expect(formatFastApiDetail("")).toBeNull();
|
||||
expect(formatFastApiDetail(null)).toBeNull();
|
||||
expect(formatFastApiDetail(undefined)).toBeNull();
|
||||
expect(formatFastApiDetail(42)).toBeNull();
|
||||
expect(formatFastApiDetail(true)).toBeNull();
|
||||
expect(formatFastApiDetail({ foo: 1 })).toBeNull();
|
||||
});
|
||||
|
||||
it("joins a single FastAPI 422 array entry as `field: msg`", () => {
|
||||
expect(
|
||||
formatFastApiDetail([
|
||||
{ loc: ["body", "password"], msg: "field required" },
|
||||
]),
|
||||
).toBe("password: field required");
|
||||
});
|
||||
|
||||
it("joins multi-entry detail with `; ` and strips the 'body' segment", () => {
|
||||
expect(
|
||||
formatFastApiDetail([
|
||||
{ loc: ["body", "password"], msg: "field required" },
|
||||
{ loc: ["body", "username"], msg: "must be lowercase" },
|
||||
]),
|
||||
).toBe("password: field required; username: must be lowercase");
|
||||
});
|
||||
|
||||
it("joins nested loc segments with `.`", () => {
|
||||
expect(
|
||||
formatFastApiDetail([
|
||||
{ loc: ["body", "settings", "lr"], msg: "must be > 0" },
|
||||
]),
|
||||
).toBe("settings.lr: must be > 0");
|
||||
});
|
||||
|
||||
it("falls back to path when msg is missing", () => {
|
||||
expect(formatFastApiDetail([{ loc: ["body", "lr"] }])).toBe("lr");
|
||||
});
|
||||
|
||||
it("falls back to msg when loc is missing", () => {
|
||||
expect(formatFastApiDetail([{ msg: "bad request" }])).toBe("bad request");
|
||||
});
|
||||
|
||||
it("returns null for empty / no-content arrays", () => {
|
||||
expect(formatFastApiDetail([])).toBeNull();
|
||||
expect(formatFastApiDetail([{}, {}])).toBeNull();
|
||||
});
|
||||
|
||||
it("skips mid-array junk and yields the valid entry", () => {
|
||||
expect(
|
||||
formatFastApiDetail([
|
||||
null,
|
||||
{ loc: ["body", "x"], msg: "bad" },
|
||||
"not-an-object",
|
||||
]),
|
||||
).toBe("x: bad");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readFastApiError", () => {
|
||||
it("renders 422 array detail as `field: msg`", async () => {
|
||||
const r = fakeResponse(422, {
|
||||
detail: [{ loc: ["body", "p"], msg: "bad" }],
|
||||
});
|
||||
expect(await readFastApiError(r)).toBe("p: bad");
|
||||
});
|
||||
|
||||
it("renders 400 string detail verbatim", async () => {
|
||||
expect(await readFastApiError(fakeResponse(400, { detail: "Invalid" }))).toBe(
|
||||
"Invalid",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls through to `message` when detail is missing", async () => {
|
||||
expect(await readFastApiError(fakeResponse(500, { message: "boom" }))).toBe(
|
||||
"boom",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the fallback prefix + status code when body has neither", async () => {
|
||||
expect(await readFastApiError(fakeResponse(503, {}))).toBe(
|
||||
"Request failed (503)",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles non-JSON bodies via the fallback", async () => {
|
||||
expect(await readFastApiError(fakeResponse(502, "not json"))).toBe(
|
||||
"Request failed (502)",
|
||||
);
|
||||
});
|
||||
|
||||
it("honours a custom fallback prefix", async () => {
|
||||
expect(
|
||||
await readFastApiError(fakeResponse(413, "binary"), "Upload too large"),
|
||||
).toBe("Upload too large (413)");
|
||||
});
|
||||
|
||||
it("falls through when detail is an empty array", async () => {
|
||||
expect(await readFastApiError(fakeResponse(422, { detail: [] }))).toBe(
|
||||
"Request failed (422)",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers `message` when detail is an object (not a renderable shape)", async () => {
|
||||
expect(
|
||||
await readFastApiError(
|
||||
fakeResponse(400, { detail: { foo: 1 }, message: "msg-fallback" }),
|
||||
),
|
||||
).toBe("msg-fallback");
|
||||
});
|
||||
});
|
||||
18
studio/frontend/vitest.config.ts
Normal file
18
studio/frontend/vitest.config.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "./src") },
|
||||
},
|
||||
test: {
|
||||
environment: "happy-dom",
|
||||
globals: false,
|
||||
include: ["src/**/__tests__/**/*.test.ts", "src/**/*.test.ts"],
|
||||
restoreMocks: true,
|
||||
clearMocks: true,
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue