Files
SnapOtter/tests/unit/web/features-store.test.ts
T

588 lines
19 KiB
TypeScript
Raw Normal View History

// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const apiGetMock = vi.fn();
const apiPostMock = vi.fn();
vi.mock("@/lib/api", () => ({
apiGet: (...args: unknown[]) => apiGetMock(...args),
apiPost: (...args: unknown[]) => apiPostMock(...args),
}));
class FakeEventSource {
static instances: FakeEventSource[] = [];
url: string;
onmessage: ((event: { data: string }) => void) | null = null;
onerror: (() => void) | null = null;
closed = false;
constructor(url: string) {
this.url = url;
FakeEventSource.instances.push(this);
}
close() {
this.closed = true;
}
static reset() {
FakeEventSource.instances = [];
}
}
vi.stubGlobal("EventSource", FakeEventSource);
import type { FeatureBundleState } from "@snapotter/shared";
import { useFeaturesStore } from "@/stores/features-store";
function makeBundleState(
overrides: Partial<FeatureBundleState> & { id: string },
): FeatureBundleState {
return {
name: overrides.id,
description: "Test bundle",
status: "not_installed",
installedVersion: null,
estimatedSize: "100 MB",
enablesTools: [],
progress: null,
error: null,
...overrides,
};
}
describe("useFeaturesStore", () => {
beforeEach(() => {
useFeaturesStore.setState({
bundles: [],
loaded: false,
loadError: false,
installing: {},
errors: {},
queued: [],
installAllActive: false,
startTimes: {},
});
vi.clearAllMocks();
FakeEventSource.reset();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("initial state", () => {
it("loaded is false", () => {
expect(useFeaturesStore.getState().loaded).toBe(false);
});
it("loadError is false", () => {
expect(useFeaturesStore.getState().loadError).toBe(false);
});
it("bundles is empty array", () => {
expect(useFeaturesStore.getState().bundles).toEqual([]);
});
it("installing is empty object", () => {
expect(useFeaturesStore.getState().installing).toEqual({});
});
it("errors is empty object", () => {
expect(useFeaturesStore.getState().errors).toEqual({});
});
it("queued is empty array", () => {
expect(useFeaturesStore.getState().queued).toEqual([]);
});
it("installAllActive is false", () => {
expect(useFeaturesStore.getState().installAllActive).toBe(false);
});
});
describe("fetch()", () => {
it("sets bundles from response and loaded = true on success", async () => {
const bundles = [makeBundleState({ id: "bundle-a", status: "installed" })];
apiGetMock.mockResolvedValueOnce({ bundles });
await useFeaturesStore.getState().fetch();
expect(apiGetMock).toHaveBeenCalledWith("/v1/features");
expect(useFeaturesStore.getState().bundles).toEqual(bundles);
expect(useFeaturesStore.getState().loaded).toBe(true);
expect(useFeaturesStore.getState().loadError).toBe(false);
});
it("sets loadError = true on failure", async () => {
apiGetMock.mockRejectedValueOnce(new Error("Network error"));
await useFeaturesStore.getState().fetch();
expect(useFeaturesStore.getState().loaded).toBe(true);
expect(useFeaturesStore.getState().loadError).toBe(true);
});
it("does a background refresh if already loaded without error", async () => {
const bundles = [makeBundleState({ id: "bundle-a" })];
useFeaturesStore.setState({ loaded: true, loadError: false, bundles });
apiGetMock.mockResolvedValueOnce({ bundles });
await useFeaturesStore.getState().fetch();
// Should call apiGet for a background refresh (not short-circuit)
expect(apiGetMock).toHaveBeenCalledWith("/v1/features");
});
it("retries fetch if previously loaded with error", async () => {
useFeaturesStore.setState({ loaded: true, loadError: true });
const bundles = [makeBundleState({ id: "bundle-b" })];
apiGetMock.mockResolvedValueOnce({ bundles });
await useFeaturesStore.getState().fetch();
expect(apiGetMock).toHaveBeenCalled();
expect(useFeaturesStore.getState().bundles).toEqual(bundles);
expect(useFeaturesStore.getState().loadError).toBe(false);
});
it("recovers active installs after successful fetch", async () => {
const bundles = [
makeBundleState({
id: "active-bundle",
status: "installing",
progress: { percent: 42, stage: "Downloading..." },
}),
];
apiGetMock.mockResolvedValueOnce({ bundles });
await useFeaturesStore.getState().fetch();
const state = useFeaturesStore.getState();
expect(state.installing["active-bundle"]).toBeDefined();
expect(state.installing["active-bundle"].percent).toBe(42);
expect(state.installing["active-bundle"].stage).toBe("Downloading...");
});
});
describe("isToolInstalled()", () => {
it("returns true when tool has no bundle requirement", () => {
expect(useFeaturesStore.getState().isToolInstalled("resize")).toBe(true);
});
it("returns true when tool bundle is installed", () => {
useFeaturesStore.setState({
bundles: [
makeBundleState({
id: "background-removal",
status: "installed",
enablesTools: ["remove-background"],
}),
],
});
expect(useFeaturesStore.getState().isToolInstalled("remove-background")).toBe(true);
});
it("returns false when bundle exists but not installed", () => {
useFeaturesStore.setState({
bundles: [
makeBundleState({
id: "background-removal",
status: "not_installed",
enablesTools: ["remove-background"],
}),
],
});
expect(useFeaturesStore.getState().isToolInstalled("remove-background")).toBe(false);
});
});
describe("getBundleForTool()", () => {
it("returns matching bundle state", () => {
const bundle = makeBundleState({
id: "background-removal",
status: "installed",
});
useFeaturesStore.setState({ bundles: [bundle] });
const result = useFeaturesStore.getState().getBundleForTool("remove-background");
expect(result).toEqual(bundle);
});
it("returns null when no bundle for tool", () => {
const result = useFeaturesStore.getState().getBundleForTool("resize");
expect(result).toBeNull();
});
it("returns null when bundle id has no matching state", () => {
useFeaturesStore.setState({ bundles: [] });
const result = useFeaturesStore.getState().getBundleForTool("remove-background");
expect(result).toBeNull();
});
});
describe("clearError()", () => {
it("removes error for specified bundleId", () => {
useFeaturesStore.setState({
errors: { "bundle-a": "Something went wrong", "bundle-b": "Another error" },
});
useFeaturesStore.getState().clearError("bundle-a");
const errors = useFeaturesStore.getState().errors;
expect(errors["bundle-a"]).toBeUndefined();
expect(errors["bundle-b"]).toBe("Another error");
});
it("does nothing if error does not exist for bundleId", () => {
useFeaturesStore.setState({ errors: { "bundle-a": "Error" } });
useFeaturesStore.getState().clearError("bundle-nonexistent");
expect(useFeaturesStore.getState().errors).toEqual({ "bundle-a": "Error" });
});
});
describe("installBundle()", () => {
it("sets installing state and calls API", async () => {
apiPostMock.mockResolvedValueOnce({ jobId: "job-123" });
const promise = useFeaturesStore.getState().installBundle("test-bundle");
await vi.waitFor(() => {
expect(useFeaturesStore.getState().installing["test-bundle"]).toBeDefined();
});
expect(useFeaturesStore.getState().installing["test-bundle"].percent).toBe(5);
expect(useFeaturesStore.getState().installing["test-bundle"].stage).toBe("Starting...");
await vi.waitFor(() => {
expect(apiPostMock).toHaveBeenCalledWith("/v1/admin/features/test-bundle/install", {});
});
expect(FakeEventSource.instances.length).toBe(1);
expect(FakeEventSource.instances[0].url).toBe("/api/v1/jobs/job-123/progress");
const es = FakeEventSource.instances[0];
es.onmessage?.({ data: JSON.stringify({ phase: "complete" }) });
await promise;
});
it("on API failure: sets error and clears installing", async () => {
apiPostMock.mockRejectedValueOnce(new Error("Server error"));
await useFeaturesStore.getState().installBundle("fail-bundle");
const state = useFeaturesStore.getState();
expect(state.installing["fail-bundle"]).toBeUndefined();
expect(state.errors["fail-bundle"]).toBe("Server error");
});
it("on API failure with non-Error: uses fallback message", async () => {
apiPostMock.mockRejectedValueOnce("some string error");
await useFeaturesStore.getState().installBundle("fail-bundle2");
const state = useFeaturesStore.getState();
expect(state.installing["fail-bundle2"]).toBeUndefined();
expect(state.errors["fail-bundle2"]).toBe("Failed to start installation");
});
it("clears previous error for the bundle before installing", async () => {
useFeaturesStore.setState({ errors: { "retry-bundle": "Old error" } });
apiPostMock.mockResolvedValueOnce({ jobId: "job-retry" });
const promise = useFeaturesStore.getState().installBundle("retry-bundle");
await vi.waitFor(() => {
expect(useFeaturesStore.getState().errors["retry-bundle"]).toBeUndefined();
});
const es = FakeEventSource.instances[0];
es.onmessage?.({ data: JSON.stringify({ phase: "complete" }) });
await promise;
});
});
describe("uninstallBundle()", () => {
it("calls API and refreshes", async () => {
apiPostMock.mockResolvedValueOnce({});
apiGetMock.mockResolvedValueOnce({ bundles: [] });
await useFeaturesStore.getState().uninstallBundle("remove-bundle");
expect(apiPostMock).toHaveBeenCalledWith("/v1/admin/features/remove-bundle/uninstall", {});
expect(apiGetMock).toHaveBeenCalledWith("/v1/features");
});
it("sets error on failure with Error instance", async () => {
apiPostMock.mockRejectedValueOnce(new Error("Uninstall failed hard"));
await useFeaturesStore.getState().uninstallBundle("fail-uninstall");
expect(useFeaturesStore.getState().errors["fail-uninstall"]).toBe("Uninstall failed hard");
});
it("sets fallback error on failure with non-Error", async () => {
apiPostMock.mockRejectedValueOnce(42);
await useFeaturesStore.getState().uninstallBundle("fail-uninstall2");
expect(useFeaturesStore.getState().errors["fail-uninstall2"]).toBe("Uninstall failed");
});
});
describe("reinstallBundle()", () => {
it("calls uninstall then install in sequence", async () => {
apiPostMock.mockResolvedValueOnce({}).mockResolvedValueOnce({ jobId: "job-reinstall" });
apiGetMock.mockResolvedValueOnce({ bundles: [] });
const promise = useFeaturesStore.getState().reinstallBundle("re-bundle");
await vi.waitFor(() => {
expect(apiPostMock).toHaveBeenCalledWith("/v1/admin/features/re-bundle/uninstall", {});
});
await vi.waitFor(() => {
expect(apiPostMock).toHaveBeenCalledWith("/v1/admin/features/re-bundle/install", {});
});
await vi.waitFor(() => {
expect(FakeEventSource.instances.length).toBeGreaterThan(0);
});
const es = FakeEventSource.instances[0];
es.onmessage?.({ data: JSON.stringify({ phase: "complete" }) });
await promise;
});
});
describe("installAll()", () => {
it("sets installAllActive = true and processes all uninstalled bundles", async () => {
const bundles = [
makeBundleState({ id: "bundle-1", status: "not_installed" }),
makeBundleState({ id: "bundle-2", status: "installed" }),
makeBundleState({ id: "bundle-3", status: "not_installed" }),
];
useFeaturesStore.setState({ bundles, loaded: true });
apiPostMock.mockImplementation((path: string) => {
if (path.includes("install")) {
return Promise.resolve({ jobId: `job-${path}` });
}
return Promise.resolve({});
});
apiGetMock.mockResolvedValue({
bundles: bundles.map((b) => ({ ...b, status: "installed" })),
});
const promise = useFeaturesStore.getState().installAll();
await vi.waitFor(() => {
expect(useFeaturesStore.getState().installAllActive).toBe(true);
});
const completeAllOpen = () => {
for (const es of FakeEventSource.instances) {
if (!es.closed) {
es.onmessage?.({ data: JSON.stringify({ phase: "complete" }) });
}
}
};
await vi.waitFor(() => {
expect(FakeEventSource.instances.length).toBeGreaterThan(0);
});
completeAllOpen();
await vi
.waitFor(
() => {
if (FakeEventSource.instances.length < 2) {
throw new Error("waiting for second EventSource");
}
},
{ timeout: 5000 },
)
.catch(() => {});
completeAllOpen();
await promise;
const state = useFeaturesStore.getState();
expect(state.installAllActive).toBe(false);
expect(state.queued).toEqual([]);
}, 15000);
it("clears stale errors for pending bundles", async () => {
const bundles = [makeBundleState({ id: "err-bundle", status: "error" })];
useFeaturesStore.setState({
bundles,
loaded: true,
errors: { "err-bundle": "Old failure" },
});
apiPostMock.mockResolvedValue({ jobId: "job-err" });
apiGetMock.mockResolvedValue({
bundles: bundles.map((b) => ({ ...b, status: "installed" })),
});
const promise = useFeaturesStore.getState().installAll();
await vi.waitFor(() => {
expect(useFeaturesStore.getState().errors["err-bundle"]).toBeUndefined();
});
await vi.waitFor(() => {
expect(FakeEventSource.instances.length).toBeGreaterThan(0);
});
for (const es of FakeEventSource.instances) {
es.onmessage?.({ data: JSON.stringify({ phase: "complete" }) });
}
await promise;
}, 15000);
});
describe("EventSource progress handling", () => {
it("updates progress on message", async () => {
apiPostMock.mockResolvedValueOnce({ jobId: "job-progress" });
const promise = useFeaturesStore.getState().installBundle("progress-bundle");
await vi.waitFor(() => {
expect(FakeEventSource.instances.length).toBe(1);
});
const es = FakeEventSource.instances[0];
es.onmessage?.({
data: JSON.stringify({ phase: "downloading", percent: 50, stage: "Downloading models..." }),
});
await vi.waitFor(() => {
const installing = useFeaturesStore.getState().installing["progress-bundle"];
expect(installing).toBeDefined();
expect(installing.percent).toBe(50);
expect(installing.stage).toBe("Downloading models...");
});
es.onmessage?.({ data: JSON.stringify({ phase: "complete" }) });
await promise;
});
it("handles failed phase from EventSource", async () => {
apiPostMock.mockResolvedValueOnce({ jobId: "job-fail" });
const promise = useFeaturesStore.getState().installBundle("fail-es-bundle");
await vi.waitFor(() => {
expect(FakeEventSource.instances.length).toBe(1);
});
const es = FakeEventSource.instances[0];
es.onmessage?.({
data: JSON.stringify({ phase: "failed", error: "Download failed" }),
});
await promise;
const state = useFeaturesStore.getState();
expect(state.installing["fail-es-bundle"]).toBeUndefined();
expect(state.errors["fail-es-bundle"]).toBe("Download failed");
});
it("handles failed phase with no error message", async () => {
apiPostMock.mockResolvedValueOnce({ jobId: "job-fail-noerr" });
const promise = useFeaturesStore.getState().installBundle("fail-noerr-bundle");
await vi.waitFor(() => {
expect(FakeEventSource.instances.length).toBe(1);
});
const es = FakeEventSource.instances[0];
es.onmessage?.({ data: JSON.stringify({ phase: "failed" }) });
await promise;
expect(useFeaturesStore.getState().errors["fail-noerr-bundle"]).toBe("Installation failed");
});
it("falls back to polling on EventSource error", async () => {
apiPostMock.mockResolvedValueOnce({ jobId: "job-es-err" });
useFeaturesStore.getState().installBundle("poll-bundle");
await vi.waitFor(() => {
expect(FakeEventSource.instances.length).toBe(1);
});
const es = FakeEventSource.instances[0];
es.onerror?.();
expect(es.closed).toBe(true);
});
it("progress percent never decreases", async () => {
apiPostMock.mockResolvedValueOnce({ jobId: "job-monotonic" });
const promise = useFeaturesStore.getState().installBundle("mono-bundle");
await vi.waitFor(() => {
expect(FakeEventSource.instances.length).toBe(1);
});
const es = FakeEventSource.instances[0];
es.onmessage?.({
data: JSON.stringify({ phase: "downloading", percent: 60, stage: "Stage A" }),
});
await vi.waitFor(() => {
expect(useFeaturesStore.getState().installing["mono-bundle"]?.percent).toBe(60);
});
es.onmessage?.({
data: JSON.stringify({ phase: "downloading", percent: 30, stage: "Stage B" }),
});
await vi.waitFor(() => {
const installing = useFeaturesStore.getState().installing["mono-bundle"];
expect(installing?.percent).toBe(60);
expect(installing?.stage).toBe("Stage B");
});
es.onmessage?.({ data: JSON.stringify({ phase: "complete" }) });
await promise;
});
});
describe("refresh()", () => {
it("refreshes bundles from API", async () => {
const bundles = [makeBundleState({ id: "refreshed" })];
apiGetMock.mockResolvedValueOnce({ bundles });
await useFeaturesStore.getState().refresh();
expect(apiGetMock).toHaveBeenCalledWith("/v1/features");
expect(useFeaturesStore.getState().bundles).toEqual(bundles);
expect(useFeaturesStore.getState().loaded).toBe(true);
});
it("silently handles refresh failure", async () => {
useFeaturesStore.setState({ bundles: [makeBundleState({ id: "existing" })], loaded: true });
apiGetMock.mockRejectedValueOnce(new Error("fail"));
await useFeaturesStore.getState().refresh();
expect(useFeaturesStore.getState().bundles).toEqual([makeBundleState({ id: "existing" })]);
});
});
});