mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
- Fix installAll tests timing out by returning installed status from refreshBundles mock (prevents infinite retry loop) and increasing waitFor/test timeouts to accommodate the 2s inter-install delay - Fix zustand-stores installAll test with same refreshBundles mock fix - Icon-map test already fixed to derive from shared constants
585 lines
18 KiB
TypeScript
585 lines
18 KiB
TypeScript
// @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("skips fetch if already loaded without error", async () => {
|
|
useFeaturesStore.setState({ loaded: true, loadError: false });
|
|
|
|
await useFeaturesStore.getState().fetch();
|
|
|
|
expect(apiGetMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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" })]);
|
|
});
|
|
});
|
|
});
|