mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: reliable, self-healing AI feature-bundle installs (#472)
Make on-demand AI feature-bundle installs reliable and self-healing, closing the failure modes behind most "some tool doesn't work" reports. Multi-bundle installs: tools needing more than one bundle (Passport Photo, Enhance Faces) install every required bundle from one action and stay not-installed until all are present. Verified across all 19 AI tools. Downloads: self-heal the accelerated Hugging Face (Xet) client so an upgraded venv no longer silently falls back to slow urllib; restart instead of corrupting a resumed partial when a proxy ignores Range and returns 200; verify the completed size; fail fast on disk-full and HTTP 4xx; retry transient errors five times; add hf_transfer fallback and document Xet egress. Install integrity: crash-atomic venv writes so a killed or out-of-space install can no longer tear the shared venv and break other tools; a boot breadcrumb reseeds a torn venv to a clean state automatically; a post-install smoke import test refuses to record a bundle whose libraries cannot load; an install watchdog stops a wedged installer that would otherwise hold the venv writer lock forever. Adds unit and end-to-end tests for every failure mode above.
This commit is contained in:
@@ -175,6 +175,14 @@ describe("POST /api/v1/admin/features/:bundleId/install queue", () => {
|
||||
});
|
||||
}
|
||||
|
||||
async function postToolInstall(toolId: string) {
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: `/api/v1/admin/tools/${toolId}/features/install`,
|
||||
headers: auth(),
|
||||
});
|
||||
}
|
||||
|
||||
async function getFeatures() {
|
||||
const res = await app.inject({ method: "GET", url: "/api/v1/features", headers: auth() });
|
||||
return JSON.parse(res.body).bundles as Array<{ id: string; status: string }>;
|
||||
@@ -209,6 +217,29 @@ describe("POST /api/v1/admin/features/:bundleId/install queue", () => {
|
||||
expect(bundles.find((b) => b.id === "face-detection")?.status).toBe("queued");
|
||||
});
|
||||
|
||||
it("tool install enqueues every missing hard dependency in one request", async () => {
|
||||
const res = await postToolInstall("passport-photo");
|
||||
expect(res.statusCode).toBe(202);
|
||||
const body = JSON.parse(res.body) as {
|
||||
bundles: Array<{ bundleId: string; jobId: string; queued: boolean }>;
|
||||
};
|
||||
|
||||
expect(body.bundles.map((b) => b.bundleId)).toEqual(["background-removal", "face-detection"]);
|
||||
expect(body.bundles[0].queued).toBe(false);
|
||||
expect(body.bundles[1].queued).toBe(true);
|
||||
|
||||
await waitFor(() => hoisted.spawnCalls.length === 1);
|
||||
expect(hoisted.spawnCalls[0].bundleId).toBe("background-removal");
|
||||
|
||||
const queuedBundles = await getFeatures();
|
||||
expect(queuedBundles.find((b) => b.id === "background-removal")?.status).toBe("installing");
|
||||
expect(queuedBundles.find((b) => b.id === "face-detection")?.status).toBe("queued");
|
||||
|
||||
hoisted.spawnCalls[0].emit("close", 0);
|
||||
await waitFor(() => hoisted.spawnCalls.length === 2);
|
||||
expect(hoisted.spawnCalls[1].bundleId).toBe("face-detection");
|
||||
});
|
||||
|
||||
it("dedups a duplicate install POST of the active bundle (no second entry, same job)", async () => {
|
||||
const r1 = await postInstall("ocr");
|
||||
const jobId1 = JSON.parse(r1.body).jobId;
|
||||
|
||||
@@ -1309,6 +1309,7 @@ describe("bridge - env passthrough for sidecar-specific vars", () => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.U2NET_HOME;
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.MODELS_PATH;
|
||||
delete process.env.DISPATCHER_MAX_REQUESTS;
|
||||
});
|
||||
|
||||
@@ -1360,9 +1361,10 @@ describe("bridge - env passthrough for sidecar-specific vars", () => {
|
||||
expect(getLastSpawnEnv()?.DISPATCHER_MAX_REQUESTS).toBe("100");
|
||||
});
|
||||
|
||||
it("does not include unset vars in subprocess env", async () => {
|
||||
it("defaults data/model paths but does not include other unset vars in subprocess env", async () => {
|
||||
delete process.env.U2NET_HOME;
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.MODELS_PATH;
|
||||
delete process.env.DISPATCHER_MAX_REQUESTS;
|
||||
|
||||
const mock = createMockProcess();
|
||||
@@ -1375,7 +1377,8 @@ describe("bridge - env passthrough for sidecar-specific vars", () => {
|
||||
|
||||
const env = getLastSpawnEnv();
|
||||
expect(env?.U2NET_HOME).toBeUndefined();
|
||||
expect(env?.DATA_DIR).toBeUndefined();
|
||||
expect(env?.DATA_DIR).toBe("./data");
|
||||
expect(env?.MODELS_PATH).toBe("./data/ai/models");
|
||||
expect(env?.DISPATCHER_MAX_REQUESTS).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,15 +9,18 @@ import {
|
||||
|
||||
let tempDir: string;
|
||||
let savedDataDir: string | undefined;
|
||||
let savedCwd: string;
|
||||
|
||||
beforeEach(() => {
|
||||
savedDataDir = process.env.DATA_DIR;
|
||||
savedCwd = process.cwd();
|
||||
tempDir = mkdtempSync(join(tmpdir(), "snapotter-gate-"));
|
||||
mkdirSync(join(tempDir, "ai"), { recursive: true });
|
||||
process.env.DATA_DIR = tempDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(savedCwd);
|
||||
if (savedDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = savedDataDir;
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
@@ -73,6 +76,28 @@ describe("missingBundleForScript", () => {
|
||||
expect(missingBundleForScript("face_landmarks")).toBe("face-detection");
|
||||
expect(missingBundleForScript("remove_bg")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the native ./data fallback when DATA_DIR is unset", () => {
|
||||
delete process.env.DATA_DIR;
|
||||
process.chdir(tempDir);
|
||||
const defaultAiDir = join(tempDir, "data", "ai");
|
||||
mkdirSync(defaultAiDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(defaultAiDir, "installed.json"),
|
||||
JSON.stringify({
|
||||
bundles: {
|
||||
"object-eraser-colorize": {
|
||||
version: "1.0.0-test",
|
||||
installedAt: "2026-01-01T00:00:00.000Z",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
expect(missingBundleForScript("inpaint.py")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SCRIPT_BUNDLE_MAP drift vs dispatcher.py", () => {
|
||||
|
||||
@@ -67,6 +67,7 @@ describe("buildMinimalEnv - env passthrough", () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.SNAPOTTER_GPU;
|
||||
delete process.env.MODELS_PATH;
|
||||
delete process.env.MODELS_DIR;
|
||||
@@ -110,6 +111,24 @@ describe("buildMinimalEnv - env passthrough", () => {
|
||||
expect(env?.MODELS_PATH).toBe("/data/ai/models");
|
||||
});
|
||||
|
||||
it("sets matching native DATA_DIR and MODELS_PATH defaults", async () => {
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.MODELS_PATH;
|
||||
|
||||
const mock = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mock.process);
|
||||
|
||||
const promise = runPythonWithProgress("test.py", []);
|
||||
mock.stdout.emit("data", Buffer.from('{"ok": true}\n'));
|
||||
mock.emitEvent("close", 0, null);
|
||||
await promise;
|
||||
|
||||
const env = getSpawnEnv();
|
||||
expect(env).toBeDefined();
|
||||
expect(env?.DATA_DIR).toBe("./data");
|
||||
expect(env?.MODELS_PATH).toBe("./data/ai/models");
|
||||
});
|
||||
|
||||
it("does not pass MODELS_DIR (removed dead entry)", async () => {
|
||||
process.env.MODELS_DIR = "/some/path";
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { evaluateInstallWatchdog } from "../../../apps/api/src/lib/install-watchdog.js";
|
||||
|
||||
const STALL = 20 * 60_000; // 20 min
|
||||
const MAX = 120 * 60_000; // 2 h
|
||||
|
||||
describe("evaluateInstallWatchdog", () => {
|
||||
it("does not kill an install making steady progress", () => {
|
||||
const now = 1_000_000;
|
||||
const v = evaluateInstallWatchdog(now, now - 5_000, now - 60_000, STALL, MAX);
|
||||
expect(v.kill).toBe(false);
|
||||
expect(v.reason).toBeNull();
|
||||
});
|
||||
|
||||
it("kills when no progress frame has arrived within the stall budget", () => {
|
||||
const now = 1_000_000;
|
||||
const v = evaluateInstallWatchdog(now, now - (STALL + 1), now - (STALL + 1), STALL, MAX);
|
||||
expect(v.kill).toBe(true);
|
||||
expect(v.reason).toContain("no progress");
|
||||
});
|
||||
|
||||
it("does not kill exactly at the stall boundary (strictly greater than)", () => {
|
||||
const now = 1_000_000;
|
||||
const v = evaluateInstallWatchdog(now, now - STALL, now - STALL, STALL, MAX);
|
||||
expect(v.kill).toBe(false);
|
||||
});
|
||||
|
||||
it("kills when the absolute time ceiling is exceeded even if progress is recent", () => {
|
||||
const now = 1_000_000;
|
||||
// Progress 1s ago (not stalled) but the install started > MAX ago.
|
||||
const v = evaluateInstallWatchdog(now, now - 1_000, now - (MAX + 1), STALL, MAX);
|
||||
expect(v.kill).toBe(true);
|
||||
expect(v.reason).toContain("time limit");
|
||||
});
|
||||
|
||||
it("prefers the absolute-ceiling reason when both conditions hold", () => {
|
||||
const now = 1_000_000;
|
||||
const v = evaluateInstallWatchdog(now, now - (STALL + 1), now - (MAX + 1), STALL, MAX);
|
||||
expect(v.kill).toBe(true);
|
||||
expect(v.reason).toContain("time limit");
|
||||
});
|
||||
|
||||
it("treats 0 as disabled for each check independently", () => {
|
||||
const now = 1_000_000;
|
||||
// Stall disabled, max active: a long stall alone must not kill.
|
||||
expect(evaluateInstallWatchdog(now, now - 10 * STALL, now - 1_000, 0, MAX).kill).toBe(false);
|
||||
// Max disabled, stall active: an old start alone must not kill.
|
||||
expect(evaluateInstallWatchdog(now, now - 1_000, now - 10 * MAX, STALL, 0).kill).toBe(false);
|
||||
// Both disabled: never kills.
|
||||
expect(evaluateInstallWatchdog(now, now - 10 * STALL, now - 10 * MAX, 0, 0).kill).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -100,6 +100,7 @@ vi.mock("../../../apps/api/src/lib/svg-sanitize.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../../apps/api/src/lib/feature-status.js", () => ({
|
||||
getFirstMissingBundleForTool: vi.fn(() => null),
|
||||
isToolInstalled: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
@@ -139,7 +140,10 @@ vi.mock("sharp", () => ({
|
||||
|
||||
import { apiToolPath } from "@snapotter/shared";
|
||||
import { enqueueToolJob, waitForJob } from "../../../apps/api/src/jobs/enqueue.js";
|
||||
import { isToolInstalled } from "../../../apps/api/src/lib/feature-status.js";
|
||||
import {
|
||||
getFirstMissingBundleForTool,
|
||||
isToolInstalled,
|
||||
} from "../../../apps/api/src/lib/feature-status.js";
|
||||
import { validateImageBuffer } from "../../../apps/api/src/lib/file-validation.js";
|
||||
import type { AnyToolRouteConfig } from "../../../apps/api/src/routes/tool-factory.js";
|
||||
import {
|
||||
@@ -281,6 +285,8 @@ function createMockRequest(opts: {
|
||||
describe("createToolRoute", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getFirstMissingBundleForTool).mockReset();
|
||||
vi.mocked(getFirstMissingBundleForTool).mockReturnValue(null);
|
||||
vi.mocked(isToolInstalled).mockReset();
|
||||
vi.mocked(isToolInstalled).mockReturnValue(true);
|
||||
});
|
||||
@@ -424,6 +430,31 @@ describe("createToolRoute", () => {
|
||||
// This validates the guard code path exists without false positives.
|
||||
});
|
||||
|
||||
it("returns 501 naming the first missing extra bundle for multi-bundle AI tools", async () => {
|
||||
vi.mocked(isToolInstalled).mockReturnValueOnce(false);
|
||||
vi.mocked(getFirstMissingBundleForTool).mockReturnValueOnce("face-detection");
|
||||
const app = createMockApp();
|
||||
const id = "enhance-faces";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[apiToolPath(id)];
|
||||
const reply = createMockReply();
|
||||
const req = createMockRequest({
|
||||
fileBuffer: Buffer.from("png-data"),
|
||||
settings: JSON.stringify({}),
|
||||
});
|
||||
|
||||
await handler(req, reply);
|
||||
|
||||
expect(reply.status).toHaveBeenCalledWith(501);
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
code: "FEATURE_NOT_INSTALLED",
|
||||
feature: "face-detection",
|
||||
featureName: "Face Detection",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 200 success envelope when waitForJob resolves", async () => {
|
||||
const app = createMockApp();
|
||||
const id = "resize";
|
||||
|
||||
@@ -388,6 +388,16 @@ describe("Feature status queries", () => {
|
||||
expect(mod.isToolInstalled("passport-photo")).toBe(true);
|
||||
});
|
||||
|
||||
it("isToolInstalled is false for enhance-faces when only upscale-enhance is installed", () => {
|
||||
mod.markInstalled("upscale-enhance", "1.0.0", []);
|
||||
expect(mod.isToolInstalled("enhance-faces")).toBe(false);
|
||||
});
|
||||
|
||||
it("getFirstMissingBundleForTool names face-detection for enhance-faces when only upscale-enhance is installed", () => {
|
||||
mod.markInstalled("upscale-enhance", "1.0.0", []);
|
||||
expect(mod.getFirstMissingBundleForTool("enhance-faces")).toBe("face-detection");
|
||||
});
|
||||
|
||||
it("getFirstMissingBundleForTool names face-detection when only background-removal is installed", () => {
|
||||
mod.markInstalled("background-removal", "1.0.0", []);
|
||||
expect(mod.getFirstMissingBundleForTool("passport-photo")).toBe("face-detection");
|
||||
@@ -559,6 +569,23 @@ describe("Crash recovery - recoverInterruptedInstalls", () => {
|
||||
expect(existsSync(lockPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("consumes a surviving venv.writing breadcrumb (interrupted venv write)", () => {
|
||||
const marker = join(aiDir, "venv.writing");
|
||||
writeFileSync(
|
||||
marker,
|
||||
JSON.stringify({ bundleId: "ocr", startedAt: "2026-01-01T00:00:00.000Z" }),
|
||||
);
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
expect(() => mod.recoverInterruptedInstalls()).not.toThrow();
|
||||
|
||||
// The breadcrumb is always consumed so it can't retrigger recovery forever.
|
||||
expect(existsSync(marker)).toBe(false);
|
||||
// And the interruption is surfaced in the logs.
|
||||
expect(warn.mock.calls.flat().join(" ")).toMatch(/interrupted|venv/i);
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("handles missing directories gracefully", async () => {
|
||||
vi.resetModules();
|
||||
const emptyTemp = mkdtempSync(join(tmpdir(), "snapotter-empty-"));
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
@@ -58,7 +66,12 @@ function createTestTar(bundleId: string): { tarPath: string; sha256: string } {
|
||||
return { tarPath, sha256: hash };
|
||||
}
|
||||
|
||||
function writeManifest(bundleId: string, tarPath: string, sha256: string) {
|
||||
function writeManifest(
|
||||
bundleId: string,
|
||||
tarPath: string,
|
||||
sha256: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
const size = readFileSync(tarPath).length;
|
||||
const manifest = {
|
||||
manifestVersion: 2,
|
||||
@@ -75,12 +88,20 @@ function writeManifest(bundleId: string, tarPath: string, sha256: string) {
|
||||
},
|
||||
models: [{ id: "testmodel", path: "testmodel/weights.bin", minSize: 0 }],
|
||||
enablesTools: [],
|
||||
...extra,
|
||||
},
|
||||
},
|
||||
};
|
||||
writeFileSync(manifestPath, JSON.stringify(manifest));
|
||||
}
|
||||
|
||||
/** Put a real python3 at venv/bin/python3 so the post-install smoke check runs. */
|
||||
function linkVenvPython() {
|
||||
const py = execFileSync("python3", ["-c", "import sys; print(sys.executable)"]).toString().trim();
|
||||
mkdirSync(join(venvDir, "bin"), { recursive: true });
|
||||
symlinkSync(py, join(venvDir, "bin", "python3"));
|
||||
}
|
||||
|
||||
describe("install_feature.py prebuilt mode", () => {
|
||||
it("extracts models and site-packages from a local tar", () => {
|
||||
const { tarPath, sha256 } = createTestTar("face-detection");
|
||||
@@ -150,4 +171,73 @@ describe("install_feature.py prebuilt mode", () => {
|
||||
const last = JSON.parse(progressLines[progressLines.length - 1]);
|
||||
expect(last.progress).toBe(100);
|
||||
});
|
||||
|
||||
it("passes the post-install smoke import check and clears the venv-writing breadcrumb", () => {
|
||||
linkVenvPython();
|
||||
const { tarPath, sha256 } = createTestTar("face-detection");
|
||||
writeManifest("face-detection", tarPath, sha256, { smokeImports: ["json", "sys"] });
|
||||
|
||||
const result = spawnSync("python3", [scriptPath, "face-detection", manifestPath, modelsDir], {
|
||||
env: {
|
||||
...process.env,
|
||||
DATA_DIR: tempDir,
|
||||
PYTHON_VENV_PATH: venvDir,
|
||||
SNAPOTTER_BUNDLE_LOCAL_PATH: tarPath,
|
||||
},
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
expect(result.status, `stderr: ${result.stderr?.toString()}`).toBe(0);
|
||||
const installed = JSON.parse(readFileSync(join(aiDir, "installed.json"), "utf-8"));
|
||||
expect(installed.bundles["face-detection"]).toBeDefined();
|
||||
// The breadcrumb is cleared once the venv write completes cleanly.
|
||||
expect(existsSync(join(aiDir, "venv.writing"))).toBe(false);
|
||||
});
|
||||
|
||||
it("fails the install (and does NOT record it) when the smoke import cannot load", () => {
|
||||
linkVenvPython();
|
||||
const { tarPath, sha256 } = createTestTar("face-detection");
|
||||
writeManifest("face-detection", tarPath, sha256, {
|
||||
smokeImports: ["snapotter_not_a_real_module_zzz"],
|
||||
});
|
||||
|
||||
const result = spawnSync("python3", [scriptPath, "face-detection", manifestPath, modelsDir], {
|
||||
env: {
|
||||
...process.env,
|
||||
DATA_DIR: tempDir,
|
||||
PYTHON_VENV_PATH: venvDir,
|
||||
SNAPOTTER_BUNDLE_LOCAL_PATH: tarPath,
|
||||
},
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr?.toString()).toContain("verification failed");
|
||||
// Not marked installed, so the tool shows as needing install and a retry is clean.
|
||||
const installed = JSON.parse(readFileSync(join(aiDir, "installed.json"), "utf-8"));
|
||||
expect(installed.bundles["face-detection"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("honors SNAPOTTER_SKIP_INSTALL_SMOKE=1 as a safety valve for false positives", () => {
|
||||
linkVenvPython();
|
||||
const { tarPath, sha256 } = createTestTar("face-detection");
|
||||
writeManifest("face-detection", tarPath, sha256, {
|
||||
smokeImports: ["snapotter_not_a_real_module_zzz"],
|
||||
});
|
||||
|
||||
const result = spawnSync("python3", [scriptPath, "face-detection", manifestPath, modelsDir], {
|
||||
env: {
|
||||
...process.env,
|
||||
DATA_DIR: tempDir,
|
||||
PYTHON_VENV_PATH: venvDir,
|
||||
SNAPOTTER_BUNDLE_LOCAL_PATH: tarPath,
|
||||
SNAPOTTER_SKIP_INSTALL_SMOKE: "1",
|
||||
},
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
expect(result.status, `stderr: ${result.stderr?.toString()}`).toBe(0);
|
||||
const installed = JSON.parse(readFileSync(join(aiDir, "installed.json"), "utf-8"));
|
||||
expect(installed.bundles["face-detection"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { FeatureBundleState } from "@snapotter/shared";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { FeatureInstallPrompt } from "@/components/features/feature-install-prompt";
|
||||
import { useFeaturesStore } from "@/stores/features-store";
|
||||
|
||||
function makeBundleState(overrides: Partial<FeatureBundleState> = {}): FeatureBundleState {
|
||||
return {
|
||||
id: "background-removal",
|
||||
name: "Background Removal",
|
||||
description: "Remove backgrounds",
|
||||
status: "not_installed",
|
||||
installedVersion: null,
|
||||
estimatedSize: "4-5 GB",
|
||||
enablesTools: ["remove-background", "passport-photo"],
|
||||
progress: null,
|
||||
error: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("FeatureInstallPrompt", () => {
|
||||
beforeEach(() => {
|
||||
useFeaturesStore.setState({
|
||||
bundles: [],
|
||||
loaded: true,
|
||||
loadError: false,
|
||||
installing: {},
|
||||
errors: {},
|
||||
queued: [],
|
||||
installAllActive: false,
|
||||
startTimes: {},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("uses the tool-aware install action when a tool id is provided", () => {
|
||||
const installTool = vi.fn();
|
||||
const installBundle = vi.fn();
|
||||
useFeaturesStore.setState({ installTool, installBundle });
|
||||
|
||||
render(
|
||||
<FeatureInstallPrompt
|
||||
bundle={makeBundleState()}
|
||||
isAdmin
|
||||
toolId="passport-photo"
|
||||
toolName="Passport Photo"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enable Passport Photo" }));
|
||||
|
||||
expect(installTool).toHaveBeenCalledWith("passport-photo");
|
||||
expect(installBundle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows every required bundle and keeps installed dependencies clear", () => {
|
||||
const installTool = vi.fn();
|
||||
const installBundle = vi.fn();
|
||||
const backgroundRemoval = makeBundleState({ status: "installed" });
|
||||
const faceDetection = makeBundleState({
|
||||
id: "face-detection",
|
||||
name: "Face Detection",
|
||||
description: "Detect faces",
|
||||
status: "not_installed",
|
||||
estimatedSize: "200-300 MB",
|
||||
enablesTools: ["blur-faces", "red-eye-removal", "smart-crop"],
|
||||
});
|
||||
useFeaturesStore.setState({
|
||||
bundles: [backgroundRemoval, faceDetection],
|
||||
installTool,
|
||||
installBundle,
|
||||
});
|
||||
|
||||
render(
|
||||
<FeatureInstallPrompt
|
||||
bundle={faceDetection}
|
||||
isAdmin
|
||||
toolId="passport-photo"
|
||||
toolName="Passport Photo"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Background Removal")).toBeTruthy();
|
||||
expect(screen.getByText("Face Detection")).toBeTruthy();
|
||||
expect(screen.getByText("Installed")).toBeTruthy();
|
||||
expect(screen.getByText("Not installed")).toBeTruthy();
|
||||
expect(screen.getByText("200-300 MB")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enable Passport Photo" }));
|
||||
|
||||
expect(installTool).toHaveBeenCalledWith("passport-photo");
|
||||
expect(installBundle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the multi-bundle breakdown visible when one bundle is in the error/repair state", () => {
|
||||
const backgroundRemoval = makeBundleState({
|
||||
status: "error",
|
||||
error: "Checksum mismatch",
|
||||
});
|
||||
const faceDetection = makeBundleState({
|
||||
id: "face-detection",
|
||||
name: "Face Detection",
|
||||
description: "Detect faces",
|
||||
status: "installed",
|
||||
estimatedSize: "200-300 MB",
|
||||
enablesTools: ["blur-faces", "red-eye-removal", "smart-crop"],
|
||||
});
|
||||
useFeaturesStore.setState({
|
||||
bundles: [backgroundRemoval, faceDetection],
|
||||
installTool: vi.fn(),
|
||||
installBundle: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<FeatureInstallPrompt
|
||||
bundle={backgroundRemoval}
|
||||
isAdmin
|
||||
toolId="passport-photo"
|
||||
toolName="Passport Photo"
|
||||
/>,
|
||||
);
|
||||
|
||||
// The breakdown must still render during repair so the user can see the
|
||||
// sibling bundle's state, not just the single failed one.
|
||||
expect(screen.getByText("Background Removal")).toBeTruthy();
|
||||
expect(screen.getByText("Face Detection")).toBeTruthy();
|
||||
expect(screen.getByText("Installed")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -341,6 +341,58 @@ describe("useFeaturesStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("installTool()", () => {
|
||||
it("starts every missing hard dependency for a multi-bundle AI tool from one server request", async () => {
|
||||
const bundles = [
|
||||
makeBundleState({ id: "background-removal", status: "not_installed" }),
|
||||
makeBundleState({ id: "face-detection", status: "not_installed" }),
|
||||
];
|
||||
useFeaturesStore.setState({ bundles, loaded: true });
|
||||
apiPostMock.mockResolvedValueOnce({
|
||||
bundles: [
|
||||
{ bundleId: "background-removal", jobId: "job-bg", queued: false },
|
||||
{ bundleId: "face-detection", jobId: "job-face", queued: true },
|
||||
],
|
||||
});
|
||||
|
||||
await useFeaturesStore.getState().installTool("passport-photo");
|
||||
|
||||
expect(apiPostMock).toHaveBeenCalledWith(
|
||||
"/v1/admin/tools/passport-photo/features/install",
|
||||
{},
|
||||
);
|
||||
expect(FakeEventSource.instances).toHaveLength(1);
|
||||
expect(FakeEventSource.instances[0].url).toBe("/api/v1/jobs/job-bg/progress");
|
||||
expect(useFeaturesStore.getState().installing["background-removal"]).toBeDefined();
|
||||
expect(useFeaturesStore.getState().installing["face-detection"]).toBeUndefined();
|
||||
expect(useFeaturesStore.getState().queued).toContain("face-detection");
|
||||
});
|
||||
|
||||
it("only tracks missing dependencies when a multi-bundle AI tool is partially installed", async () => {
|
||||
const bundles = [
|
||||
makeBundleState({ id: "background-removal", status: "installed" }),
|
||||
makeBundleState({ id: "face-detection", status: "not_installed" }),
|
||||
];
|
||||
useFeaturesStore.setState({ bundles, loaded: true });
|
||||
apiPostMock.mockResolvedValueOnce({
|
||||
bundles: [
|
||||
{ bundleId: "background-removal", skipped: true },
|
||||
{ bundleId: "face-detection", jobId: "job-face", queued: false },
|
||||
],
|
||||
});
|
||||
|
||||
await useFeaturesStore.getState().installTool("passport-photo");
|
||||
|
||||
expect(apiPostMock).toHaveBeenCalledWith(
|
||||
"/v1/admin/tools/passport-photo/features/install",
|
||||
{},
|
||||
);
|
||||
expect(useFeaturesStore.getState().installing["background-removal"]).toBeUndefined();
|
||||
expect(useFeaturesStore.getState().installing["face-detection"]).toBeDefined();
|
||||
expect(FakeEventSource.instances[0].url).toBe("/api/v1/jobs/job-face/progress");
|
||||
});
|
||||
});
|
||||
|
||||
describe("uninstallBundle()", () => {
|
||||
it("calls API and refreshes", async () => {
|
||||
apiPostMock.mockResolvedValueOnce({});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { FeatureBundleState } from "@snapotter/shared";
|
||||
import { TOOLS } from "@snapotter/shared";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ToolCard } from "@/components/common/tool-card";
|
||||
import { useFeaturesStore } from "@/stores/features-store";
|
||||
import { usePinnedToolsStore } from "@/stores/pinned-tools-store";
|
||||
|
||||
// Make the store's optimistic persistence a no-op so the test stays server-free.
|
||||
@@ -14,6 +16,23 @@ vi.mock("@/lib/api", () => ({
|
||||
|
||||
const resize = TOOLS.find((tool) => tool.id === "resize");
|
||||
if (!resize) throw new Error("resize tool missing from TOOLS");
|
||||
const passportPhoto = TOOLS.find((tool) => tool.id === "passport-photo");
|
||||
if (!passportPhoto) throw new Error("passport-photo tool missing from TOOLS");
|
||||
|
||||
function makeBundleState(overrides: Partial<FeatureBundleState> = {}): FeatureBundleState {
|
||||
return {
|
||||
id: "background-removal",
|
||||
name: "Background Removal",
|
||||
description: "Remove backgrounds",
|
||||
status: "not_installed",
|
||||
installedVersion: null,
|
||||
estimatedSize: "4-5 GB",
|
||||
enablesTools: ["remove-background", "passport-photo"],
|
||||
progress: null,
|
||||
error: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -24,6 +43,16 @@ beforeEach(() => {
|
||||
loaded: true,
|
||||
loadError: false,
|
||||
});
|
||||
useFeaturesStore.setState({
|
||||
bundles: [],
|
||||
loaded: true,
|
||||
loadError: false,
|
||||
installing: {},
|
||||
errors: {},
|
||||
queued: [],
|
||||
installAllActive: false,
|
||||
startTimes: {},
|
||||
});
|
||||
});
|
||||
|
||||
function renderCard(showPin: boolean) {
|
||||
@@ -56,3 +85,28 @@ describe("ToolCard pin button", () => {
|
||||
expect(usePinnedToolsStore.getState().pinnedTools).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolCard AI bundle status", () => {
|
||||
it("treats multi-bundle tools as not installed when an extra bundle is missing", () => {
|
||||
useFeaturesStore.setState({
|
||||
bundles: [
|
||||
makeBundleState({ id: "background-removal", status: "installed" }),
|
||||
makeBundleState({
|
||||
id: "face-detection",
|
||||
name: "Face Detection",
|
||||
status: "not_installed",
|
||||
enablesTools: ["blur-faces", "red-eye-removal", "smart-crop"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<ToolCard tool={passportPhoto} variant="descriptive" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// One SVG is the tool icon, the second is the missing-AI-bundle indicator.
|
||||
expect(container.querySelectorAll("svg")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user