Files
SnapOtter/tests/unit/web/features-store.test.ts
T
SnapOtterandGitHub b4375e558d fix: harden install queue/dispatcher lifecycle and repair review-sweep regressions (#395)
Fixes 15 defects found by a max-effort multi-agent review of the last 6
merged PRs (#388, #390, #391, #392, #393, #394), all adversarially
verified before fixing.

Install queue + dispatcher (the serious cluster):

- features.ts: finalize the installer child exactly once. A failed spawn
  fires both "error" and "close", and the second event released the file
  lock and active slot that pump() had just handed to the next queued
  bundle, letting two pip processes write the same venv concurrently.
  Outcome recording now happens before pump() so the next bundle's first
  progress frame cannot race the previous install's bookkeeping.
- feature-status.ts: keep failed-install errors in a per-bundle map
  instead of the single progress slot. With the queue auto-starting the
  next install, the slot was overwritten within seconds and a failed
  install vanished without ever surfacing to GET /features.
- bridge.ts: scope child lifecycle per process (stopped-children set +
  request generation tags) instead of an instance-wide shuttingDown flag
  that the next spawn reset. A stale SIGTERMed child's late close event
  could record a phantom crash (5 of which permanently disable the
  dispatcher), null out the freshly spawned child, and reject the new
  child's pending requests. The request-timeout kill path still counts
  as a real crash.
- install_feature.py: the pre-write disk re-check measured ai_dir's
  filesystem even when budgeting the cross-filesystem copy that lands on
  the venv's disk; now each budget is checked against the filesystem the
  bytes actually land on, so ENOSPC cannot strike mid-write and leave
  site-packages half overwritten.

Behavior regressions:

- embed-subtitles: preserve pre-existing subtitle tracks (0:s?) and MKV
  attachments (0:t?) that the -map 0:v:0/0:a? rewrite silently dropped;
  data streams stay unmapped on purpose (the actual MPEG remux fix). The
  new subtitle maps first so the language tag hits the right stream.
- usage-survey-overlay: fail closed when the settings fetch fails; the
  fail-open path rendered the blocking survey against an unhealthy API
  and soft-locked admins, the lock-out class #392 fixed.
- features-store: queued bundles poll instead of each holding an SSE
  connection (Install All could pin 7 EventSources and exhaust the
  browser's 6-per-origin HTTP/1.1 limit, hanging the whole app);
  listenToProgress closes any prior stream and stops any poll before
  subscribing; installAll skips bundles already installing or queued.

Contracts, tests, i18n:

- openapi.yaml: add "queued" to the features status enum and document
  downloadBytes/installedBytes (Schemathesis conformance).
- feature-lifecycle e2e: queue transcription (~0.5 GB) instead of ocr
  (~6 GB) and give the test a budget that covers both install drains
  (the stacked waits exceeded the old 900s timeout).
- docker-compose.qa.yml: parameterize the host port (QA_APP_PORT) so
  QA_PROJECT_NAME concurrent stacks can actually bind.
- compare + watermark-image: restore per-input error attribution
  ("Invalid first/second image", "Invalid watermark image") lost in the
  shared-handler migration.
- ai-features-section: the "{size} on disk" suffix now goes through
  i18n; key added to all 21 locales.
- watermark-image + content-aware-resize: migrate to the shared
  inputHandlerFor("image") chain like compare/vectorize/compose, fixing
  drift in the inline copies (no SVG sanitize, no RAW extension hint,
  no AVIF probe).

Verified: typecheck across 9 workspaces, Biome clean on all changed
files, 584 targeted unit tests and 249 integration tests green
(including real-ffmpeg embed-subtitles runs). One unit test updated to
the new poll-while-queued contract with a single-EventSource assertion.

Claude-Session: https://claude.ai/code/session_017mR1HiHaf3a1BmUtrHX4j3
2026-07-03 13:47:15 +08:00

700 lines
24 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("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);
});
// passport-photo needs background-removal AND face-detection (issue #327).
it("returns false for passport-photo when only background-removal is installed", () => {
useFeaturesStore.setState({
bundles: [
makeBundleState({ id: "background-removal", status: "installed" }),
makeBundleState({ id: "face-detection", status: "not_installed" }),
],
});
expect(useFeaturesStore.getState().isToolInstalled("passport-photo")).toBe(false);
});
it("returns true for passport-photo only when both required bundles are installed", () => {
useFeaturesStore.setState({
bundles: [
makeBundleState({ id: "background-removal", status: "installed" }),
makeBundleState({ id: "face-detection", status: "installed" }),
],
});
expect(useFeaturesStore.getState().isToolInstalled("passport-photo")).toBe(true);
});
});
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();
});
it("points at the first missing required bundle for a multi-bundle tool", () => {
const bg = makeBundleState({ id: "background-removal", status: "installed" });
const face = makeBundleState({ id: "face-detection", status: "not_installed" });
useFeaturesStore.setState({ bundles: [bg, face] });
// passport-photo needs both; background-removal is installed, so the
// prompt should ask for face-detection.
expect(useFeaturesStore.getState().getBundleForTool("passport-photo")).toEqual(face);
});
});
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()", () => {
// The server serializes installs now, so installAll just fires one POST per
// not-installed bundle and lets the backend queue them.
it("POSTs an install for every not-installed bundle and sets installAllActive", 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) => Promise.resolve({ jobId: `job-${path}` }));
await useFeaturesStore.getState().installAll();
expect(useFeaturesStore.getState().installAllActive).toBe(true);
await vi.waitFor(() => {
expect(apiPostMock).toHaveBeenCalledWith("/v1/admin/features/bundle-1/install", {});
expect(apiPostMock).toHaveBeenCalledWith("/v1/admin/features/bundle-3/install", {});
});
// The already-installed bundle is not POSTed.
expect(apiPostMock).not.toHaveBeenCalledWith("/v1/admin/features/bundle-2/install", {});
// Complete both installs; installAllActive clears once installing + queued drain.
await vi.waitFor(() => {
expect(FakeEventSource.instances.length).toBe(2);
});
for (const es of FakeEventSource.instances) {
es.onmessage?.({ data: JSON.stringify({ phase: "complete" }) });
}
await vi.waitFor(() => {
expect(useFeaturesStore.getState().installAllActive).toBe(false);
});
expect(useFeaturesStore.getState().queued).toEqual([]);
}, 15000);
it("reflects server-queued bundles in the queued pill during Install All", async () => {
const bundles = [
makeBundleState({ id: "bundle-a", status: "not_installed" }),
makeBundleState({ id: "bundle-b", status: "not_installed" }),
];
useFeaturesStore.setState({ bundles, loaded: true });
// Server starts bundle-a immediately and queues bundle-b.
apiPostMock.mockImplementation((path: string) =>
Promise.resolve({
jobId: `job-${path}`,
queued: path.includes("bundle-b"),
}),
);
// The poll for the queued bundle reads server state from GET /v1/features.
let bundleBStatus: FeatureBundleState["status"] = "queued";
apiGetMock.mockImplementation(() =>
Promise.resolve({
bundles: [
makeBundleState({ id: "bundle-a", status: "installing" }),
makeBundleState({ id: "bundle-b", status: bundleBStatus }),
],
}),
);
await useFeaturesStore.getState().installAll();
await vi.waitFor(() => {
expect(useFeaturesStore.getState().queued).toContain("bundle-b");
expect(useFeaturesStore.getState().installing["bundle-a"]).toBeDefined();
});
expect(useFeaturesStore.getState().installing["bundle-b"]).toBeUndefined();
// A queued bundle must NOT hold an SSE connection open (it polls
// instead): only the actively-installing bundle has an EventSource.
expect(FakeEventSource.instances).toHaveLength(1);
expect(FakeEventSource.instances[0].url).toContain("bundle-a");
// The server starts bundle-b; its poll moves it queued -> installing.
bundleBStatus = "installing";
await vi.waitFor(
() => {
expect(useFeaturesStore.getState().queued).not.toContain("bundle-b");
expect(useFeaturesStore.getState().installing["bundle-b"]).toBeDefined();
},
{ timeout: 8000 },
);
// Finish both: bundle-a via its SSE stream, bundle-b via its poll
// observing the terminal server status.
bundleBStatus = "installed";
FakeEventSource.instances[0].onmessage?.({ data: JSON.stringify({ phase: "complete" }) });
await vi.waitFor(
() => {
expect(useFeaturesStore.getState().installAllActive).toBe(false);
},
{ timeout: 8000 },
);
}, 25000);
it("retries a bundle once if it fails during Install All, then stops", async () => {
const bundles = [makeBundleState({ id: "flaky", status: "not_installed" })];
useFeaturesStore.setState({ bundles, loaded: true });
apiPostMock.mockImplementation((path: string) => Promise.resolve({ jobId: `job-${path}` }));
await useFeaturesStore.getState().installAll();
// First attempt fails -> one retry (a second install POST + EventSource).
await vi.waitFor(() => {
expect(FakeEventSource.instances.length).toBe(1);
});
FakeEventSource.instances[0].onmessage?.({
data: JSON.stringify({ phase: "failed", error: "boom" }),
});
await vi.waitFor(() => {
expect(FakeEventSource.instances.length).toBe(2);
});
// Still active (retrying), no error surfaced yet.
expect(useFeaturesStore.getState().installAllActive).toBe(true);
expect(useFeaturesStore.getState().errors.flaky).toBeUndefined();
// Second attempt fails too -> no further retry; error recorded, run ends.
FakeEventSource.instances[1].onmessage?.({
data: JSON.stringify({ phase: "failed", error: "boom again" }),
});
await vi.waitFor(() => {
expect(useFeaturesStore.getState().installAllActive).toBe(false);
});
expect(FakeEventSource.instances.length).toBe(2);
expect(useFeaturesStore.getState().errors.flaky).toBe("boom again");
}, 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" })),
});
await 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 vi.waitFor(() => {
expect(useFeaturesStore.getState().installAllActive).toBe(false);
});
}, 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" })]);
});
});
});