test: raise UI coverage with offline fixtures

This commit is contained in:
narugo1992
2026-04-21 03:05:31 +08:00
parent 8770440fc3
commit 5a4429b30c
26 changed files with 4003 additions and 6 deletions

2
.gitignore vendored
View File

@@ -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/

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -0,0 +1 @@
export { default } from "axios";

View File

@@ -0,0 +1,2 @@
export { http, HttpResponse } from "msw";
export { setupServer } from "msw/node";

View File

@@ -0,0 +1 @@
export { createMemoryHistory, createRouter } from "vue-router";

View File

@@ -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",
],
},
},

View 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");
});
});

View 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",
]),
);
});
});

View 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("/");
});
});

View 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");
});
});

View File

@@ -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({

View 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",
);
});
});

View 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");
});
});

View 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();
});
});

View 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|");
});
});

View 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",
);
});
});

View File

@@ -0,0 +1,3 @@
import { setupServer } from "@/testing/msw";
export const server = setupServer();

View File

@@ -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();
});

View File

@@ -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([]);
});
});

View 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");
});
});

View 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);
});
});

View 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;
});
});

View 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();
});
});

View File

@@ -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);
});
});

View 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,
});
});
});