mirror of
https://github.com/KohakuBlueleaf/KohakuHub.git
synced 2026-05-07 03:57:42 -05:00
test: raise UI coverage with offline fixtures
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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/
|
||||
|
||||
1567
src/kohaku-hub-ui/package-lock.json
generated
1567
src/kohaku-hub-ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
1
src/kohaku-hub-ui/src/testing/axios.js
Normal file
1
src/kohaku-hub-ui/src/testing/axios.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "axios";
|
||||
2
src/kohaku-hub-ui/src/testing/msw.js
Normal file
2
src/kohaku-hub-ui/src/testing/msw.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { http, HttpResponse } from "msw";
|
||||
export { setupServer } from "msw/node";
|
||||
1
src/kohaku-hub-ui/src/testing/router.js
Normal file
1
src/kohaku-hub-ui/src/testing/router.js
Normal file
@@ -0,0 +1 @@
|
||||
export { createMemoryHistory, createRouter } from "vue-router";
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
142
test/kohaku-hub-ui/components/test_file_uploader.test.js
Normal file
142
test/kohaku-hub-ui/components/test_file_uploader.test.js
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
25
test/kohaku-hub-ui/components/test_footer.test.js
Normal file
25
test/kohaku-hub-ui/components/test_footer.test.js
Normal file
@@ -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",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
123
test/kohaku-hub-ui/components/test_header.test.js
Normal file
123
test/kohaku-hub-ui/components/test_header.test.js
Normal file
@@ -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("/");
|
||||
});
|
||||
});
|
||||
165
test/kohaku-hub-ui/components/test_repo_list_page.test.js
Normal file
165
test/kohaku-hub-ui/components/test_repo_list_page.test.js
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
156
test/kohaku-hub-ui/pages/test_auth_pages.test.js
Normal file
156
test/kohaku-hub-ui/pages/test_auth_pages.test.js
Normal file
@@ -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: "<div />" } },
|
||||
{ path: "/register", component: { template: "<div />" } },
|
||||
{ path: "/:pathMatch(.*)*", component: { template: "<div />" } },
|
||||
],
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
139
test/kohaku-hub-ui/pages/test_home_page.test.js
Normal file
139
test/kohaku-hub-ui/pages/test_home_page.test.js
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
108
test/kohaku-hub-ui/pages/test_new_page.test.js
Normal file
108
test/kohaku-hub-ui/pages/test_new_page.test.js
Normal file
@@ -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: "<div />" } },
|
||||
{ path: "/:pathMatch(.*)*", component: { template: "<div />" } },
|
||||
],
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
91
test/kohaku-hub-ui/pages/test_repo_route_pages.test.js
Normal file
91
test/kohaku-hub-ui/pages/test_repo_route_pages.test.js
Normal file
@@ -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: '<div data-repo-list-page="true">{{ repoType }}</div>',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/repo/RepoViewer.vue", () => ({
|
||||
default: {
|
||||
name: "RepoViewer",
|
||||
props: ["repoType", "namespace", "name", "tab", "branch", "currentPath"],
|
||||
template:
|
||||
'<div data-repo-viewer="true">{{ repoType }}|{{ namespace }}|{{ name }}|{{ tab }}|{{ branch || "" }}|{{ currentPath || "" }}</div>',
|
||||
},
|
||||
}));
|
||||
|
||||
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|");
|
||||
});
|
||||
});
|
||||
115
test/kohaku-hub-ui/pages/test_upload_page.test.js
Normal file
115
test/kohaku-hub-ui/pages/test_upload_page.test.js
Normal file
@@ -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: `
|
||||
<div data-file-uploader="true">
|
||||
<button type="button" data-action="success" @click="$emit('upload-success')">
|
||||
Upload success
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-action="error"
|
||||
@click="$emit('upload-error', { response: { data: { detail: 'Upload failed badly' } } })"
|
||||
>
|
||||
Upload error
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
}));
|
||||
|
||||
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: "<div />" },
|
||||
},
|
||||
{
|
||||
path: "/datasets/:namespace/:name",
|
||||
component: { template: "<div />" },
|
||||
},
|
||||
{ path: "/:pathMatch(.*)*", component: { template: "<div />" } },
|
||||
],
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
3
test/kohaku-hub-ui/setup/msw-server.js
Normal file
3
test/kohaku-hub-ui/setup/msw-server.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { setupServer } from "@/testing/msw";
|
||||
|
||||
export const server = setupServer();
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
38
test/kohaku-hub-ui/stores/test_theme.test.js
Normal file
38
test/kohaku-hub-ui/stores/test_theme.test.js
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
79
test/kohaku-hub-ui/test_app.test.js
Normal file
79
test/kohaku-hub-ui/test_app.test.js
Normal file
@@ -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 data-header="true">Header</header>',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/layout/TheFooter.vue", () => ({
|
||||
default: {
|
||||
name: "TheFooter",
|
||||
template: '<footer data-footer="true">Footer</footer>',
|
||||
},
|
||||
}));
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
501
test/kohaku-hub-ui/utils/test_api.test.js
Normal file
501
test/kohaku-hub-ui/utils/test_api.test.js
Normal file
@@ -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;
|
||||
});
|
||||
});
|
||||
50
test/kohaku-hub-ui/utils/test_clipboard.test.js
Normal file
50
test/kohaku-hub-ui/utils/test_clipboard.test.js
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
215
test/kohaku-hub-ui/utils/test_lfs.test.js
Normal file
215
test/kohaku-hub-ui/utils/test_lfs.test.js
Normal file
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user