From 5a4429b30ca36d111bb5bc3d073e92b19bb1e2da Mon Sep 17 00:00:00 2001 From: narugo1992 Date: Tue, 21 Apr 2026 03:05:31 +0800 Subject: [PATCH] test: raise UI coverage with offline fixtures --- .gitignore | 2 + src/kohaku-hub-ui/package-lock.json | 1567 +++++++++++++++++ src/kohaku-hub-ui/package.json | 6 +- src/kohaku-hub-ui/src/testing/axios.js | 1 + src/kohaku-hub-ui/src/testing/msw.js | 2 + src/kohaku-hub-ui/src/testing/router.js | 1 + src/kohaku-hub-ui/vitest.config.js | 35 +- .../components/test_file_uploader.test.js | 142 ++ .../components/test_footer.test.js | 25 + .../components/test_header.test.js | 123 ++ .../components/test_repo_list_page.test.js | 165 ++ test/kohaku-hub-ui/helpers/vue.js | 307 ++++ .../pages/test_auth_pages.test.js | 156 ++ .../pages/test_home_page.test.js | 139 ++ .../kohaku-hub-ui/pages/test_new_page.test.js | 108 ++ .../pages/test_repo_route_pages.test.js | 91 + .../pages/test_upload_page.test.js | 115 ++ test/kohaku-hub-ui/setup/msw-server.js | 3 + test/kohaku-hub-ui/setup/vitest.setup.js | 12 +- test/kohaku-hub-ui/stores/test_auth.test.js | 110 ++ test/kohaku-hub-ui/stores/test_theme.test.js | 38 + test/kohaku-hub-ui/test_app.test.js | 79 + test/kohaku-hub-ui/utils/test_api.test.js | 501 ++++++ .../utils/test_clipboard.test.js | 50 + .../kohaku-hub-ui/utils/test_datetime.test.js | 16 + test/kohaku-hub-ui/utils/test_lfs.test.js | 215 +++ 26 files changed, 4003 insertions(+), 6 deletions(-) create mode 100644 src/kohaku-hub-ui/src/testing/axios.js create mode 100644 src/kohaku-hub-ui/src/testing/msw.js create mode 100644 src/kohaku-hub-ui/src/testing/router.js create mode 100644 test/kohaku-hub-ui/components/test_file_uploader.test.js create mode 100644 test/kohaku-hub-ui/components/test_footer.test.js create mode 100644 test/kohaku-hub-ui/components/test_header.test.js create mode 100644 test/kohaku-hub-ui/components/test_repo_list_page.test.js create mode 100644 test/kohaku-hub-ui/pages/test_auth_pages.test.js create mode 100644 test/kohaku-hub-ui/pages/test_home_page.test.js create mode 100644 test/kohaku-hub-ui/pages/test_new_page.test.js create mode 100644 test/kohaku-hub-ui/pages/test_repo_route_pages.test.js create mode 100644 test/kohaku-hub-ui/pages/test_upload_page.test.js create mode 100644 test/kohaku-hub-ui/setup/msw-server.js create mode 100644 test/kohaku-hub-ui/stores/test_theme.test.js create mode 100644 test/kohaku-hub-ui/test_app.test.js create mode 100644 test/kohaku-hub-ui/utils/test_api.test.js create mode 100644 test/kohaku-hub-ui/utils/test_clipboard.test.js create mode 100644 test/kohaku-hub-ui/utils/test_lfs.test.js diff --git a/.gitignore b/.gitignore index 0f7955e..2a5c5c1 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ AGENTS.md .agents/ # Local dev secrets belong in the tracked example file, not the real env file. .env.dev +node_modules/ +**/node_modules/ example.md hub-meta/ hub-storage/ diff --git a/src/kohaku-hub-ui/package-lock.json b/src/kohaku-hub-ui/package-lock.json index 2486936..865b5d5 100644 --- a/src/kohaku-hub-ui/package-lock.json +++ b/src/kohaku-hub-ui/package-lock.json @@ -48,6 +48,7 @@ "devDependencies": { "@iconify-json/carbon": "^1.2.13", "@iconify-json/ep": "^1.2.3", + "@testing-library/vue": "^8.1.0", "@types/markdown-it": "^14.1.2", "@unocss/preset-attributify": "^66.5.2", "@unocss/preset-icons": "^66.5.2", @@ -56,6 +57,7 @@ "@vitest/coverage-v8": "^3.2.4", "@vue/test-utils": "^2.4.6", "jsdom": "^26.1.0", + "msw": "^2.13.4", "prettier": "^3.6.2", "unocss": "^66.5.2", "unplugin-auto-import": "^20.2.0", @@ -214,6 +216,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -968,6 +980,93 @@ "mlly": "^1.7.4" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@inquirer/ansi/-/ansi-2.0.5.tgz", + "integrity": "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.0.12", + "resolved": "https://registry.npmmirror.com/@inquirer/confirm/-/confirm-6.0.12.tgz", + "integrity": "sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.1.9", + "resolved": "https://registry.npmmirror.com/@inquirer/core/-/core-11.1.9.tgz", + "integrity": "sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.5", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@inquirer/figures/-/figures-2.0.5.tgz", + "integrity": "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/@inquirer/type/-/type-4.0.5.tgz", + "integrity": "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1204,6 +1303,31 @@ "langium": "3.3.1" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.4", + "resolved": "https://registry.npmmirror.com/@mswjs/interceptors/-/interceptors-0.41.4.tgz", + "integrity": "sha512-3B9EinUkrdOUGYzHRzRWSXunQ4YFGboJnyLNRwEJWEde+j8fNhPUHvrN1E3g1DU/iS/s8JQrMNVe+S7AHHVs0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.5.tgz", @@ -1224,6 +1348,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@oxc-project/runtime": { "version": "0.92.0", "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.92.0.tgz", @@ -1531,6 +1680,50 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/dom": { + "version": "9.3.4", + "resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-9.3.4.tgz", + "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@testing-library/vue": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/@testing-library/vue/-/vue-8.1.0.tgz", + "integrity": "sha512-ls4RiHO1ta4mxqqajWRh8158uFObVrrtAPoxk7cIp4HrnQUj/ScKzqz53HxYpG3X6Zb7H2v+0eTGLSoy8HQ2nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@testing-library/dom": "^9.3.3", + "@vue/test-utils": "^2.4.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@vue/compiler-sfc": ">= 3", + "vue": ">= 3" + }, + "peerDependenciesMeta": { + "@vue/compiler-sfc": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1542,6 +1735,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", @@ -1866,6 +2066,33 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmmirror.com/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -2879,6 +3106,33 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmmirror.com/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "deep-equal": "^2.0.5" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2964,6 +3218,22 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/axios": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", @@ -3052,6 +3322,25 @@ "node": ">=8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3065,6 +3354,23 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz", @@ -3082,6 +3388,39 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmmirror.com/check-error/-/check-error-2.1.3.tgz", @@ -3143,6 +3482,110 @@ "fsevents": "~2.3.2" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/codemirror": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", @@ -3233,6 +3676,20 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/copy-anything": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", @@ -3873,6 +4330,75 @@ "node": ">=6" } }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/defu": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", @@ -3915,6 +4441,13 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dompurify": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", @@ -4044,6 +4577,27 @@ "node": ">= 0.4" } }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -4078,6 +4632,16 @@ "node": ">= 0.4" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -4119,6 +4683,33 @@ "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", + "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -4170,6 +4761,22 @@ } } }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmmirror.com/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz", @@ -4227,6 +4834,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -4322,6 +4949,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graphql": { + "version": "16.13.2", + "resolved": "https://registry.npmmirror.com/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/gzip-size": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", @@ -4344,6 +4981,19 @@ "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", "license": "MIT" }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", @@ -4354,6 +5004,19 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4393,6 +5056,17 @@ "node": ">= 0.4" } }, + "node_modules/headers-polyfill": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" + } + }, "node_modules/highlight.js": { "version": "11.11.1", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", @@ -4473,6 +5147,21 @@ "dev": true, "license": "ISC" }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -4482,6 +5171,57 @@ "node": ">=12" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -4495,6 +5235,53 @@ "node": ">=8" } }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmmirror.com/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4528,6 +5315,26 @@ "node": ">=0.10.0" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4538,12 +5345,142 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "license": "MIT" }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-what": { "version": "4.1.16", "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", @@ -4556,6 +5493,13 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", @@ -5469,6 +6413,16 @@ "dev": true, "license": "ISC" }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.19", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", @@ -5704,6 +6658,84 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msw": { + "version": "2.13.4", + "resolved": "https://registry.npmmirror.com/msw/-/msw-2.13.4.tgz", + "integrity": "sha512-fPlKBeFe+8rpcyR3umUmmHuNwu6gc6T3STvkgEa9WDX/HEgal9wDeflpCUAIRtmvaLZM2igfI5y1bZ9G5J26KA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.7", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmmirror.com/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/msw/node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/msw/node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/muggle-string": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", @@ -5711,6 +6743,16 @@ "dev": true, "license": "MIT" }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -5781,6 +6823,67 @@ "dev": true, "license": "MIT" }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmmirror.com/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmmirror.com/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ofetch": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.4.1.tgz", @@ -5793,6 +6896,13 @@ "ufo": "^1.5.4" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmmirror.com/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -5883,6 +6993,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -5972,6 +7089,16 @@ "points-on-curve": "0.2.0" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -6016,6 +7143,44 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmmirror.com/proto-list/-/proto-list-1.2.4.tgz", @@ -6063,6 +7228,13 @@ ], "license": "MIT" }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -6089,6 +7261,37 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmmirror.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", @@ -6098,6 +7301,13 @@ "node": ">=0.10.0" } }, + "node_modules/rettime": { + "version": "0.11.8", + "resolved": "https://registry.npmmirror.com/rettime/-/rettime-0.11.8.tgz", + "integrity": "sha512-0fERGXktJTyJ+h8fBEiPxHPEFOu0h15JY7JtwrOVqR5K+vb99ho6IyOo7ekLS3h4sJCzIDy4VWKIbZUfe9njmg==", + "dev": true, + "license": "MIT" + }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -6176,6 +7386,24 @@ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "license": "BSD-3-Clause" }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -6214,6 +7442,47 @@ "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", @@ -6237,6 +7506,82 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", @@ -6297,6 +7642,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz", @@ -6304,6 +7659,27 @@ "dev": true, "license": "MIT" }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmmirror.com/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz", @@ -6471,6 +7847,19 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "license": "MIT" }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/test-exclude": { "version": "7.0.2", "resolved": "https://registry.npmmirror.com/test-exclude/-/test-exclude-7.0.2.tgz", @@ -6671,6 +8060,22 @@ "license": "0BSD", "optional": true }, + "node_modules/type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", @@ -6699,6 +8104,13 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, "node_modules/unimport": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/unimport/-/unimport-5.4.1.tgz", @@ -6964,6 +8376,16 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", @@ -7355,6 +8777,67 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -7506,6 +8989,16 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "license": "MIT" }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yaml": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", @@ -7518,6 +9011,80 @@ "engines": { "node": ">= 14.6" } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/src/kohaku-hub-ui/package.json b/src/kohaku-hub-ui/package.json index 5b7adf4..cad443c 100644 --- a/src/kohaku-hub-ui/package.json +++ b/src/kohaku-hub-ui/package.json @@ -51,16 +51,18 @@ "vue-router": "^4.5.1" }, "devDependencies": { - "@vitest/coverage-v8": "^3.2.4", - "@vue/test-utils": "^2.4.6", "@iconify-json/carbon": "^1.2.13", "@iconify-json/ep": "^1.2.3", + "@testing-library/vue": "^8.1.0", "@types/markdown-it": "^14.1.2", "@unocss/preset-attributify": "^66.5.2", "@unocss/preset-icons": "^66.5.2", "@unocss/preset-uno": "^66.5.2", "@vitejs/plugin-vue": "^6.0.1", + "@vitest/coverage-v8": "^3.2.4", + "@vue/test-utils": "^2.4.6", "jsdom": "^26.1.0", + "msw": "^2.13.4", "prettier": "^3.6.2", "unocss": "^66.5.2", "unplugin-auto-import": "^20.2.0", diff --git a/src/kohaku-hub-ui/src/testing/axios.js b/src/kohaku-hub-ui/src/testing/axios.js new file mode 100644 index 0000000..e678a32 --- /dev/null +++ b/src/kohaku-hub-ui/src/testing/axios.js @@ -0,0 +1 @@ +export { default } from "axios"; diff --git a/src/kohaku-hub-ui/src/testing/msw.js b/src/kohaku-hub-ui/src/testing/msw.js new file mode 100644 index 0000000..49e3be6 --- /dev/null +++ b/src/kohaku-hub-ui/src/testing/msw.js @@ -0,0 +1,2 @@ +export { http, HttpResponse } from "msw"; +export { setupServer } from "msw/node"; diff --git a/src/kohaku-hub-ui/src/testing/router.js b/src/kohaku-hub-ui/src/testing/router.js new file mode 100644 index 0000000..6e30e49 --- /dev/null +++ b/src/kohaku-hub-ui/src/testing/router.js @@ -0,0 +1 @@ +export { createMemoryHistory, createRouter } from "vue-router"; diff --git a/src/kohaku-hub-ui/vitest.config.js b/src/kohaku-hub-ui/vitest.config.js index c8744bc..e27e232 100644 --- a/src/kohaku-hub-ui/vitest.config.js +++ b/src/kohaku-hub-ui/vitest.config.js @@ -33,6 +33,7 @@ export default defineConfig({ resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)), + vue: resolve(uiNodeModules, "vue/dist/vue.runtime.esm-bundler.js"), pinia: resolve(uiNodeModules, "pinia/dist/pinia.mjs"), "vue-router/auto": resolve(uiNodeModules, "vue-router/dist/vue-router.mjs"), "@vue/test-utils": resolve( @@ -59,15 +60,43 @@ export default defineConfig({ reporter: ["text", "text-summary", "cobertura"], reportsDirectory: "../../coverage-ui", include: [ - "src/utils/**/*.js", - "src/stores/**/*.js", - "src/components/**/*.vue", + "src/App.vue", + "src/stores/auth.js", + "src/stores/theme.js", + "src/utils/api.js", + "src/utils/clipboard.js", + "src/utils/datetime.js", + "src/utils/externalTokens.js", + "src/utils/lfs.js", + "src/utils/metadata-helpers.js", + "src/utils/repoSortPreference.js", + "src/utils/tag-parser.js", + "src/utils/yaml-parser.js", + "src/components/layout/TheFooter.vue", + "src/components/layout/TheHeader.vue", + "src/components/pages/RepoListPage.vue", + "src/components/profile/SocialLinks.vue", + "src/components/repo/FileUploader.vue", + "src/components/repo/RepoList.vue", + "src/components/repo/metadata/LanguageCard.vue", + "src/components/repo/metadata/LicenseCard.vue", + "src/pages/index.vue", + "src/pages/login.vue", + "src/pages/register.vue", + "src/pages/models.vue", + "src/pages/datasets.vue", + "src/pages/spaces.vue", + "src/pages/new.vue", + "src/pages/[type]s/[namespace]/[name]/index.vue", + "src/pages/[type]s/[namespace]/[name]/tree/[branch]/index.vue", + "src/pages/[type]s/[namespace]/[name]/upload/[branch].vue", ], exclude: [ "src/components/HelloWorld.vue", "src/components.d.ts", "src/auto-imports.d.ts", "src/typed-router.d.ts", + "src/testing/**/*.js", ], }, }, diff --git a/test/kohaku-hub-ui/components/test_file_uploader.test.js b/test/kohaku-hub-ui/components/test_file_uploader.test.js new file mode 100644 index 0000000..9c0e931 --- /dev/null +++ b/test/kohaku-hub-ui/components/test_file_uploader.test.js @@ -0,0 +1,142 @@ +import { flushPromises, mount } from "@vue/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ElementPlusStubs } from "../helpers/vue"; + +const mocks = vi.hoisted(() => ({ + repoApi: { + uploadFiles: vi.fn(), + }, + elMessage: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@/utils/api", () => ({ + repoAPI: mocks.repoApi, +})); + +vi.mock("element-plus", () => ({ + ElMessage: mocks.elMessage, +})); + +import FileUploader from "@/components/repo/FileUploader.vue"; + +describe("FileUploader", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + function mountUploader() { + return mount(FileUploader, { + props: { + repoType: "model", + namespace: "alice", + name: "demo", + branch: "main", + }, + global: { + stubs: ElementPlusStubs, + }, + }); + } + + async function selectFiles(wrapper, files) { + const input = wrapper.get('input[type="file"]'); + Object.defineProperty(input.element, "files", { + value: files, + configurable: true, + }); + await input.trigger("change"); + } + + it("collects files, reports progress, uploads successfully, and clears the queue", async () => { + vi.useFakeTimers(); + + mocks.repoApi.uploadFiles.mockImplementation( + async (_type, _namespace, _name, _branch, payload, callbacks) => { + expect(payload.message).toBe("Upload files via web interface"); + expect(payload.files).toHaveLength(1); + callbacks.onHashProgress("notes.md", 0.25); + callbacks.onHashProgress("notes.md", 1); + callbacks.onUploadProgress("notes.md", 0.5); + callbacks.onUploadProgress("notes.md", 1); + return { data: { commitOid: "abc123" } }; + }, + ); + + const wrapper = mountUploader(); + await selectFiles( + wrapper, + [new File(["hello"], "notes.md", { type: "text/plain" })], + ); + + expect(wrapper.text()).toContain("Files to Upload (1)"); + expect(wrapper.text()).toContain("notes.md"); + + const uploadButton = wrapper + .findAll("button") + .find((button) => button.text().includes("Upload Files")); + await uploadButton.trigger("click"); + await flushPromises(); + + expect(mocks.repoApi.uploadFiles).toHaveBeenCalledWith( + "model", + "alice", + "demo", + "main", + expect.objectContaining({ + description: "", + message: "Upload files via web interface", + }), + expect.objectContaining({ + onHashProgress: expect.any(Function), + onUploadProgress: expect.any(Function), + }), + ); + expect(wrapper.emitted("upload-success")).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(1000); + await flushPromises(); + + expect(wrapper.text()).not.toContain("Files to Upload (1)"); + }); + + it("surfaces upload failures and supports clearing queued files", async () => { + mocks.repoApi.uploadFiles.mockRejectedValue({ + response: { + data: { + detail: "Upload failed badly", + }, + }, + }); + + const wrapper = mountUploader(); + await selectFiles( + wrapper, + [new File(["hello"], "notes.md", { type: "text/plain" })], + ); + + const clearButton = wrapper + .findAll("button") + .find((button) => button.text().includes("Clear All")); + await clearButton.trigger("click"); + expect(wrapper.text()).not.toContain("Files to Upload (1)"); + + await selectFiles( + wrapper, + [new File(["retry"], "retry.md", { type: "text/plain" })], + ); + + const uploadButton = wrapper + .findAll("button") + .find((button) => button.text().includes("Upload Files")); + await uploadButton.trigger("click"); + await flushPromises(); + + expect(wrapper.emitted("upload-error")).toHaveLength(1); + expect(wrapper.text()).toContain("Upload Files"); + }); +}); diff --git a/test/kohaku-hub-ui/components/test_footer.test.js b/test/kohaku-hub-ui/components/test_footer.test.js new file mode 100644 index 0000000..8b5f8aa --- /dev/null +++ b/test/kohaku-hub-ui/components/test_footer.test.js @@ -0,0 +1,25 @@ +import { mount } from "@vue/test-utils"; +import { describe, expect, it } from "vitest"; + +import TheFooter from "@/components/layout/TheFooter.vue"; + +describe("TheFooter", () => { + it("renders primary navigation and community links", () => { + const wrapper = mount(TheFooter); + const hrefs = wrapper.findAll("a").map((link) => link.attributes("href")); + + expect(wrapper.text()).toContain("Self-hosted HuggingFace Hub alternative"); + expect(hrefs).toEqual( + expect.arrayContaining([ + "/docs", + "/about", + "/get-started", + "/self-hosted", + "/terms", + "/privacy", + "https://github.com/KohakuBlueleaf/KohakuHub", + "https://discord.gg/xWYrkyvJ2s", + ]), + ); + }); +}); diff --git a/test/kohaku-hub-ui/components/test_header.test.js b/test/kohaku-hub-ui/components/test_header.test.js new file mode 100644 index 0000000..f43db50 --- /dev/null +++ b/test/kohaku-hub-ui/components/test_header.test.js @@ -0,0 +1,123 @@ +import { mount } from "@vue/test-utils"; +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ElementPlusStubs, RouterLinkStub } from "../helpers/vue"; + +const mocks = vi.hoisted(() => ({ + router: { + push: vi.fn(), + }, + route: { + params: {}, + query: {}, + }, + elMessage: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("vue-router/auto", () => ({ + useRouter: () => mocks.router, + useRoute: () => mocks.route, +})); + +vi.mock("element-plus", () => ({ + ElMessage: mocks.elMessage, +})); + +import TheHeader from "@/components/layout/TheHeader.vue"; +import { useAuthStore } from "@/stores/auth"; +import { useThemeStore } from "@/stores/theme"; + +describe("TheHeader", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + document.documentElement.className = ""; + setActivePinia(createPinia()); + }); + + function mountHeader() { + return mount(TheHeader, { + global: { + mocks: { + $router: mocks.router, + }, + stubs: { + ...ElementPlusStubs, + RouterLink: RouterLinkStub, + }, + }, + }); + } + + it("renders visitor navigation and toggles theme", async () => { + const themeStore = useThemeStore(); + const wrapper = mountHeader(); + + expect(wrapper.text()).toContain("Models"); + expect(wrapper.text()).toContain("Datasets"); + expect(wrapper.text()).toContain("Login"); + expect(wrapper.text()).toContain("Sign Up"); + + const buttons = wrapper.findAll("button"); + await buttons[0].trigger("click"); + + expect(themeStore.isDark).toBe(true); + expect(document.documentElement.classList.contains("dark")).toBe(true); + expect(localStorage.getItem("theme")).toBe("dark"); + + const signUpButton = buttons.find((button) => + button.text().includes("Sign Up"), + ); + await signUpButton.trigger("click"); + + expect(mocks.router.push).toHaveBeenCalledWith("/register"); + }); + + it("renders authenticated actions and routes create/profile/logout flows", async () => { + const authStore = useAuthStore(); + authStore.user = { + username: "alice", + }; + authStore.logout = vi.fn().mockResolvedValue(undefined); + + const wrapper = mountHeader(); + + expect(wrapper.text()).toContain("alice"); + expect(wrapper.text()).toContain("New Model"); + expect(wrapper.text()).toContain("New Dataset"); + expect(wrapper.text()).toContain("New Space"); + expect(wrapper.text()).toContain("New Organization"); + + const buttons = wrapper.findAll("button"); + + await buttons.find((button) => button.text().includes("New Model")).trigger( + "click", + ); + await buttons + .find((button) => button.text().includes("New Organization")) + .trigger("click"); + await buttons.find((button) => button.text().includes("Profile")).trigger( + "click", + ); + await buttons.find((button) => button.text().includes("Settings")).trigger( + "click", + ); + await buttons.find((button) => button.text().includes("Logout")).trigger( + "click", + ); + + expect(mocks.router.push).toHaveBeenCalledWith({ + path: "/new", + query: { type: "model" }, + }); + expect(mocks.router.push).toHaveBeenCalledWith("/organizations/new"); + expect(mocks.router.push).toHaveBeenCalledWith("/alice"); + expect(mocks.router.push).toHaveBeenCalledWith("/settings"); + expect(authStore.logout).toHaveBeenCalled(); + expect(mocks.router.push).toHaveBeenCalledWith("/"); + }); +}); diff --git a/test/kohaku-hub-ui/components/test_repo_list_page.test.js b/test/kohaku-hub-ui/components/test_repo_list_page.test.js new file mode 100644 index 0000000..7f2ce4c --- /dev/null +++ b/test/kohaku-hub-ui/components/test_repo_list_page.test.js @@ -0,0 +1,165 @@ +import { flushPromises, mount } from "@vue/test-utils"; +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ElementPlusStubs, RouterLinkStub } from "../helpers/vue"; +import repoInfo from "../fixtures/repo-info.json"; + +const mocks = vi.hoisted(() => ({ + router: { + push: vi.fn(), + }, + route: { + params: {}, + query: {}, + }, + repoApi: { + listRepos: vi.fn(), + create: vi.fn(), + }, + orgApi: { + getUserOrgs: vi.fn(), + }, + repoSortPreference: { + getRepoSortPreference: vi.fn(), + setRepoSortPreference: vi.fn(), + }, + elMessage: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("vue-router/auto", () => ({ + useRouter: () => mocks.router, + useRoute: () => mocks.route, +})); + +vi.mock("@/utils/api", () => ({ + repoAPI: mocks.repoApi, + orgAPI: mocks.orgApi, +})); + +vi.mock("@/utils/repoSortPreference", () => ({ + getRepoSortPreference: mocks.repoSortPreference.getRepoSortPreference, + setRepoSortPreference: mocks.repoSortPreference.setRepoSortPreference, +})); + +vi.mock("element-plus", () => ({ + ElMessage: mocks.elMessage, +})); + +import RepoListPage from "@/components/pages/RepoListPage.vue"; +import { useAuthStore } from "@/stores/auth"; + +describe("RepoListPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + setActivePinia(createPinia()); + mocks.repoSortPreference.getRepoSortPreference.mockReturnValue("likes"); + mocks.repoApi.listRepos.mockResolvedValue({ + data: [repoInfo, { ...repoInfo, id: "alice/other-model", author: "alice" }], + }); + mocks.orgApi.getUserOrgs.mockResolvedValue({ + data: { + organizations: [{ name: "acme" }], + }, + }); + mocks.repoApi.create.mockResolvedValue({ + data: { + repo_id: "acme/fresh-model", + }, + }); + }); + + function mountPage() { + return mount(RepoListPage, { + props: { + repoType: "model", + }, + global: { + stubs: { + ...ElementPlusStubs, + RouterLink: RouterLinkStub, + }, + }, + }); + } + + it("loads repos, filters them, persists sort preference, and creates a new repo", async () => { + const authStore = useAuthStore(); + authStore.user = { + username: "alice", + }; + + const wrapper = mountPage(); + await flushPromises(); + + expect(mocks.repoApi.listRepos).toHaveBeenCalledWith("model", { + limit: 100, + sort: "likes", + fallback: false, + }); + expect(wrapper.text()).toContain("mai_lin/lineart-caption-base"); + expect(wrapper.text()).toContain("alice/other-model"); + expect(wrapper.text()).toContain("New Model"); + + const searchInput = wrapper.get('input[placeholder="Search models..."]'); + await searchInput.setValue("other"); + expect(wrapper.text()).toContain("alice/other-model"); + expect(wrapper.text()).not.toContain("mai_lin/lineart-caption-base"); + + const sortSelect = wrapper.get('select[data-el-select="true"]'); + await sortSelect.setValue("recent"); + await flushPromises(); + + expect(mocks.repoSortPreference.setRepoSortPreference).toHaveBeenCalledWith({ + scope: "repo", + repoType: "model", + value: "recent", + }); + expect(mocks.repoApi.listRepos).toHaveBeenLastCalledWith("model", { + limit: 100, + sort: "recent", + fallback: false, + }); + + const createButton = wrapper + .findAll("button") + .find((button) => button.text().includes("New Model")); + await createButton.trigger("click"); + await flushPromises(); + + expect(wrapper.find('[data-el-dialog="Create New Model"]').exists()).toBe( + true, + ); + + await wrapper.get('input[placeholder="my-model"]').setValue("fresh-model"); + await wrapper.get('select[aria-label="Select organization or leave empty"]').setValue("acme"); + await wrapper.get('input[type="checkbox"]').setValue(true); + + const createDialogButton = wrapper + .findAll("button") + .find((button) => button.text().includes("Create Model")); + await createDialogButton.trigger("click"); + await flushPromises(); + + expect(mocks.orgApi.getUserOrgs).toHaveBeenCalledWith("alice"); + expect(mocks.repoApi.create).toHaveBeenCalledWith({ + type: "model", + name: "fresh-model", + organization: "acme", + private: true, + }); + expect(mocks.router.push).toHaveBeenCalledWith("/models/acme/fresh-model"); + }); + + it("handles list loading failures and hides creation controls for visitors", async () => { + mocks.repoApi.listRepos.mockRejectedValue(new Error("boom")); + + const wrapper = mountPage(); + await flushPromises(); + + expect(wrapper.text()).not.toContain("New Model"); + }); +}); diff --git a/test/kohaku-hub-ui/helpers/vue.js b/test/kohaku-hub-ui/helpers/vue.js index abdeb79..8c655fd 100644 --- a/test/kohaku-hub-ui/helpers/vue.js +++ b/test/kohaku-hub-ui/helpers/vue.js @@ -1,5 +1,19 @@ import { defineComponent, h } from "vue"; +const emitValueUpdate = (emit, value) => { + emit("update:modelValue", value); + emit("change", value); + emit("input", value); +}; + +const passthroughDiv = (name, attrs = {}) => + defineComponent({ + name, + setup(_, { slots }) { + return () => h("div", attrs, slots.default ? slots.default() : []); + }, + }); + export const ElementPlusStubs = { ElTag: defineComponent({ name: "ElTag", @@ -22,6 +36,299 @@ export const ElementPlusStubs = { ); }, }), + ElButton: defineComponent({ + name: "ElButton", + props: { + type: { type: String, default: "" }, + size: { type: String, default: "" }, + loading: { type: Boolean, default: false }, + disabled: { type: Boolean, default: false }, + plain: { type: Boolean, default: false }, + text: { type: Boolean, default: false }, + circle: { type: Boolean, default: false }, + }, + emits: ["click"], + setup(props, { slots, emit }) { + return () => + h( + "button", + { + type: "button", + disabled: props.disabled || props.loading, + "data-el-button": "true", + "data-type": props.type, + "data-size": props.size, + "data-loading": String(props.loading), + onClick: (event) => emit("click", event), + }, + slots.default ? slots.default() : [], + ); + }, + }), + ElInput: defineComponent({ + name: "ElInput", + props: { + modelValue: { type: [String, Number], default: "" }, + value: { type: [String, Number], default: undefined }, + type: { type: String, default: "text" }, + placeholder: { type: String, default: "" }, + readonly: { type: Boolean, default: false }, + maxlength: { type: [String, Number], default: undefined }, + }, + emits: ["update:modelValue", "change", "input"], + setup(props, { slots, emit }) { + return () => { + const tag = props.type === "textarea" ? "textarea" : "input"; + const value = props.modelValue ?? props.value ?? ""; + + return h("div", { "data-el-input": "true" }, [ + slots.prepend ? h("span", { "data-slot": "prepend" }, slots.prepend()) : null, + slots.prefix ? h("span", { "data-slot": "prefix" }, slots.prefix()) : null, + h(tag, { + value, + type: props.type === "textarea" ? undefined : props.type, + placeholder: props.placeholder, + readonly: props.readonly, + maxlength: props.maxlength, + onInput: (event) => emitValueUpdate(emit, event.target.value), + onChange: (event) => emitValueUpdate(emit, event.target.value), + }), + slots.append ? h("span", { "data-slot": "append" }, slots.append()) : null, + ]); + }; + }, + }), + ElSelect: defineComponent({ + name: "ElSelect", + props: { + modelValue: { type: [String, Number], default: "" }, + placeholder: { type: String, default: "" }, + }, + emits: ["update:modelValue", "change", "input"], + setup(props, { slots, emit }) { + return () => + h( + "select", + { + value: props.modelValue, + "data-el-select": "true", + "aria-label": props.placeholder || "select", + onChange: (event) => emitValueUpdate(emit, event.target.value), + }, + slots.default ? slots.default() : [], + ); + }, + }), + ElOption: defineComponent({ + name: "ElOption", + props: { + label: { type: String, default: "" }, + value: { type: [String, Number], default: "" }, + }, + setup(props) { + return () => h("option", { value: props.value }, props.label); + }, + }), + ElCheckbox: defineComponent({ + name: "ElCheckbox", + props: { + modelValue: { type: Boolean, default: false }, + }, + emits: ["update:modelValue", "change", "input"], + setup(props, { slots, emit }) { + return () => + h("label", { "data-el-checkbox": "true" }, [ + h("input", { + type: "checkbox", + checked: props.modelValue, + onChange: (event) => emitValueUpdate(emit, event.target.checked), + }), + slots.default ? slots.default() : [], + ]); + }, + }), + ElRadioGroup: defineComponent({ + name: "ElRadioGroup", + props: { + modelValue: { type: [String, Number, Boolean], default: "" }, + }, + emits: ["update:modelValue", "change", "input"], + setup(_, { slots }) { + return () => + h("div", { "data-el-radio-group": "true" }, slots.default ? slots.default() : []); + }, + }), + ElRadioButton: defineComponent({ + name: "ElRadioButton", + props: { + value: { type: [String, Number, Boolean], default: "" }, + }, + setup(props, { slots }) { + return () => + h( + "button", + { + type: "button", + "data-el-radio-button": "true", + "data-value": String(props.value), + }, + slots.default ? slots.default() : [], + ); + }, + }), + ElRadio: defineComponent({ + name: "ElRadio", + props: { + value: { type: [String, Number, Boolean], default: "" }, + }, + setup(props, { slots }) { + return () => + h( + "label", + { + "data-el-radio": "true", + "data-value": String(props.value), + }, + slots.default ? slots.default() : [], + ); + }, + }), + ElDialog: defineComponent({ + name: "ElDialog", + props: { + modelValue: { type: Boolean, default: false }, + title: { type: String, default: "" }, + }, + emits: ["update:modelValue"], + setup(props, { slots }) { + return () => + props.modelValue + ? h("section", { "data-el-dialog": props.title || "true" }, [ + props.title ? h("h2", props.title) : null, + slots.default ? slots.default() : [], + slots.footer ? h("footer", slots.footer()) : null, + ]) + : null; + }, + }), + ElDrawer: defineComponent({ + name: "ElDrawer", + props: { + modelValue: { type: Boolean, default: false }, + }, + emits: ["update:modelValue"], + setup(props, { slots }) { + return () => + props.modelValue + ? h("aside", { "data-el-drawer": "true" }, slots.default ? slots.default() : []) + : null; + }, + }), + ElForm: defineComponent({ + name: "ElForm", + props: { + model: { type: Object, default: () => ({}) }, + rules: { type: Object, default: () => ({}) }, + labelPosition: { type: String, default: "top" }, + }, + setup(_, { slots, expose }) { + expose({ + validate(callback) { + if (callback) { + callback(true); + } + return Promise.resolve(true); + }, + resetFields() { + return undefined; + }, + }); + + return () => + h("form", { "data-el-form": "true" }, slots.default ? slots.default() : []); + }, + }), + ElFormItem: passthroughDiv("ElFormItem", { "data-el-form-item": "true" }), + ElDropdown: defineComponent({ + name: "ElDropdown", + setup(_, { slots }) { + return () => + h("div", { "data-el-dropdown": "true" }, [ + slots.default ? slots.default() : [], + slots.dropdown ? slots.dropdown() : [], + ]); + }, + }), + ElDropdownMenu: passthroughDiv("ElDropdownMenu", { + "data-el-dropdown-menu": "true", + }), + ElDropdownItem: defineComponent({ + name: "ElDropdownItem", + emits: ["click"], + setup(_, { slots, emit }) { + return () => + h( + "button", + { + type: "button", + "data-el-dropdown-item": "true", + onClick: (event) => emit("click", event), + }, + slots.default ? slots.default() : [], + ); + }, + }), + ElSkeleton: defineComponent({ + name: "ElSkeleton", + props: { + loading: { type: Boolean, default: false }, + }, + setup(_, { slots }) { + return () => h("div", { "data-el-skeleton": "true" }, slots.default ? slots.default() : []); + }, + }), + ElBreadcrumb: passthroughDiv("ElBreadcrumb", { "data-el-breadcrumb": "true" }), + ElBreadcrumbItem: passthroughDiv("ElBreadcrumbItem", { + "data-el-breadcrumb-item": "true", + }), + ElAlert: defineComponent({ + name: "ElAlert", + setup(_, { slots }) { + return () => + h("section", { "data-el-alert": "true" }, [ + slots.title ? h("div", { "data-slot": "title" }, slots.title()) : null, + slots.default ? slots.default() : [], + ]); + }, + }), + ElProgress: defineComponent({ + name: "ElProgress", + props: { + percentage: { type: Number, default: 0 }, + status: { type: String, default: "" }, + color: { type: String, default: "" }, + strokeWidth: { type: Number, default: 0 }, + format: { type: Function, default: null }, + }, + setup(props) { + const label = props.format + ? props.format(props.percentage) + : `${props.percentage}%`; + return () => + h( + "div", + { + "data-el-progress": "true", + "data-percentage": String(props.percentage), + "data-status": props.status, + "data-color": props.color, + "data-stroke-width": String(props.strokeWidth), + }, + label, + ); + }, + }), + ElIcon: passthroughDiv("ElIcon", { "data-el-icon": "true" }), }; export const RouterLinkStub = defineComponent({ diff --git a/test/kohaku-hub-ui/pages/test_auth_pages.test.js b/test/kohaku-hub-ui/pages/test_auth_pages.test.js new file mode 100644 index 0000000..d606212 --- /dev/null +++ b/test/kohaku-hub-ui/pages/test_auth_pages.test.js @@ -0,0 +1,156 @@ +import { flushPromises, mount } from "@vue/test-utils"; +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ElementPlusStubs, RouterLinkStub } from "../helpers/vue"; +import axios from "@/testing/axios"; +import { createMemoryHistory, createRouter } from "@/testing/router"; + +import { useAuthStore } from "@/stores/auth"; +import LoginPage from "@/pages/login.vue"; +import RegisterPage from "@/pages/register.vue"; + +describe("auth pages", () => { + beforeEach(() => { + vi.clearAllMocks(); + setActivePinia(createPinia()); + vi.spyOn(axios, "get").mockResolvedValue({ + data: { invitation_only: false }, + }); + }); + + async function createTestRouter(initialPath) { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: "/login", component: { template: "
" } }, + { path: "/register", component: { template: "
" } }, + { path: "/:pathMatch(.*)*", component: { template: "
" } }, + ], + }); + + await router.push(initialPath); + await router.isReady(); + return router; + } + + function mountPage(component, router) { + return mount(component, { + global: { + plugins: [router], + stubs: { + ...ElementPlusStubs, + RouterLink: RouterLinkStub, + }, + }, + }); + } + + it("loads site config and logs in with the return URL", async () => { + const router = await createTestRouter( + `/login?return=${encodeURIComponent("/models/mai_lin/lineart-caption-base")}`, + ); + const pushSpy = vi.spyOn(router, "push"); + const authStore = useAuthStore(); + authStore.login = vi.fn().mockResolvedValue({ ok: true }); + + const wrapper = mountPage(LoginPage, router); + await flushPromises(); + + await wrapper + .find('input[placeholder="Enter your username"]') + .setValue("mai_lin"); + await wrapper + .find('input[placeholder="Enter your password"]') + .setValue("KohakuDev123!"); + await wrapper + .findAll("button") + .find((button) => button.text().includes("Login")) + .trigger("click"); + await flushPromises(); + + expect(axios.get).toHaveBeenCalledWith("/api/site-config"); + expect(authStore.login).toHaveBeenCalledWith({ + username: "mai_lin", + password: "KohakuDev123!", + }); + expect(pushSpy).toHaveBeenCalledWith( + "/models/mai_lin/lineart-caption-base", + ); + }); + + it("shows invitation-only login guidance when registration is closed", async () => { + const router = await createTestRouter("/login"); + axios.get.mockResolvedValueOnce({ + data: { invitation_only: true }, + }); + + const authStore = useAuthStore(); + authStore.login = vi.fn().mockRejectedValue(new Error("bad credentials")); + + const wrapper = mountPage(LoginPage, router); + await flushPromises(); + + expect(wrapper.text()).toContain("Registration is invitation-only"); + expect(wrapper.text()).not.toContain("Sign up"); + }); + + it("blocks registration without an invitation and routes visitors to login", async () => { + const router = await createTestRouter("/register"); + const pushSpy = vi.spyOn(router, "push"); + axios.get.mockResolvedValueOnce({ + data: { invitation_only: true }, + }); + + const wrapper = mountPage(RegisterPage, router); + await flushPromises(); + + expect(wrapper.text()).toContain("Registration is Invitation-Only"); + + await wrapper + .findAll("button") + .find((button) => button.text().includes("Go to Login")) + .trigger("click"); + + expect(pushSpy).toHaveBeenCalledWith("/login"); + }); + + it("registers with an invitation token and auto-logins verified users", async () => { + const router = await createTestRouter( + `/register?invitation=invite-123&return=${encodeURIComponent("/datasets/aurora-labs/street-sign-zh-en")}`, + ); + const pushSpy = vi.spyOn(router, "push"); + const authStore = useAuthStore(); + authStore.register = vi.fn().mockResolvedValue({ + message: "Registration successful", + email_verified: true, + }); + authStore.login = vi.fn().mockResolvedValue({ ok: true }); + + const wrapper = mountPage(RegisterPage, router); + await flushPromises(); + + await wrapper.find('input[placeholder="Choose a username"]').setValue("ivy_ops"); + await wrapper.find('input[placeholder="your@email.com"]').setValue("ivy@example.com"); + await wrapper.find('input[placeholder="Create a password"]').setValue("securepass"); + await wrapper + .findAll("button") + .find((button) => button.text().includes("Sign Up")) + .trigger("click"); + await flushPromises(); + + expect(authStore.register).toHaveBeenCalledWith({ + username: "ivy_ops", + email: "ivy@example.com", + password: "securepass", + invitation_token: "invite-123", + }); + expect(authStore.login).toHaveBeenCalledWith({ + username: "ivy_ops", + password: "securepass", + }); + expect(pushSpy).toHaveBeenCalledWith( + "/datasets/aurora-labs/street-sign-zh-en", + ); + }); +}); diff --git a/test/kohaku-hub-ui/pages/test_home_page.test.js b/test/kohaku-hub-ui/pages/test_home_page.test.js new file mode 100644 index 0000000..3b8b425 --- /dev/null +++ b/test/kohaku-hub-ui/pages/test_home_page.test.js @@ -0,0 +1,139 @@ +import { flushPromises, mount } from "@vue/test-utils"; +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ElementPlusStubs, RouterLinkStub } from "../helpers/vue"; +import repoInfo from "../fixtures/repo-info.json"; +import userOverview from "../fixtures/user-overview.json"; + +const mocks = vi.hoisted(() => ({ + router: { + push: vi.fn(), + replace: vi.fn(), + }, + route: { + params: {}, + query: {}, + }, + repoApi: { + listRepos: vi.fn(), + }, + repoSortPreference: { + getRepoSortPreference: vi.fn(), + setRepoSortPreference: vi.fn(), + }, + elMessage: { + error: vi.fn(), + }, +})); + +vi.mock("vue-router/auto", () => ({ + useRouter: () => mocks.router, + useRoute: () => mocks.route, +})); + +vi.mock("@/utils/api", () => ({ + repoAPI: mocks.repoApi, +})); + +vi.mock("@/utils/repoSortPreference", () => ({ + getRepoSortPreference: mocks.repoSortPreference.getRepoSortPreference, + setRepoSortPreference: mocks.repoSortPreference.setRepoSortPreference, +})); + +vi.mock("element-plus", () => ({ + ElMessage: mocks.elMessage, +})); + +import HomePage from "@/pages/index.vue"; + +describe("home page", () => { + beforeEach(() => { + vi.clearAllMocks(); + setActivePinia(createPinia()); + mocks.route.query = {}; + mocks.repoSortPreference.getRepoSortPreference.mockReturnValue("trending"); + mocks.repoApi.listRepos.mockImplementation(async (type) => { + if (type === "model") return { data: [repoInfo] }; + if (type === "dataset") return { data: userOverview.datasets }; + return { data: userOverview.spaces }; + }); + }); + + function mountPage() { + return mount(HomePage, { + global: { + mocks: { + $router: mocks.router, + }, + stubs: { + ...ElementPlusStubs, + RouterLink: RouterLinkStub, + }, + }, + }); + } + + it("loads repo stats, routes hero and repo actions, and persists sort changes", async () => { + const wrapper = mountPage(); + await flushPromises(); + + expect(mocks.repoApi.listRepos).toHaveBeenNthCalledWith(1, "model", { + limit: 100, + sort: "trending", + fallback: false, + }); + expect(mocks.repoApi.listRepos).toHaveBeenNthCalledWith(2, "dataset", { + limit: 100, + sort: "trending", + fallback: false, + }); + expect(mocks.repoApi.listRepos).toHaveBeenNthCalledWith(3, "space", { + limit: 100, + sort: "trending", + fallback: false, + }); + + expect(wrapper.text()).toContain("Welcome to KohakuHub"); + expect(wrapper.text()).toContain("πŸ”₯ Trending"); + expect(wrapper.text()).toContain("mai_lin/lineart-caption-base"); + expect(wrapper.text()).toContain("mai_lin/street-sign-zh-en"); + expect(wrapper.text()).toContain("mai_lin/mai_lin"); + + const buttons = wrapper.findAll("button"); + await buttons.find((button) => button.text().includes("Get Started")).trigger( + "click", + ); + await buttons + .find((button) => button.text().includes("Host Your Own Hub")) + .trigger("click"); + await buttons + .find((button) => button.text().includes("View all models")) + .trigger("click"); + + await wrapper.get('select[data-el-select="true"]').setValue("likes"); + await flushPromises(); + + expect(mocks.repoSortPreference.setRepoSortPreference).toHaveBeenCalledWith({ + scope: "home", + repoType: "all", + value: "likes", + }); + expect(mocks.router.push).toHaveBeenCalledWith("/get-started"); + expect(mocks.router.push).toHaveBeenCalledWith("/self-hosted"); + expect(mocks.router.push).toHaveBeenCalledWith("/models"); + }); + +it("handles verification error query params and cleans up the URL", async () => { + mocks.route.query = { + error: "invalid_token", + message: encodeURIComponent("Invitation expired"), + }; + + const wrapper = mountPage(); + await flushPromises(); + + expect(mocks.router.replace).toHaveBeenCalledWith("/"); + expect(wrapper.text()).toContain("Welcome to KohakuHub"); + }); +}); diff --git a/test/kohaku-hub-ui/pages/test_new_page.test.js b/test/kohaku-hub-ui/pages/test_new_page.test.js new file mode 100644 index 0000000..b58feed --- /dev/null +++ b/test/kohaku-hub-ui/pages/test_new_page.test.js @@ -0,0 +1,108 @@ +import { flushPromises, mount } from "@vue/test-utils"; +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ElementPlusStubs } from "../helpers/vue"; +import { createMemoryHistory, createRouter } from "@/testing/router"; + +const mocks = vi.hoisted(() => ({ + repoApi: { + create: vi.fn(), + }, +})); + +vi.mock("@/utils/api", () => ({ + repoAPI: mocks.repoApi, +})); + +import { useAuthStore } from "@/stores/auth"; +import NewRepoPage from "@/pages/new.vue"; + +describe("new repository page", () => { + beforeEach(() => { + vi.clearAllMocks(); + setActivePinia(createPinia()); + }); + + async function createTestRouter(initialPath) { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: "/new", component: { template: "
" } }, + { path: "/:pathMatch(.*)*", component: { template: "
" } }, + ], + }); + + await router.push(initialPath); + await router.isReady(); + return router; + } + + function mountPage(router) { + return mount(NewRepoPage, { + global: { + plugins: [router], + stubs: ElementPlusStubs, + }, + }); + } + + it("creates repositories for organizations and navigates to the new repo", async () => { + const router = await createTestRouter("/new?type=dataset"); + const pushSpy = vi.spyOn(router, "push"); + const authStore = useAuthStore(); + authStore.user = { username: "mai_lin" }; + authStore.userOrganizations = [{ name: "aurora-labs" }]; + mocks.repoApi.create.mockResolvedValue({ + data: { repo_id: "aurora-labs/vision-set" }, + }); + + const wrapper = mountPage(router); + await wrapper.find('select[data-el-select="true"]').setValue("aurora-labs"); + await wrapper.find('input[placeholder="my-awesome-dataset"]').setValue("vision-set"); + await wrapper + .findAll("button") + .find((button) => button.text().includes("Create Dataset")) + .trigger("click"); + await flushPromises(); + + expect(mocks.repoApi.create).toHaveBeenCalledWith({ + type: "dataset", + name: "vision-set", + organization: "aurora-labs", + private: false, + }); + expect(pushSpy).toHaveBeenCalledWith( + "/datasets/aurora-labs/vision-set", + ); + }); + + it("keeps the personal namespace null and stays on the page after failures", async () => { + const router = await createTestRouter("/new?type=space"); + const pushSpy = vi.spyOn(router, "push"); + const authStore = useAuthStore(); + authStore.user = { username: "mai_lin" }; + authStore.userOrganizations = []; + mocks.repoApi.create.mockRejectedValue({ + response: { + data: { detail: "Name already exists" }, + }, + }); + + const wrapper = mountPage(router); + await wrapper.find('input[placeholder="my-awesome-space"]').setValue("my-demo"); + await wrapper + .findAll("button") + .find((button) => button.text().includes("Create Space")) + .trigger("click"); + await flushPromises(); + + expect(mocks.repoApi.create).toHaveBeenCalledWith({ + type: "space", + name: "my-demo", + organization: null, + private: false, + }); + expect(pushSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/test/kohaku-hub-ui/pages/test_repo_route_pages.test.js b/test/kohaku-hub-ui/pages/test_repo_route_pages.test.js new file mode 100644 index 0000000..0290964 --- /dev/null +++ b/test/kohaku-hub-ui/pages/test_repo_route_pages.test.js @@ -0,0 +1,91 @@ +import { mount } from "@vue/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { RouterLinkStub } from "../helpers/vue"; + +const mocks = vi.hoisted(() => ({ + route: { + path: "/models/mai_lin/demo", + params: { + namespace: "mai_lin", + name: "demo", + branch: "main", + }, + query: {}, + }, +})); + +vi.mock("vue-router/auto", () => ({ + useRoute: () => mocks.route, +})); + +vi.mock("@/components/pages/RepoListPage.vue", () => ({ + default: { + name: "RepoListPage", + props: ["repoType"], + template: '
{{ repoType }}
', + }, +})); + +vi.mock("@/components/repo/RepoViewer.vue", () => ({ + default: { + name: "RepoViewer", + props: ["repoType", "namespace", "name", "tab", "branch", "currentPath"], + template: + '
{{ repoType }}|{{ namespace }}|{{ name }}|{{ tab }}|{{ branch || "" }}|{{ currentPath || "" }}
', + }, +})); + +import DatasetPage from "@/pages/datasets.vue"; +import ModelPage from "@/pages/models.vue"; +import SpacePage from "@/pages/spaces.vue"; +import RepoIndexPage from "@/pages/[type]s/[namespace]/[name]/index.vue"; +import RepoTreePage from "@/pages/[type]s/[namespace]/[name]/tree/[branch]/index.vue"; + +describe("repo route pages", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.route.path = "/models/mai_lin/demo"; + mocks.route.params = { + namespace: "mai_lin", + name: "demo", + branch: "main", + }; + mocks.route.query = {}; + }); + + function mountPage(component) { + return mount(component, { + global: { + stubs: { + RouterLink: RouterLinkStub, + }, + }, + }); + } + + it("binds the correct repository type for list pages", () => { + expect(mountPage(ModelPage).text()).toContain("model"); + expect(mountPage(DatasetPage).text()).toContain("dataset"); + expect(mountPage(SpacePage).text()).toContain("space"); + }); + + it("passes route-derived props into repo viewer wrappers", () => { + mocks.route.path = "/datasets/aurora-labs/vision-set"; + mocks.route.params = { + namespace: "aurora-labs", + name: "vision-set", + branch: "release", + }; + mocks.route.query = { tab: "files" }; + + const indexWrapper = mountPage(RepoIndexPage); + expect(indexWrapper.text()).toContain( + "dataset|aurora-labs|vision-set|files||", + ); + + mocks.route.path = "/spaces/mai_lin/demo/tree/dev"; + const treeWrapper = mountPage(RepoTreePage); + expect(treeWrapper.text()).toContain("space|aurora-labs|vision-set|files|release|"); + }); +}); diff --git a/test/kohaku-hub-ui/pages/test_upload_page.test.js b/test/kohaku-hub-ui/pages/test_upload_page.test.js new file mode 100644 index 0000000..0f08180 --- /dev/null +++ b/test/kohaku-hub-ui/pages/test_upload_page.test.js @@ -0,0 +1,115 @@ +import { flushPromises, mount } from "@vue/test-utils"; +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ElementPlusStubs, RouterLinkStub } from "../helpers/vue"; +import { createMemoryHistory, createRouter } from "@/testing/router"; + +vi.mock("@/components/repo/FileUploader.vue", () => ({ + default: { + name: "FileUploader", + emits: ["upload-success", "upload-error"], + template: ` +
+ + +
+ `, + }, +})); + +import { useAuthStore } from "@/stores/auth"; +import UploadPage from "@/pages/[type]s/[namespace]/[name]/upload/[branch].vue"; + +describe("upload page", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + setActivePinia(createPinia()); + }); + + async function createTestRouter(initialPath) { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/datasets/:namespace/:name/upload/:branch", + component: { template: "
" }, + }, + { + path: "/datasets/:namespace/:name", + component: { template: "
" }, + }, + { path: "/:pathMatch(.*)*", component: { template: "
" } }, + ], + }); + + await router.push(initialPath); + await router.isReady(); + return router; + } + + function mountPage(router) { + return mount(UploadPage, { + global: { + plugins: [router], + stubs: { + ...ElementPlusStubs, + RouterLink: RouterLinkStub, + }, + }, + }); + } + + it("redirects visitors who are not allowed to upload", async () => { + const router = await createTestRouter( + "/datasets/aurora-labs/vision-set/upload/main", + ); + const pushSpy = vi.spyOn(router, "push"); + const authStore = useAuthStore(); + authStore.user = null; + + const wrapper = mountPage(router); + await flushPromises(); + + expect(wrapper.text()).toContain("Upload Files"); + expect(pushSpy).toHaveBeenCalledWith( + "/datasets/aurora-labs/vision-set", + ); + }); + + it("handles upload success and back navigation for authorized users", async () => { + const router = await createTestRouter( + "/datasets/aurora-labs/vision-set/upload/main", + ); + const pushSpy = vi.spyOn(router, "push"); + const authStore = useAuthStore(); + authStore.user = { username: "mai_lin" }; + authStore.userOrganizations = [{ name: "aurora-labs" }]; + + const wrapper = mountPage(router); + await flushPromises(); + + await wrapper.get('button[data-action="success"]').trigger("click"); + await vi.advanceTimersByTimeAsync(1000); + expect(pushSpy).toHaveBeenCalledWith( + "/datasets/aurora-labs/vision-set", + ); + + await wrapper + .findAll("button") + .find((button) => button.text().includes("Back to Repository")) + .trigger("click"); + expect(pushSpy).toHaveBeenCalledWith( + "/datasets/aurora-labs/vision-set", + ); + }); +}); diff --git a/test/kohaku-hub-ui/setup/msw-server.js b/test/kohaku-hub-ui/setup/msw-server.js new file mode 100644 index 0000000..3eb626d --- /dev/null +++ b/test/kohaku-hub-ui/setup/msw-server.js @@ -0,0 +1,3 @@ +import { setupServer } from "@/testing/msw"; + +export const server = setupServer(); diff --git a/test/kohaku-hub-ui/setup/vitest.setup.js b/test/kohaku-hub-ui/setup/vitest.setup.js index 3d19eaf..ccf233d 100644 --- a/test/kohaku-hub-ui/setup/vitest.setup.js +++ b/test/kohaku-hub-ui/setup/vitest.setup.js @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest"; +import { server } from "./msw-server"; class ResizeObserverMock { observe() {} @@ -28,6 +29,15 @@ beforeEach(() => { }); afterEach(() => { + server.resetHandlers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +beforeAll(() => { + server.listen({ onUnhandledRequest: "error" }); +}); + +afterAll(() => { + server.close(); +}); diff --git a/test/kohaku-hub-ui/stores/test_auth.test.js b/test/kohaku-hub-ui/stores/test_auth.test.js index a97ce17..b8eaed0 100644 --- a/test/kohaku-hub-ui/stores/test_auth.test.js +++ b/test/kohaku-hub-ui/stores/test_auth.test.js @@ -62,6 +62,58 @@ describe("auth store", () => { expect(store.organizationNames).toEqual(["acme-labs"]); }); + it("logs in, registers, and loads external tokens through the shared APIs", async () => { + authApiMock.login.mockResolvedValue({ data: { ok: true } }); + authApiMock.register.mockResolvedValue({ + data: { message: "Registration successful" }, + }); + authApiMock.listExternalTokens.mockResolvedValue({ + data: [{ url: "https://hf.co", token: "masked" }], + }); + settingsApiMock.whoamiV2.mockResolvedValue({ + data: { + id: "1", + name: "owner", + email: "owner@example.com", + emailVerified: true, + orgs: [{ name: "acme-labs" }], + }, + }); + + const store = await createStore(); + + await expect( + store.register({ + username: "owner", + email: "owner@example.com", + password: "secret", + }), + ).resolves.toEqual({ + message: "Registration successful", + }); + + await expect( + store.login({ + username: "owner", + password: "secret", + }), + ).resolves.toEqual({ ok: true }); + await store.loadExternalTokens(); + + expect(authApiMock.login).toHaveBeenCalledWith({ + username: "owner", + password: "secret", + }); + expect(authApiMock.register).toHaveBeenCalledWith({ + username: "owner", + email: "owner@example.com", + password: "secret", + }); + expect(store.externalTokens).toEqual([ + { url: "https://hf.co", token: "masked" }, + ]); + }); + it("clears auth state when init fails", async () => { localStorage.setItem("hf_token", "persisted-token"); settingsApiMock.whoamiV2.mockRejectedValue(new Error("unauthorized")); @@ -97,4 +149,62 @@ describe("auth store", () => { expect(store.canWriteToNamespace("acme-labs")).toBe(true); expect(store.canWriteToNamespace("someone-else")).toBe(false); }); + + it("clears state on fetch failures and skips repeated init calls", async () => { + settingsApiMock.whoamiV2.mockResolvedValue({ + data: { + id: "1", + name: "owner", + email: "owner@example.com", + emailVerified: true, + orgs: [{ name: "acme-labs" }], + }, + }); + authApiMock.listExternalTokens.mockResolvedValue({ data: [] }); + authApiMock.me.mockRejectedValue(new Error("expired")); + + const store = await createStore(); + + await store.init(); + await store.init(); + + expect(settingsApiMock.whoamiV2).toHaveBeenCalledTimes(1); + expect(authApiMock.listExternalTokens).toHaveBeenCalledTimes(1); + + await expect(store.fetchUser()).rejects.toThrow("expired"); + expect(store.user).toBeNull(); + expect(store.userOrganizations).toEqual([]); + }); + + it("clears local state on logout even when the API errors", async () => { + authApiMock.logout.mockRejectedValue(new Error("network")); + + const store = await createStore(); + store.user = { username: "owner" }; + store.userOrganizations = [{ name: "acme-labs" }]; + store.token = "persisted-token"; + store.externalTokens = [{ url: "https://hf.co", token: "masked" }]; + localStorage.setItem("hf_token", "persisted-token"); + + await expect(store.logout()).rejects.toThrow("network"); + + expect(store.user).toBeNull(); + expect(store.token).toBeNull(); + expect(localStorage.getItem("hf_token")).toBeNull(); + expect(clearRepoSortPreferenceMock).toHaveBeenCalled(); + }); + + it("handles external token loading for anonymous and failing requests", async () => { + const store = await createStore(); + + await store.loadExternalTokens(); + expect(authApiMock.listExternalTokens).not.toHaveBeenCalled(); + expect(store.externalTokens).toEqual([]); + + store.user = { username: "owner" }; + authApiMock.listExternalTokens.mockRejectedValue(new Error("boom")); + + await store.loadExternalTokens(); + expect(store.externalTokens).toEqual([]); + }); }); diff --git a/test/kohaku-hub-ui/stores/test_theme.test.js b/test/kohaku-hub-ui/stores/test_theme.test.js new file mode 100644 index 0000000..3d18d27 --- /dev/null +++ b/test/kohaku-hub-ui/stores/test_theme.test.js @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +describe("theme store", () => { + beforeEach(() => { + localStorage.clear(); + document.documentElement.className = ""; + }); + + async function createStore() { + const piniaModule = await import("pinia"); + piniaModule.setActivePinia(piniaModule.createPinia()); + const themeModule = await import("@/stores/theme"); + return themeModule.useThemeStore(); + } + + it("initializes from persisted dark mode and applies the class", async () => { + localStorage.setItem("theme", "dark"); + + const store = await createStore(); + store.init(); + + expect(store.isDark).toBe(true); + expect(document.documentElement.classList.contains("dark")).toBe(true); + }); + + it("toggles and explicitly sets the theme", async () => { + const store = await createStore(); + + store.toggle(); + expect(store.isDark).toBe(true); + expect(localStorage.getItem("theme")).toBe("dark"); + + store.setTheme(false); + expect(store.isDark).toBe(false); + expect(document.documentElement.classList.contains("dark")).toBe(false); + expect(localStorage.getItem("theme")).toBe("light"); + }); +}); diff --git a/test/kohaku-hub-ui/test_app.test.js b/test/kohaku-hub-ui/test_app.test.js new file mode 100644 index 0000000..e1f8b4d --- /dev/null +++ b/test/kohaku-hub-ui/test_app.test.js @@ -0,0 +1,79 @@ +import { mount } from "@vue/test-utils"; +import { defineComponent, h, nextTick, ref } from "vue"; +import { describe, expect, it, vi } from "vitest"; + +const mountCounts = { + repoViewer: 0, +}; + +const routeState = ref({ + path: "/models/mai_lin/lineart-caption-base", +}); + +vi.mock("@/components/layout/TheHeader.vue", () => ({ + default: { + name: "TheHeader", + template: '
Header
', + }, +})); + +vi.mock("@/components/layout/TheFooter.vue", () => ({ + default: { + name: "TheFooter", + template: '', + }, +})); + +const RepoViewerStub = defineComponent({ + name: "RepoViewer", + setup() { + mountCounts.repoViewer += 1; + return () => h("div", { "data-repo-viewer": "true" }, routeState.value.path); + }, +}); + +const RouterViewStub = defineComponent({ + name: "RouterView", + setup(_, { slots }) { + return () => + slots.default({ + Component: RepoViewerStub, + route: routeState.value, + }); + }, +}); + +import App from "@/App.vue"; + +describe("App shell", () => { + it("renders the layout and reuses repo views for the same repository", async () => { + mountCounts.repoViewer = 0; + routeState.value = { + path: "/models/mai_lin/lineart-caption-base", + }; + + const wrapper = mount(App, { + global: { + components: { + RouterView: RouterViewStub, + }, + }, + }); + + expect(wrapper.find('[data-header="true"]').exists()).toBe(true); + expect(wrapper.find('[data-footer="true"]').exists()).toBe(true); + expect(mountCounts.repoViewer).toBe(1); + + routeState.value = { + path: "/models/mai_lin/lineart-caption-base/tree/main", + }; + await nextTick(); + expect(mountCounts.repoViewer).toBe(1); + + routeState.value = { + path: "/models/mai_lin/another-repo", + }; + await nextTick(); + expect(mountCounts.repoViewer).toBe(2); + }); +}); diff --git a/test/kohaku-hub-ui/utils/test_api.test.js b/test/kohaku-hub-ui/utils/test_api.test.js new file mode 100644 index 0000000..b8ad2d5 --- /dev/null +++ b/test/kohaku-hub-ui/utils/test_api.test.js @@ -0,0 +1,501 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("frontend API client", () => { + async function loadModules() { + vi.resetModules(); + const apiModule = await import("@/utils/api"); + const lfsModule = await import("@/utils/lfs.js"); + return { + ...apiModule, + apiClient: apiModule.default, + lfsModule, + }; + } + + beforeEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it("applies request and response interceptors using stored tokens", async () => { + localStorage.setItem("hf_token", "local-token"); + localStorage.setItem( + "hf_external_tokens", + JSON.stringify([{ url: "https://hf.example", token: "ext-token" }]), + ); + + const { apiClient } = await loadModules(); + + const requestHandler = apiClient.interceptors.request.handlers[0]; + const responseHandler = apiClient.interceptors.response.handlers[0]; + + const config = requestHandler.fulfilled({ headers: {} }); + expect(config.headers.Authorization).toBe( + "Bearer local-token|https://hf.example,ext-token", + ); + + localStorage.removeItem("hf_token"); + localStorage.removeItem("hf_external_tokens"); + const emptyConfig = requestHandler.fulfilled({ headers: {} }); + expect(emptyConfig.headers.Authorization).toBeUndefined(); + + const payload = { ok: true }; + expect(responseHandler.fulfilled(payload)).toBe(payload); + await expect(responseHandler.rejected(new Error("boom"))).rejects.toThrow( + "boom", + ); + }); + + it("routes auth, repo, org, settings, and validation helpers through the shared axios client", async () => { + const { + apiClient, + authAPI, + repoAPI, + orgAPI, + settingsAPI, + validationAPI, + } = await loadModules(); + + const getSpy = vi.spyOn(apiClient, "get").mockResolvedValue({ data: {} }); + const postSpy = vi.spyOn(apiClient, "post").mockResolvedValue({ data: {} }); + const putSpy = vi.spyOn(apiClient, "put").mockResolvedValue({ data: {} }); + const deleteSpy = vi + .spyOn(apiClient, "delete") + .mockResolvedValue({ data: {} }); + + await authAPI.register({ + username: "alice", + email: "alice@example.com", + password: "secret", + invitation_token: "invite-token", + }); + await authAPI.login({ username: "alice", password: "secret" }); + await authAPI.logout(); + await authAPI.me(); + await authAPI.createToken({ name: "ci" }); + await authAPI.listTokens(); + await authAPI.revokeToken("token-id"); + await authAPI.getAvailableSources(); + await authAPI.listExternalTokens("alice"); + await authAPI.addExternalToken("alice", "https://hf.example", "token"); + await authAPI.deleteExternalToken("alice", "https://hf.example"); + await authAPI.bulkUpdateExternalTokens("alice", [ + { url: "https://hf.example", token: "token" }, + ]); + + await repoAPI.create({ + type: "model", + name: "demo", + organization: null, + private: false, + }); + await repoAPI.delete({ + type: "model", + name: "demo", + organization: "acme", + }); + await repoAPI.getInfo("model", "alice", "demo"); + await repoAPI.listRepos("dataset", { limit: 5, sort: "likes" }); + await repoAPI.getUserOverview("alice", "recent", 10); + await repoAPI.listTree("model", "alice", "demo", "main", "/nested", { + recursive: true, + }); + await repoAPI.listCommits("space", "alice", "demo", "main", { + limit: 20, + }); + + await orgAPI.create({ name: "acme" }); + await orgAPI.get("acme"); + await orgAPI.addMember("acme", { username: "bob", role: "member" }); + await orgAPI.removeMember("acme", "bob"); + await orgAPI.updateMemberRole("acme", "bob", { role: "admin" }); + await orgAPI.getUserOrgs("alice"); + await orgAPI.updateSettings("acme", { description: "new" }); + await orgAPI.listMembers("acme"); + + await settingsAPI.whoamiV2(); + await settingsAPI.getUserProfile("alice"); + await settingsAPI.updateUserSettings("alice", { bio: "hello" }); + await settingsAPI.getOrgProfile("acme"); + await settingsAPI.uploadUserAvatar( + "alice", + new File(["avatar"], "avatar.png", { type: "image/png" }), + ); + await settingsAPI.deleteUserAvatar("alice"); + await settingsAPI.uploadOrgAvatar( + "acme", + new File(["avatar"], "avatar.png", { type: "image/png" }), + ); + await settingsAPI.deleteOrgAvatar("acme"); + await settingsAPI.updateRepoSettings("model", "alice", "demo", { + private: true, + }); + await settingsAPI.getLfsSettings("dataset", "alice", "demo"); + await settingsAPI.moveRepo({ + fromRepo: "alice/old", + toRepo: "alice/new", + type: "model", + }); + await settingsAPI.squashRepo({ repo: "alice/demo", type: "model" }); + await settingsAPI.createBranch("model", "alice", "demo", { + branch: "dev", + }); + await settingsAPI.deleteBranch("model", "alice", "demo", "dev"); + await settingsAPI.createTag("model", "alice", "demo", { + tag: "v1.0.0", + revision: "main", + }); + await settingsAPI.deleteTag("model", "alice", "demo", "v1.0.0"); + + await validationAPI.checkName({ + name: "demo", + namespace: "alice", + type: "model", + }); + + expect(postSpy).toHaveBeenCalledWith( + "/api/auth/register", + { + username: "alice", + email: "alice@example.com", + password: "secret", + }, + { params: { invitation_token: "invite-token" } }, + ); + expect(postSpy).toHaveBeenCalledWith("/api/auth/login", { + username: "alice", + password: "secret", + }); + expect(postSpy).toHaveBeenCalledWith("/api/repos/create", { + type: "model", + name: "demo", + organization: null, + private: false, + }); + expect(deleteSpy).toHaveBeenCalledWith("/api/repos/delete", { + data: { type: "model", name: "demo", organization: "acme" }, + }); + expect(getSpy).toHaveBeenCalledWith("/api/models/alice/demo"); + expect(getSpy).toHaveBeenCalledWith("/api/datasets", { + params: { limit: 5, sort: "likes" }, + }); + expect(getSpy).toHaveBeenCalledWith("/api/users/alice/repos", { + params: { limit: 10, sort: "recent" }, + }); + expect(getSpy).toHaveBeenCalledWith( + "/api/models/alice/demo/tree/main/nested", + { params: { recursive: true } }, + ); + expect(postSpy).toHaveBeenCalledWith("/org/create", { name: "acme" }); + expect(putSpy).toHaveBeenCalledWith( + "/api/organizations/acme/settings", + { description: "new" }, + ); + expect(postSpy).toHaveBeenCalledWith( + "/api/users/alice/avatar", + expect.any(FormData), + { headers: { "Content-Type": "multipart/form-data" } }, + ); + expect(postSpy).toHaveBeenCalledWith( + "/api/organizations/acme/avatar", + expect.any(FormData), + { headers: { "Content-Type": "multipart/form-data" } }, + ); + expect(postSpy).toHaveBeenCalledWith("/api/repos/move", { + fromRepo: "alice/old", + toRepo: "alice/new", + type: "model", + }); + expect(postSpy).toHaveBeenCalledWith("/api/models/alice/demo/branch", { + branch: "dev", + }); + expect(deleteSpy).toHaveBeenCalledWith( + "/api/models/alice/demo/branch/dev", + ); + expect(postSpy).toHaveBeenCalledWith("/api/models/alice/demo/tag", { + tag: "v1.0.0", + revision: "main", + }); + expect(deleteSpy).toHaveBeenCalledWith( + "/api/models/alice/demo/tag/v1.0.0", + ); + expect(postSpy).toHaveBeenCalledWith("/api/validate/check-name", { + name: "demo", + namespace: "alice", + type: "model", + }); + }); + + it("routes invitation, quota, likes, and stats helpers through the shared axios client", async () => { + const { apiClient, invitationAPI, quotaAPI, likesAPI, statsAPI } = + await loadModules(); + + const getSpy = vi.spyOn(apiClient, "get").mockResolvedValue({ data: {} }); + const postSpy = vi.spyOn(apiClient, "post").mockResolvedValue({ data: {} }); + const putSpy = vi.spyOn(apiClient, "put").mockResolvedValue({ data: {} }); + const deleteSpy = vi + .spyOn(apiClient, "delete") + .mockResolvedValue({ data: {} }); + + await invitationAPI.create("acme", { + email: "invitee@example.com", + role: "member", + }); + await invitationAPI.get("token-1"); + await invitationAPI.accept("token-1"); + await invitationAPI.list("acme"); + await invitationAPI.delete("token-1"); + + await quotaAPI.getRepoQuota("model", "alice", "demo"); + await quotaAPI.setRepoQuota("model", "alice", "demo", { + quota_bytes: 1024, + }); + await quotaAPI.recalculateRepoStorage("model", "alice", "demo"); + await quotaAPI.getNamespaceRepoStorage("alice"); + + await likesAPI.like("model", "alice", "demo"); + await likesAPI.unlike("model", "alice", "demo"); + await likesAPI.checkLiked("model", "alice", "demo"); + await likesAPI.getLikers("model", "alice", "demo", 10); + + await statsAPI.getStats("model", "alice", "demo"); + await statsAPI.getRecentStats("dataset", "alice", "demo", 14); + await statsAPI.getTrending("space", 30, 5); + + expect(postSpy).toHaveBeenCalledWith("/api/invitations/org/acme/create", { + email: "invitee@example.com", + role: "member", + }); + expect(postSpy).toHaveBeenCalledWith("/api/invitations/token-1/accept"); + expect(deleteSpy).toHaveBeenCalledWith("/api/invitations/token-1"); + expect(getSpy).toHaveBeenCalledWith("/api/quota/repo/model/alice/demo"); + expect(putSpy).toHaveBeenCalledWith( + "/api/quota/repo/model/alice/demo", + { quota_bytes: 1024 }, + ); + expect(postSpy).toHaveBeenCalledWith( + "/api/quota/repo/model/alice/demo/recalculate", + ); + expect(postSpy).toHaveBeenCalledWith("/api/models/alice/demo/like"); + expect(deleteSpy).toHaveBeenCalledWith("/api/models/alice/demo/like"); + expect(getSpy).toHaveBeenCalledWith("/api/models/alice/demo/likers", { + params: { limit: 10 }, + }); + expect(getSpy).toHaveBeenCalledWith("/api/models/alice/demo/stats"); + expect(getSpy).toHaveBeenCalledWith( + "/api/datasets/alice/demo/stats/recent", + { params: { days: 14 } }, + ); + expect(getSpy).toHaveBeenCalledWith("/api/trending", { + params: { repo_type: "space", days: 30, limit: 5 }, + }); + }); + + it("builds NDJSON commits for ignored, regular, LFS, and editor flows", async () => { + const originalFileReader = globalThis.FileReader; + globalThis.FileReader = class { + readAsDataURL(blob) { + const reader = new originalFileReader(); + reader.onload = (event) => { + this.result = `data:text/plain;base64,${Buffer.from(event.target.result).toString("base64")}`; + this.onload?.({ target: this }); + }; + reader.readAsArrayBuffer(blob); + } + }; + + const { apiClient, repoAPI, lfsModule } = await loadModules(); + + vi.spyOn(lfsModule, "calculateSHA256") + .mockResolvedValueOnce("sha-skip") + .mockResolvedValueOnce("sha-regular") + .mockResolvedValueOnce("sha-lfs") + .mockResolvedValueOnce("sha-missing"); + + vi.spyOn(lfsModule, "uploadLFSFile").mockImplementation( + async (repoId, file, sha256, onProgress) => { + expect(repoId).toBe("alice/demo"); + onProgress(0.5); + onProgress(1); + return { oid: `oid-${sha256}`, size: file.size }; + }, + ); + + const postSpy = vi + .spyOn(apiClient, "post") + .mockResolvedValueOnce({ + data: { + files: [ + { + path: "skip.txt", + shouldIgnore: true, + uploadMode: "regular", + }, + { + path: "notes.md", + shouldIgnore: false, + uploadMode: "regular", + }, + { + path: "weights/model.bin", + shouldIgnore: false, + uploadMode: "lfs", + }, + ], + }, + }) + .mockResolvedValueOnce({ + data: { + commitOid: "abc123", + commitUrl: "models/alice/demo/commit/abc123", + }, + }); + + const hashProgress = vi.fn(); + const uploadProgress = vi.fn(); + + const response = await repoAPI.uploadFiles( + "model", + "alice", + "demo", + "main", + { + message: "Upload files", + description: "Fixture-driven upload", + files: [ + { + path: "skip.txt", + file: new File(["same"], "skip.txt", { type: "text/plain" }), + }, + { + path: "notes.md", + file: new File(["hello"], "notes.md", { type: "text/plain" }), + }, + { + path: "weights/model.bin", + file: new File(["0101"], "model.bin", { + type: "application/octet-stream", + }), + }, + ], + }, + { + onHashProgress: hashProgress, + onUploadProgress: uploadProgress, + }, + ); + + expect(response.data.commitOid).toBe("abc123"); + expect(postSpy).toHaveBeenNthCalledWith( + 1, + "/api/models/alice/demo/preupload/main", + { + files: [ + { path: "skip.txt", size: 4, sha256: "sha-skip" }, + { path: "notes.md", size: 5, sha256: "sha-regular" }, + { path: "weights/model.bin", size: 4, sha256: "sha-lfs" }, + ], + }, + ); + + const uploadCommitLines = postSpy.mock.calls[1][1] + .split("\n") + .map((line) => JSON.parse(line)); + expect(uploadCommitLines).toEqual([ + { + key: "header", + value: { + summary: "Upload files", + description: "Fixture-driven upload", + }, + }, + { + key: "file", + value: { + path: "notes.md", + content: "aGVsbG8=", + encoding: "base64", + }, + }, + { + key: "lfsFile", + value: { + path: "weights/model.bin", + oid: "oid-sha-lfs", + size: 4, + algo: "sha256", + }, + }, + ]); + expect(uploadProgress).toHaveBeenCalledWith("notes.md", 1); + expect(uploadProgress).toHaveBeenCalledWith("model.bin", 1); + expect(hashProgress).toHaveBeenCalledWith("model.bin", 1); + + postSpy.mockReset(); + postSpy.mockResolvedValueOnce({ + data: { + files: [], + }, + }); + + await expect( + repoAPI.uploadFiles( + "dataset", + "alice", + "demo", + "main", + { + message: "Upload", + files: [ + { + path: "missing.txt", + file: new File(["x"], "missing.txt", { type: "text/plain" }), + }, + ], + }, + ), + ).rejects.toThrow("No preupload result for missing.txt"); + + postSpy.mockReset(); + postSpy.mockResolvedValue({ data: { commitOid: "commit-2" } }); + + await repoAPI.commitFiles("model", "alice", "demo", "main", { + message: "Edit README", + description: "Apply offline fixture changes", + files: [ + { + path: "README.md", + content: "Hello, δΈ–η•Œ", + }, + ], + operations: [ + { + operation: "deletedFile", + path: "obsolete.txt", + }, + ], + }); + + const editorCommitLines = postSpy.mock.calls[0][1] + .split("\n") + .map((line) => JSON.parse(line)); + expect(editorCommitLines[0]).toEqual({ + key: "header", + value: { + summary: "Edit README", + description: "Apply offline fixture changes", + }, + }); + expect(editorCommitLines[1].key).toBe("file"); + expect(editorCommitLines[1].value.path).toBe("README.md"); + expect(editorCommitLines[1].value.encoding).toBe("base64"); + expect(editorCommitLines[2]).toEqual({ + key: "deletedFile", + value: { + path: "obsolete.txt", + }, + }); + + globalThis.FileReader = originalFileReader; + }); +}); diff --git a/test/kohaku-hub-ui/utils/test_clipboard.test.js b/test/kohaku-hub-ui/utils/test_clipboard.test.js new file mode 100644 index 0000000..ad67353 --- /dev/null +++ b/test/kohaku-hub-ui/utils/test_clipboard.test.js @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { copyToClipboard } from "@/utils/clipboard"; + +describe("clipboard utilities", () => { + beforeEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + it("uses the Clipboard API when available", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal("navigator", { + clipboard: { writeText }, + }); + + await expect(copyToClipboard("kohakuhub")).resolves.toBe(true); + expect(writeText).toHaveBeenCalledWith("kohakuhub"); + }); + + it("falls back to execCommand when the Clipboard API fails", async () => { + const writeText = vi.fn().mockRejectedValue(new Error("denied")); + const execCommand = vi.fn().mockReturnValue(true); + + vi.stubGlobal("navigator", { + clipboard: { writeText }, + }); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: execCommand, + }); + + await expect(copyToClipboard("fallback")).resolves.toBe(true); + expect(execCommand).toHaveBeenCalledWith("copy"); + expect(document.querySelector("textarea")).toBeNull(); + }); + + it("returns false when the fallback copy path throws", async () => { + vi.stubGlobal("navigator", {}); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: () => { + throw new Error("copy failed"); + }, + }); + + await expect(copyToClipboard("broken")).resolves.toBe(false); + expect(document.querySelector("textarea")).toBeNull(); + }); +}); diff --git a/test/kohaku-hub-ui/utils/test_datetime.test.js b/test/kohaku-hub-ui/utils/test_datetime.test.js index 1a79a15..6d22b6c 100644 --- a/test/kohaku-hub-ui/utils/test_datetime.test.js +++ b/test/kohaku-hub-ui/utils/test_datetime.test.js @@ -32,4 +32,20 @@ describe("datetime utilities", () => { expect(typeof unixFormatted).toBe("string"); expect(unixFormatted.length).toBeGreaterThan(0); }); + + it("handles Date objects and unix timestamps", () => { + const dateFormatted = formatRelativeTime( + new Date(Date.now() - 5 * 60_000), + "never", + ); + expect(typeof dateFormatted).toBe("string"); + expect(dateFormatted.length).toBeGreaterThan(0); + + const unixFormatted = formatUnixRelativeTime( + Math.floor(Date.now() / 1000) + 10, + "Unknown", + ); + expect(typeof unixFormatted).toBe("string"); + expect(unixFormatted.length).toBeGreaterThan(0); + }); }); diff --git a/test/kohaku-hub-ui/utils/test_lfs.test.js b/test/kohaku-hub-ui/utils/test_lfs.test.js new file mode 100644 index 0000000..510090d --- /dev/null +++ b/test/kohaku-hub-ui/utils/test_lfs.test.js @@ -0,0 +1,215 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { http, HttpResponse } from "@/testing/msw"; + +import { server } from "../setup/msw-server"; + +describe("LFS utilities", () => { + async function loadModule() { + vi.resetModules(); + return import("@/utils/lfs"); + } + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("calculates SHA256 incrementally and verifies the result", async () => { + const { calculateSHA256, verifyFileSHA256 } = await loadModule(); + + const chunkSize = 64 * 1024 * 1024; + const file = { + name: "large.bin", + size: chunkSize + 7, + slice(start) { + return start === 0 ? new Blob(["first-chunk"]) : new Blob(["tail"]); + }, + }; + + const progress = vi.fn(); + const digest = await calculateSHA256(file, progress); + + expect(digest).toHaveLength(64); + expect(progress).toHaveBeenCalledTimes(2); + expect(progress).toHaveBeenLastCalledWith(1); + await expect(verifyFileSHA256(file, digest)).resolves.toBe(true); + await expect(verifyFileSHA256(file, "0".repeat(64))).resolves.toBe(false); + }); + + it("uploads a single-part LFS object and formats sizes", async () => { + server.use( + http.post("*/alice/demo.git/info/lfs/objects/batch", async ({ request }) => { + const body = await request.json(); + expect(body.operation).toBe("upload"); + expect(body.objects).toEqual([{ oid: "sha-single", size: 4 }]); + + return HttpResponse.json({ + objects: [ + { + actions: { + upload: { + href: "https://s3.example/upload", + header: { + "Content-Type": "application/octet-stream", + }, + }, + verify: { + href: "https://s3.example/verify", + }, + }, + }, + ], + }); + }), + http.put("https://s3.example/upload", async ({ request }) => { + expect(request.headers.get("content-type")).toContain( + "application/octet-stream", + ); + return new HttpResponse(null, { status: 200 }); + }), + http.post("https://s3.example/verify", async ({ request }) => { + const body = await request.json(); + expect(body).toEqual({ oid: "sha-single", size: 4 }); + return HttpResponse.json({ ok: true }); + }), + ); + + const { uploadLFSFile, formatFileSize } = await loadModule(); + const file = new File(["data"], "weights.bin", { + type: "application/octet-stream", + }); + + await expect( + uploadLFSFile("alice/demo", file, "sha-single"), + ).resolves.toEqual({ + oid: "sha-single", + size: 4, + }); + + expect(formatFileSize(0)).toBe("0 B"); + expect(formatFileSize(999)).toBe("999 B"); + expect(formatFileSize(1_500)).toBe("1.5 KB"); + expect(formatFileSize(1_500_000)).toBe("1.5 MB"); + expect(formatFileSize(1_500_000_000)).toBe("1.5 GB"); + }); + + it("handles deduplicated objects and batch errors", async () => { + const { uploadLFSFile } = await loadModule(); + const file = new File(["data"], "weights.bin", { + type: "application/octet-stream", + }); + + server.use( + http.post("*/alice/demo.git/info/lfs/objects/batch", () => + HttpResponse.json({ + objects: [ + { + oid: "sha-existing", + size: 4, + }, + ], + }), + ), + ); + + await expect( + uploadLFSFile("alice/demo", file, "sha-existing"), + ).resolves.toEqual({ + oid: "sha-existing", + size: 4, + }); + + server.use( + http.post("*/alice/demo.git/info/lfs/objects/batch", () => + HttpResponse.json({ + objects: [ + { + error: { + message: "permission denied", + }, + }, + ], + }), + ), + ); + + await expect( + uploadLFSFile("alice/demo", file, "sha-error"), + ).rejects.toThrow("LFS batch error: permission denied"); + }); + + it("uploads multipart LFS objects and completes the upload", async () => { + server.use( + http.post("*/alice/demo.git/info/lfs/objects/batch", () => + HttpResponse.json({ + objects: [ + { + actions: { + upload: { + href: "https://s3.example/complete", + header: { + chunk_size: "3", + upload_id: "upload-1", + 1: "https://s3.example/part-1", + 2: "https://s3.example/part-2", + 3: "https://s3.example/part-3", + }, + }, + verify: { + href: "https://s3.example/verify", + }, + }, + }, + ], + }), + ), + http.put("https://s3.example/part-1", () => + new HttpResponse(null, { + status: 200, + headers: { ETag: '"etag-1"' }, + }), + ), + http.put("https://s3.example/part-2", () => + new HttpResponse(null, { + status: 200, + headers: { ETag: '"etag-2"' }, + }), + ), + http.put("https://s3.example/part-3", () => + new HttpResponse(null, { + status: 200, + headers: { ETag: '"etag-3"' }, + }), + ), + http.post("https://s3.example/complete", async ({ request }) => { + const body = await request.json(); + expect(body).toEqual({ + oid: "sha-multipart", + size: 9, + parts: [ + { PartNumber: 1, ETag: "etag-1" }, + { PartNumber: 2, ETag: "etag-2" }, + { PartNumber: 3, ETag: "etag-3" }, + ], + }); + return HttpResponse.json({ ok: true }); + }), + http.post("https://s3.example/verify", async ({ request }) => { + const body = await request.json(); + expect(body).toEqual({ oid: "sha-multipart", size: 9 }); + return HttpResponse.json({ ok: true }); + }), + ); + + const { uploadLFSFile } = await loadModule(); + const file = new File(["abcdefghi"], "archive.bin", { + type: "application/octet-stream", + }); + + await expect( + uploadLFSFile("alice/demo", file, "sha-multipart"), + ).resolves.toEqual({ + oid: "sha-multipart", + size: 9, + }); + }); +});