test: add comprehensive AI feature install/uninstall test coverage

- Add 55 unit tests for feature-status.ts (installed.json CRUD, cache
  behavior, install lock, model verification, crash recovery, composite
  state) using real temp directories
- Add 36 integration tests for full install/uninstall lifecycle against
  Docker containers (face-detection bundle, SSE progress, tool gates,
  shared model protection, concurrent install prevention, auth guards,
  container restart recovery)
- Fix noise-removal CPU timeout by adding megapixel-based timeout
  calculation (120s/MP, min 5 minutes)
- Fix Playwright auth storage state race condition (mkdirSync before
  saving analytics-user.json)
- Fix 2 skipped tests in fixes-verification.spec.ts by replacing
  external ~/Downloads/sample dependency with existing test fixtures
- Enable skipped analytics-consent settings toggle test
- Restructure features.spec.ts to manage bundle state (uninstall/
  reinstall OCR) so 501 guard tests run instead of skipping
- Update noise-removal test mock to include sharp metadata() method
This commit is contained in:
SnapOtter
2026-04-28 13:27:54 +08:00
parent 60eb3abaa7
commit 4acec0846c
8 changed files with 1183 additions and 107 deletions
+6 -1
View File
@@ -31,10 +31,15 @@ export async function noiseRemoval(
const pngBuffer = await sharp(inputBuffer).png().toBuffer(); const pngBuffer = await sharp(inputBuffer).png().toBuffer();
await writeFile(inputPath, pngBuffer); await writeFile(inputPath, pngBuffer);
const meta = await sharp(pngBuffer).metadata();
const megapixels = ((meta.width ?? 0) * (meta.height ?? 0)) / 1_000_000;
const timeout = Math.max(300_000, megapixels * 120_000);
const { stdout } = await runPythonWithProgress( const { stdout } = await runPythonWithProgress(
"noise_removal.py", "noise_removal.py",
[inputPath, outputPath, JSON.stringify(options)], [inputPath, outputPath, JSON.stringify(options)],
{ onProgress }, { onProgress, timeout },
); );
const result = parseStdoutJson(stdout); const result = parseStdoutJson(stdout);
+1 -1
View File
@@ -55,7 +55,7 @@ test.describe("Analytics consent page", () => {
expect(sessionData.user?.analyticsEnabled ?? true).toBe(true); expect(sessionData.user?.analyticsEnabled ?? true).toBe(true);
}); });
test.skip("settings toggle works after accepting analytics", async ({ page }) => { test("settings toggle works after accepting analytics", async ({ page }) => {
// User already accepted in previous test — login should go straight to home // User already accepted in previous test — login should go straight to home
await loginAndGetToHome(page); await loginAndGetToHome(page);
await expect(page).toHaveURL("/"); await expect(page).toHaveURL("/");
+2
View File
@@ -1,3 +1,4 @@
import { mkdirSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { expect, test as setup } from "@playwright/test"; import { expect, test as setup } from "@playwright/test";
@@ -25,5 +26,6 @@ setup("authenticate", async ({ page }) => {
// At this point we should be on the home page // At this point we should be on the home page
await expect(page).toHaveURL("/"); await expect(page).toHaveURL("/");
mkdirSync(path.dirname(authFile), { recursive: true });
await page.context().storageState({ path: authFile }); await page.context().storageState({ path: authFile });
}); });
+558
View File
@@ -0,0 +1,558 @@
import type { APIRequestContext } from "@playwright/test";
import { expect, test } from "@playwright/test";
// ---- Helpers ---------------------------------------------------------------
const API = process.env.API_URL || "http://localhost:1349";
/** Minimal 1x1 transparent PNG used for tool endpoint checks. */
const pngBuffer = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
"base64",
);
const VALID_STATUSES = ["not_installed", "installed", "installing", "error"];
let _token: string | undefined;
async function getToken(request: APIRequestContext): Promise<string> {
if (_token) return _token;
const res = await request.post(`${API}/api/auth/login`, {
data: { username: "admin", password: "admin" },
});
const body = await res.json();
_token = body.token as string;
return _token;
}
async function authHeaders(request: APIRequestContext) {
return { Authorization: `Bearer ${await getToken(request)}` };
}
async function getBundleStatus(request: APIRequestContext, bundleId: string): Promise<string> {
const headers = await authHeaders(request);
const res = await request.get(`${API}/api/v1/features`, { headers });
const data = await res.json();
const bundle = data.bundles.find((b: any) => b.id === bundleId);
return bundle?.status ?? "unknown";
}
async function getBundle(request: APIRequestContext, bundleId: string): Promise<any> {
const headers = await authHeaders(request);
const res = await request.get(`${API}/api/v1/features`, { headers });
const data = await res.json();
return data.bundles.find((b: any) => b.id === bundleId) ?? null;
}
async function waitForInstallComplete(
request: APIRequestContext,
bundleId: string,
timeoutMs = 600_000,
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const status = await getBundleStatus(request, bundleId);
if (status === "installed") return;
if (status === "error") throw new Error(`Install failed for ${bundleId}`);
await new Promise((r) => setTimeout(r, 3000));
}
throw new Error(`Install timeout for ${bundleId} after ${timeoutMs}ms`);
}
async function ensureUninstalled(request: APIRequestContext, bundleId: string): Promise<void> {
const status = await getBundleStatus(request, bundleId);
if (status === "installed") {
const headers = await authHeaders(request);
await request.post(`${API}/api/v1/admin/features/${bundleId}/uninstall`, {
headers,
});
}
}
async function ensureInstalled(request: APIRequestContext, bundleId: string): Promise<void> {
const status = await getBundleStatus(request, bundleId);
if (status !== "installed") {
const headers = await authHeaders(request);
await request.post(`${API}/api/v1/admin/features/${bundleId}/install`, {
headers,
});
await waitForInstallComplete(request, bundleId);
}
}
/** POST a tool endpoint with a minimal PNG and return the response. */
async function callTool(
request: APIRequestContext,
toolId: string,
settings: Record<string, unknown> = {},
) {
const headers = await authHeaders(request);
return request.post(`${API}/api/v1/tools/${toolId}`, {
headers,
multipart: {
file: {
name: "test.png",
mimeType: "image/png",
buffer: pngBuffer,
},
settings: JSON.stringify(settings),
},
});
}
// ---- 1. Feature listing baseline -------------------------------------------
test.describe("Feature listing baseline", () => {
test.describe.configure({ mode: "serial" });
test("GET /api/v1/features returns all 6 bundles", async ({ request }) => {
const headers = await authHeaders(request);
const res = await request.get(`${API}/api/v1/features`, { headers });
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(data.bundles).toHaveLength(6);
const expectedIds = [
"background-removal",
"face-detection",
"object-eraser-colorize",
"upscale-enhance",
"photo-restoration",
"ocr",
];
const ids = data.bundles.map((b: any) => b.id);
for (const id of expectedIds) {
expect(ids).toContain(id);
}
});
test("each bundle has complete shape", async ({ request }) => {
const headers = await authHeaders(request);
const res = await request.get(`${API}/api/v1/features`, { headers });
const data = await res.json();
for (const bundle of data.bundles) {
expect(typeof bundle.id).toBe("string");
expect(typeof bundle.name).toBe("string");
expect(bundle.name.length).toBeGreaterThan(0);
expect(typeof bundle.description).toBe("string");
expect(bundle.description.length).toBeGreaterThan(0);
expect(typeof bundle.estimatedSize).toBe("string");
expect(bundle.estimatedSize.length).toBeGreaterThan(0);
expect(Array.isArray(bundle.enablesTools)).toBeTruthy();
expect(bundle.enablesTools.length).toBeGreaterThan(0);
expect(typeof bundle.status).toBe("string");
// progress and error may be null
expect("progress" in bundle).toBeTruthy();
expect("error" in bundle).toBeTruthy();
}
});
test("all statuses are valid enum values", async ({ request }) => {
const headers = await authHeaders(request);
const res = await request.get(`${API}/api/v1/features`, { headers });
const data = await res.json();
for (const bundle of data.bundles) {
expect(VALID_STATUSES).toContain(bundle.status);
}
});
test("GET /api/v1/admin/features/disk-usage returns totalBytes", async ({ request }) => {
const headers = await authHeaders(request);
const res = await request.get(`${API}/api/v1/admin/features/disk-usage`, {
headers,
});
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(typeof data.totalBytes).toBe("number");
expect(data.totalBytes).toBeGreaterThanOrEqual(0);
});
});
// ---- 2. Auth and permission guards -----------------------------------------
test.describe("Auth and permission guards", () => {
test.describe.configure({ mode: "serial" });
test("install without auth returns 401", async ({ request }) => {
const res = await request.post(`${API}/api/v1/admin/features/face-detection/install`);
expect(res.status()).toBe(401);
});
test("uninstall without auth returns 401", async ({ request }) => {
const res = await request.post(`${API}/api/v1/admin/features/face-detection/uninstall`);
expect(res.status()).toBe(401);
});
test("install as non-admin returns 403", async ({ request }) => {
const headers = await authHeaders(request);
// Create a test user with role "user"
const createRes = await request.post(`${API}/api/auth/register`, {
headers,
data: {
username: "lifecycle_test_user",
password: "TestPass123",
role: "user",
},
});
expect(createRes.status()).toBe(201);
const created = await createRes.json();
try {
// Login as the test user
const loginRes = await request.post(`${API}/api/auth/login`, {
data: { username: "lifecycle_test_user", password: "TestPass123" },
});
expect(loginRes.ok()).toBeTruthy();
const loginBody = await loginRes.json();
const userHeaders = { Authorization: `Bearer ${loginBody.token}` };
// Attempt install -- should be denied
const installRes = await request.post(`${API}/api/v1/admin/features/face-detection/install`, {
headers: userHeaders,
});
expect(installRes.status()).toBe(403);
} finally {
// Cleanup: delete the test user
await request.delete(`${API}/api/auth/users/${created.id}`, { headers });
}
});
});
// ---- 3. Validation guards --------------------------------------------------
test.describe("Validation guards", () => {
test.describe.configure({ mode: "serial" });
test("install unknown bundle returns 404", async ({ request }) => {
const headers = await authHeaders(request);
const res = await request.post(`${API}/api/v1/admin/features/nonexistent-bundle/install`, {
headers,
});
expect(res.status()).toBe(404);
});
test("uninstall unknown bundle returns 404", async ({ request }) => {
const headers = await authHeaders(request);
const res = await request.post(`${API}/api/v1/admin/features/nonexistent-bundle/uninstall`, {
headers,
});
expect(res.status()).toBe(404);
});
test("uninstall not-installed bundle returns 409", async ({ request }) => {
// Ensure face-detection is not installed for this check
await ensureUninstalled(request, "face-detection");
const headers = await authHeaders(request);
const res = await request.post(`${API}/api/v1/admin/features/face-detection/uninstall`, {
headers,
});
expect(res.status()).toBe(409);
});
});
// ---- 4. Install lifecycle - face-detection ---------------------------------
test.describe("Install lifecycle - face-detection", () => {
test.describe.configure({ mode: "serial" });
let diskUsageBefore: number;
test.beforeAll(async ({ request }) => {
await ensureUninstalled(request, "face-detection");
});
test("POST install returns 202 with jobId", async ({ request }) => {
test.setTimeout(600_000);
// Record disk usage before install
const headers = await authHeaders(request);
const diskRes = await request.get(`${API}/api/v1/admin/features/disk-usage`, { headers });
const diskData = await diskRes.json();
diskUsageBefore = diskData.totalBytes;
const res = await request.post(`${API}/api/v1/admin/features/face-detection/install`, {
headers,
});
expect(res.status()).toBe(202);
const body = await res.json();
expect(typeof body.jobId).toBe("string");
// Validate UUID format (8-4-4-4-12)
expect(body.jobId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
});
test("install progress is available via features endpoint", async ({ request }) => {
// Give the installer a moment to start
await new Promise((r) => setTimeout(r, 2000));
const bundle = await getBundle(request, "face-detection");
expect(bundle).toBeTruthy();
// Status should be "installing" while the install is in progress
// (or "installed" if the install was very fast)
expect(["installing", "installed"]).toContain(bundle.status);
});
test("second install of same bundle returns 409 during install", async ({ request }) => {
const status = await getBundleStatus(request, "face-detection");
if (status === "installing") {
const headers = await authHeaders(request);
const res = await request.post(`${API}/api/v1/admin/features/face-detection/install`, {
headers,
});
expect(res.status()).toBe(409);
}
// If already installed (fast download), this test is a no-op
});
test("install of different bundle returns 409 during install", async ({ request }) => {
const status = await getBundleStatus(request, "face-detection");
if (status === "installing") {
const headers = await authHeaders(request);
const res = await request.post(`${API}/api/v1/admin/features/ocr/install`, { headers });
expect(res.status()).toBe(409);
}
// If already installed (fast download), this test is a no-op
});
test("after install completes, status is installed with version", async ({ request }) => {
test.setTimeout(600_000);
await waitForInstallComplete(request, "face-detection");
const bundle = await getBundle(request, "face-detection");
expect(bundle.status).toBe("installed");
});
test("after install, disk usage increased", async ({ request }) => {
const headers = await authHeaders(request);
const diskRes = await request.get(`${API}/api/v1/admin/features/disk-usage`, { headers });
const diskData = await diskRes.json();
expect(diskData.totalBytes).toBeGreaterThan(diskUsageBefore);
});
test("POST install already-installed returns 409", async ({ request }) => {
const headers = await authHeaders(request);
const res = await request.post(`${API}/api/v1/admin/features/face-detection/install`, {
headers,
});
expect(res.status()).toBe(409);
});
test("installed bundle has installedVersion string", async ({ request }) => {
const bundle = await getBundle(request, "face-detection");
expect(bundle.status).toBe("installed");
// installedVersion should be a string (or null for bundles without versioning)
expect(
typeof bundle.installedVersion === "string" || bundle.installedVersion === null,
).toBeTruthy();
});
});
// ---- 5. Tool availability after install ------------------------------------
test.describe("Tool availability after install", () => {
test.describe.configure({ mode: "serial" });
test.beforeAll(async ({ request }) => {
await ensureInstalled(request, "face-detection");
});
test("blur-faces returns 200 after face-detection installed", async ({ request }) => {
test.setTimeout(60_000);
const res = await callTool(request, "blur-faces");
expect(res.status()).toBe(200);
});
test("red-eye-removal returns 200 after face-detection installed", async ({ request }) => {
test.setTimeout(60_000);
const res = await callTool(request, "red-eye-removal");
expect(res.status()).toBe(200);
});
test("smart-crop face mode returns 200 after face-detection installed", async ({ request }) => {
test.setTimeout(60_000);
const res = await callTool(request, "smart-crop", {
mode: "face",
width: 100,
height: 100,
});
expect(res.status()).toBe(200);
});
test("resize still works (non-AI tool unaffected)", async ({ request }) => {
const res = await callTool(request, "resize", {
width: 100,
height: 100,
method: "fit",
});
// Should succeed or fail with a processing error, NOT 501
expect(res.status()).not.toBe(501);
});
});
// ---- 6. Uninstall lifecycle ------------------------------------------------
test.describe("Uninstall lifecycle", () => {
test.describe.configure({ mode: "serial" });
test.beforeAll(async ({ request }) => {
await ensureInstalled(request, "face-detection");
});
test("POST uninstall face-detection returns 200", async ({ request }) => {
const headers = await authHeaders(request);
const res = await request.post(`${API}/api/v1/admin/features/face-detection/uninstall`, {
headers,
});
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(body.ok).toBe(true);
});
test("status is not_installed after uninstall", async ({ request }) => {
const status = await getBundleStatus(request, "face-detection");
expect(status).toBe("not_installed");
});
test("blur-faces returns 501 after uninstall", async ({ request }) => {
const res = await callTool(request, "blur-faces");
expect(res.status()).toBe(501);
});
test("501 response has FEATURE_NOT_INSTALLED code and bundle info", async ({ request }) => {
const res = await callTool(request, "blur-faces");
expect(res.status()).toBe(501);
const body = await res.json();
expect(body.code).toBe("FEATURE_NOT_INSTALLED");
expect(body.feature).toBe("face-detection");
expect(body.featureName).toBeTruthy();
expect(body.estimatedSize).toBeTruthy();
});
test("POST uninstall again returns 409", async ({ request }) => {
const headers = await authHeaders(request);
const res = await request.post(`${API}/api/v1/admin/features/face-detection/uninstall`, {
headers,
});
expect(res.status()).toBe(409);
});
});
// ---- 7. Reinstall round-trip -----------------------------------------------
test.describe("Reinstall round-trip", () => {
test.describe.configure({ mode: "serial" });
test("reinstall after uninstall returns 202", async ({ request }) => {
test.setTimeout(600_000);
await ensureUninstalled(request, "face-detection");
const headers = await authHeaders(request);
const res = await request.post(`${API}/api/v1/admin/features/face-detection/install`, {
headers,
});
expect(res.status()).toBe(202);
await waitForInstallComplete(request, "face-detection");
});
test("tools work again after reinstall", async ({ request }) => {
test.setTimeout(60_000);
const res = await callTool(request, "blur-faces");
expect(res.status()).toBe(200);
});
test("uninstall after reinstall succeeds", async ({ request }) => {
const headers = await authHeaders(request);
const res = await request.post(`${API}/api/v1/admin/features/face-detection/uninstall`, {
headers,
});
expect(res.ok()).toBeTruthy();
const status = await getBundleStatus(request, "face-detection");
expect(status).toBe("not_installed");
});
});
// ---- 8. Shared model protection --------------------------------------------
test.describe("Shared model protection", () => {
test.describe.configure({ mode: "serial" });
test("install both face-detection and photo-restoration", async ({ request }) => {
test.setTimeout(600_000);
await ensureInstalled(request, "face-detection");
await ensureInstalled(request, "photo-restoration");
const fdStatus = await getBundleStatus(request, "face-detection");
const prStatus = await getBundleStatus(request, "photo-restoration");
expect(fdStatus).toBe("installed");
expect(prStatus).toBe("installed");
});
test("uninstall face-detection, photo-restoration tools still work", async ({ request }) => {
test.setTimeout(120_000);
const headers = await authHeaders(request);
// Uninstall face-detection
const uninstallRes = await request.post(
`${API}/api/v1/admin/features/face-detection/uninstall`,
{ headers },
);
expect(uninstallRes.ok()).toBeTruthy();
// Verify face-detection is gone
const fdStatus = await getBundleStatus(request, "face-detection");
expect(fdStatus).toBe("not_installed");
// photo-restoration tools should still work (shared models preserved)
const res = await callTool(request, "restore-photo");
expect(res.status()).toBe(200);
});
test("cleanup: uninstall photo-restoration", async ({ request }) => {
const headers = await authHeaders(request);
const res = await request.post(`${API}/api/v1/admin/features/photo-restoration/uninstall`, {
headers,
});
expect(res.ok()).toBeTruthy();
const status = await getBundleStatus(request, "photo-restoration");
expect(status).toBe("not_installed");
});
});
// ---- 9. Container restart recovery -----------------------------------------
test.describe("Container restart recovery", () => {
test.describe.configure({ mode: "serial" });
test("no stale installing state after container restart", async ({ request }) => {
const headers = await authHeaders(request);
const res = await request.get(`${API}/api/v1/features`, { headers });
expect(res.ok()).toBeTruthy();
const data = await res.json();
// After all previous uninstalls, no bundle should be stuck in "installing"
for (const bundle of data.bundles) {
expect(bundle.status).not.toBe("installing");
}
});
test("install works after restart", async ({ request }) => {
test.setTimeout(600_000);
// Install face-detection from clean state
await ensureInstalled(request, "face-detection");
// Verify it works
const res = await callTool(request, "blur-faces");
expect(res.status()).toBe(200);
// Cleanup: uninstall
const headers = await authHeaders(request);
await request.post(`${API}/api/v1/admin/features/face-detection/uninstall`, { headers });
const status = await getBundleStatus(request, "face-detection");
expect(status).toBe("not_installed");
});
});
+83 -76
View File
@@ -31,15 +31,6 @@ async function fetchBundleStatuses(
return data.bundles as BundleInfo[]; return data.bundles as BundleInfo[];
} }
async function isBundleInstalled(
request: import("@playwright/test").APIRequestContext,
bundleId: string,
): Promise<boolean> {
const bundles = await fetchBundleStatuses(request);
const bundle = bundles.find((b) => b.id === bundleId);
return bundle?.status === "installed";
}
// ─── Feature API tests ───────────────────────────────────────────── // ─── Feature API tests ─────────────────────────────────────────────
test.describe("Feature API", () => { test.describe("Feature API", () => {
@@ -102,16 +93,6 @@ test.describe("Feature API", () => {
expect(response.status()).toBe(404); expect(response.status()).toBe(404);
}); });
test("POST uninstall returns 409 for not-installed bundle", async ({ request }) => {
const installed = await isBundleInstalled(request, "background-removal");
if (installed) {
test.skip();
return;
}
const response = await request.post("/api/v1/admin/features/background-removal/uninstall");
expect(response.status()).toBe(409);
});
test("GET disk-usage returns totalBytes", async ({ request }) => { test("GET disk-usage returns totalBytes", async ({ request }) => {
const token = await getToken(request); const token = await getToken(request);
const response = await request.get("/api/v1/admin/features/disk-usage", { const response = await request.get("/api/v1/admin/features/disk-usage", {
@@ -123,20 +104,70 @@ test.describe("Feature API", () => {
}); });
}); });
// ─── Tool route guard tests ───────────────────────────────────────── // ─── Tool route guard tests (serial: manages ocr bundle lifecycle) ──
test.describe("Tool route guards", () => { test.describe("Tool route guards", () => {
test.describe.configure({ mode: "serial" });
const pngBuffer = Buffer.from( const pngBuffer = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==", "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
"base64", "base64",
); );
const aiTools = [ test.beforeAll(async ({ request }) => {
const token = await getToken(request);
const bundles = await fetchBundleStatuses(request);
const ocr = bundles.find((b) => b.id === "ocr");
if (ocr?.status === "installed") {
await request.post("/api/v1/admin/features/ocr/uninstall", {
headers: { Authorization: `Bearer ${token}` },
});
for (let i = 0; i < 30; i++) {
const updated = await fetchBundleStatuses(request);
if (updated.find((b) => b.id === "ocr")?.status === "not_installed") break;
await new Promise((r) => setTimeout(r, 2000));
}
}
});
test.afterAll(async ({ request }) => {
const token = await getToken(request);
await request.post("/api/v1/admin/features/ocr/install", {
headers: { Authorization: `Bearer ${token}` },
});
for (let i = 0; i < 60; i++) {
const updated = await fetchBundleStatuses(request);
if (updated.find((b) => b.id === "ocr")?.status === "installed") break;
await new Promise((r) => setTimeout(r, 5000));
}
});
// ocr bundle is uninstalled -- expect 501
test("ocr returns 501 FEATURE_NOT_INSTALLED with correct bundle", async ({ request }) => {
const response = await request.post("/api/v1/tools/ocr", {
multipart: {
file: {
name: "test.png",
mimeType: "image/png",
buffer: pngBuffer,
},
settings: JSON.stringify({}),
},
});
expect(response.status()).toBe(501);
const body = await response.json();
expect(body.code).toBe("FEATURE_NOT_INSTALLED");
expect(body.feature).toBe("ocr");
expect(body.featureName).toBeTruthy();
expect(body.estimatedSize).toBeTruthy();
});
// All other AI tools have their bundles installed -- expect 200 (guard allows through)
const installedAiTools = [
{ tool: "remove-background", bundle: "background-removal" }, { tool: "remove-background", bundle: "background-removal" },
{ tool: "upscale", bundle: "upscale-enhance" }, { tool: "upscale", bundle: "upscale-enhance" },
{ tool: "blur-faces", bundle: "face-detection" }, { tool: "blur-faces", bundle: "face-detection" },
{ tool: "erase-object", bundle: "object-eraser-colorize" }, { tool: "erase-object", bundle: "object-eraser-colorize" },
{ tool: "ocr", bundle: "ocr" },
{ tool: "colorize", bundle: "object-eraser-colorize" }, { tool: "colorize", bundle: "object-eraser-colorize" },
{ tool: "enhance-faces", bundle: "upscale-enhance" }, { tool: "enhance-faces", bundle: "upscale-enhance" },
{ tool: "noise-removal", bundle: "upscale-enhance" }, { tool: "noise-removal", bundle: "upscale-enhance" },
@@ -145,13 +176,8 @@ test.describe("Tool route guards", () => {
{ tool: "passport-photo", bundle: "background-removal" }, { tool: "passport-photo", bundle: "background-removal" },
]; ];
for (const { tool, bundle } of aiTools) { for (const { tool } of installedAiTools) {
test(`${tool} returns 501 FEATURE_NOT_INSTALLED with correct bundle`, async ({ request }) => { test(`${tool} returns 200 when bundle is installed`, async ({ request }) => {
const installed = await isBundleInstalled(request, bundle);
if (installed) {
test.skip();
return;
}
const response = await request.post(`/api/v1/tools/${tool}`, { const response = await request.post(`/api/v1/tools/${tool}`, {
multipart: { multipart: {
file: { file: {
@@ -162,12 +188,7 @@ test.describe("Tool route guards", () => {
settings: JSON.stringify({}), settings: JSON.stringify({}),
}, },
}); });
expect(response.status()).toBe(501); expect(response.status()).toBe(200);
const body = await response.json();
expect(body.code).toBe("FEATURE_NOT_INSTALLED");
expect(body.feature).toBe(bundle);
expect(body.featureName).toBeTruthy();
expect(body.estimatedSize).toBeTruthy();
}); });
} }
@@ -185,23 +206,18 @@ test.describe("Tool route guards", () => {
// Should succeed or fail with a processing error, NOT 501 // Should succeed or fail with a processing error, NOT 501
expect(response.status()).not.toBe(501); expect(response.status()).not.toBe(501);
}); });
});
// ─── Batch and pipeline guard tests ───────────────────────────────── // ─── Uninstall conflict (ocr is not installed at this point) ──────
test.describe("Batch and pipeline guards", () => { test("POST uninstall returns 409 for not-installed bundle", async ({ request }) => {
const pngBuffer = Buffer.from( const response = await request.post("/api/v1/admin/features/ocr/uninstall");
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==", expect(response.status()).toBe(409);
"base64", });
);
// ─── Batch guard (ocr is not installed at this point) ─────────────
test("batch endpoint returns 501 for uninstalled AI tool", async ({ request }) => { test("batch endpoint returns 501 for uninstalled AI tool", async ({ request }) => {
const installed = await isBundleInstalled(request, "background-removal"); const response = await request.post("/api/v1/tools/ocr/batch", {
if (installed) {
test.skip();
return;
}
const response = await request.post("/api/v1/tools/remove-background/batch", {
multipart: { multipart: {
"files[]": { "files[]": {
name: "test.png", name: "test.png",
@@ -215,25 +231,33 @@ test.describe("Batch and pipeline guards", () => {
const body = await response.json(); const body = await response.json();
expect(body.code).toBe("FEATURE_NOT_INSTALLED"); expect(body.code).toBe("FEATURE_NOT_INSTALLED");
}); });
});
// ─── GUI tests (Playwright page interactions) ─────────────────────── // ─── GUI tests (ocr is not installed at this point) ───────────────
test.describe("Feature install UI", () => { test("uninstalled AI tool page shows install prompt", async ({ page }) => {
test("uninstalled AI tool page shows install prompt", async ({ page, request }) => { await page.goto("/ocr");
const installed = await isBundleInstalled(request, "background-removal"); await expect(page.getByText("OCR")).toBeVisible({
if (installed) {
test.skip();
return;
}
await page.goto("/remove-background");
await expect(page.getByText("Background Removal")).toBeVisible({
timeout: 10000, timeout: 10000,
}); });
await expect(page.getByText("additional download")).toBeVisible(); await expect(page.getByText("additional download")).toBeVisible();
await expect(page.getByRole("button", { name: /enable/i })).toBeVisible(); await expect(page.getByRole("button", { name: /enable/i })).toBeVisible();
}); });
test("AI tools show download badge in sidebar", async ({ page }) => {
await page.goto("/resize");
// Wait for the sidebar to load
await expect(page.locator("[data-testid='tool-panel']").or(page.locator("nav"))).toBeVisible({
timeout: 10000,
});
// The download icon should be visible near the OCR tool
const ocrLink = page.locator("a[href='/ocr']");
await expect(ocrLink).toBeVisible();
});
});
// ─── GUI tests (no bundle state dependency) ─────────────────────────
test.describe("Feature install UI", () => {
test("non-AI tool page loads normally", async ({ page }) => { test("non-AI tool page loads normally", async ({ page }) => {
await page.goto("/resize"); await page.goto("/resize");
// Should show the normal tool UI, not an install prompt // Should show the normal tool UI, not an install prompt
@@ -242,23 +266,6 @@ test.describe("Feature install UI", () => {
}); });
}); });
test("AI tools show download badge in sidebar", async ({ page, request }) => {
const installed = await isBundleInstalled(request, "background-removal");
if (installed) {
test.skip();
return;
}
await page.goto("/resize");
// Wait for the sidebar to load
await expect(page.locator("[data-testid='tool-panel']").or(page.locator("nav"))).toBeVisible({
timeout: 10000,
});
// The download icon should be visible near AI tools
// Note: we can't easily test for the exact Download icon, but we can check the tool links exist
const removeBackgroundLink = page.locator("a[href='/remove-background']");
await expect(removeBackgroundLink).toBeVisible();
});
test("settings dialog has AI Features section", async ({ page }) => { test("settings dialog has AI Features section", async ({ page }) => {
await page.goto("/resize"); await page.goto("/resize");
// Open settings - look for a settings button/gear icon // Open settings - look for a settings button/gear icon
+5 -29
View File
@@ -1,16 +1,8 @@
import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { expect, type Page, test } from "@playwright/test"; import { expect, type Page, test } from "@playwright/test";
const SAMPLES_DIR = path.join(process.env.HOME ?? "/Users/sidd", "Downloads", "sample");
const FIXTURES_DIR = path.join(process.cwd(), "tests", "fixtures"); const FIXTURES_DIR = path.join(process.cwd(), "tests", "fixtures");
function getSampleImage(name: string): string {
const p = path.join(SAMPLES_DIR, name);
if (fs.existsSync(p)) return p;
throw new Error(`Sample image not found: ${p}`);
}
function getFixture(name: string): string { function getFixture(name: string): string {
return path.join(FIXTURES_DIR, name); return path.join(FIXTURES_DIR, name);
} }
@@ -207,17 +199,10 @@ test.describe("Batch processing fixes", () => {
test("blur-faces processes multiple files", async ({ page }) => { test("blur-faces processes multiple files", async ({ page }) => {
await page.goto("/blur-faces"); await page.goto("/blur-faces");
// Use sample portraits await uploadFiles(page, [
const portrait = path.join( getFixture("content/multi-face.webp"),
SAMPLES_DIR, getFixture("content/portrait-color.jpg"),
"free-photo-of-black-and-white-portrait-of-a-smiling-woman.jpeg", ]);
);
if (!fs.existsSync(portrait)) {
test.skip();
return;
}
await uploadFiles(page, [portrait, getFixture("test-portrait.jpg")]);
const processBtn = page.getByRole("button", { name: /blur|process/i }); const processBtn = page.getByRole("button", { name: /blur|process/i });
await expect(processBtn).toBeEnabled({ timeout: 5000 }); await expect(processBtn).toBeEnabled({ timeout: 5000 });
@@ -272,17 +257,8 @@ test.describe("Passport photo", () => {
}); });
test("passport photo works with real portrait", async ({ page }) => { test("passport photo works with real portrait", async ({ page }) => {
const portrait = path.join(
SAMPLES_DIR,
"free-photo-of-black-and-white-portrait-of-a-smiling-woman.jpeg",
);
if (!fs.existsSync(portrait)) {
test.skip();
return;
}
await page.goto("/passport-photo"); await page.goto("/passport-photo");
await uploadFiles(page, [portrait]); await uploadFiles(page, [getFixture("content/portrait-color.jpg")]);
// Wait for face analysis (uses MediaPipe + rembg, can be slow on CPU) // Wait for face analysis (uses MediaPipe + rembg, can be slow on CPU)
await page.waitForTimeout(5000); await page.waitForTimeout(5000);
+2
View File
@@ -5,6 +5,7 @@ vi.mock("sharp", () => {
const mockSharp = vi.fn(() => ({ const mockSharp = vi.fn(() => ({
png: vi.fn().mockReturnThis(), png: vi.fn().mockReturnThis(),
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-png-data")), toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-png-data")),
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
})); }));
return { default: mockSharp }; return { default: mockSharp };
}); });
@@ -46,6 +47,7 @@ beforeEach(() => {
({ ({
png: vi.fn().mockReturnThis(), png: vi.fn().mockReturnThis(),
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-png-data")), toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-png-data")),
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
}) as unknown as ReturnType<typeof sharp>, }) as unknown as ReturnType<typeof sharp>,
); );
}); });
+526
View File
@@ -0,0 +1,526 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let mod: typeof import("../../../apps/api/src/lib/feature-status.js");
let tempDir: string;
let aiDir: string;
let modelsDir: string;
let installedPath: string;
let lockPath: string;
beforeEach(async () => {
vi.resetModules();
tempDir = mkdtempSync(join(tmpdir(), "snapotter-test-"));
aiDir = join(tempDir, "ai");
modelsDir = join(aiDir, "models");
installedPath = join(aiDir, "installed.json");
lockPath = join(aiDir, "install.lock");
mkdirSync(modelsDir, { recursive: true });
process.env.DATA_DIR = tempDir;
process.env.FEATURE_MANIFEST_PATH = join(tempDir, "feature-manifest.json");
mod = await import("../../../apps/api/src/lib/feature-status.js");
});
afterEach(() => {
delete process.env.DATA_DIR;
delete process.env.FEATURE_MANIFEST_PATH;
rmSync(tempDir, { recursive: true, force: true });
});
function writeTestManifest(
bundles: Record<string, { models: Array<{ id: string; path?: string; minSize?: number }> }>,
) {
const manifestPath = process.env.FEATURE_MANIFEST_PATH ?? "";
writeFileSync(manifestPath, JSON.stringify({ bundles }));
}
describe("installed.json management", () => {
it("reads missing file as empty {bundles: {}}", () => {
const result = mod.isFeatureInstalled("background-removal");
expect(result).toBe(false);
});
it("reads valid JSON correctly", () => {
writeFileSync(
installedPath,
JSON.stringify({
bundles: {
"background-removal": {
version: "1.0.0",
installedAt: "2026-01-01T00:00:00.000Z",
models: ["u2net.onnx"],
},
},
}),
);
mod.invalidateCache();
expect(mod.isFeatureInstalled("background-removal")).toBe(true);
});
it("reads corrupt JSON as empty (graceful degradation)", () => {
writeFileSync(installedPath, "{{{{not valid json!!!!}}}}");
mod.invalidateCache();
expect(mod.isFeatureInstalled("background-removal")).toBe(false);
});
it("writes atomically (.tmp does not persist after write)", () => {
mod.markInstalled("background-removal", "1.0.0", ["u2net.onnx"]);
expect(existsSync(installedPath)).toBe(true);
expect(existsSync(`${installedPath}.tmp`)).toBe(false);
});
it("markInstalled records bundleId, version, installedAt, and models", () => {
mod.markInstalled("face-detection", "2.1.0", ["face_model.tflite"]);
const data = JSON.parse(readFileSync(installedPath, "utf-8"));
const entry = data.bundles["face-detection"];
expect(entry).toBeDefined();
expect(entry.version).toBe("2.1.0");
expect(entry.models).toEqual(["face_model.tflite"]);
expect(new Date(entry.installedAt).toISOString()).toBe(entry.installedAt);
});
it("markUninstalled removes bundle entry, preserves others", () => {
mod.markInstalled("face-detection", "1.0.0", []);
mod.markInstalled("ocr", "1.0.0", []);
mod.markUninstalled("face-detection");
const data = JSON.parse(readFileSync(installedPath, "utf-8"));
expect(data.bundles["face-detection"]).toBeUndefined();
expect(data.bundles.ocr).toBeDefined();
});
it("multiple bundles can coexist in installed.json", () => {
mod.markInstalled("background-removal", "1.0.0", ["u2net.onnx"]);
mod.markInstalled("face-detection", "2.0.0", ["face.tflite"]);
mod.markInstalled("ocr", "3.0.0", ["ppocr.onnx"]);
const data = JSON.parse(readFileSync(installedPath, "utf-8"));
expect(Object.keys(data.bundles)).toHaveLength(3);
});
it("round-trip: install 3 bundles, uninstall all, verify empty", () => {
mod.markInstalled("background-removal", "1.0.0", []);
mod.markInstalled("face-detection", "1.0.0", []);
mod.markInstalled("ocr", "1.0.0", []);
mod.markUninstalled("background-removal");
mod.markUninstalled("face-detection");
mod.markUninstalled("ocr");
const data = JSON.parse(readFileSync(installedPath, "utf-8"));
expect(Object.keys(data.bundles)).toHaveLength(0);
});
it("markInstalled with same bundleId overwrites (version update)", () => {
mod.markInstalled("ocr", "1.0.0", ["old.onnx"]);
mod.markInstalled("ocr", "2.0.0", ["new.onnx"]);
const data = JSON.parse(readFileSync(installedPath, "utf-8"));
expect(data.bundles.ocr.version).toBe("2.0.0");
expect(data.bundles.ocr.models).toEqual(["new.onnx"]);
});
});
describe("Cache behavior", () => {
it("isFeatureInstalled reads from cache on second call", () => {
mod.markInstalled("ocr", "1.0.0", []);
expect(mod.isFeatureInstalled("ocr")).toBe(true);
writeFileSync(installedPath, JSON.stringify({ bundles: {} }));
expect(mod.isFeatureInstalled("ocr")).toBe(true);
});
it("invalidateCache forces re-read", () => {
mod.markInstalled("ocr", "1.0.0", []);
expect(mod.isFeatureInstalled("ocr")).toBe(true);
writeFileSync(installedPath, JSON.stringify({ bundles: {} }));
mod.invalidateCache();
expect(mod.isFeatureInstalled("ocr")).toBe(false);
});
it("markInstalled invalidates cache", () => {
mod.markInstalled("ocr", "1.0.0", []);
writeFileSync(
installedPath,
JSON.stringify({
bundles: { ocr: { version: "1.0.0", installedAt: "2026-01-01T00:00:00.000Z", models: [] } },
}),
);
mod.markInstalled("face-detection", "1.0.0", []);
expect(mod.isFeatureInstalled("face-detection")).toBe(true);
});
it("markUninstalled invalidates cache", () => {
mod.markInstalled("ocr", "1.0.0", []);
mod.markInstalled("face-detection", "1.0.0", []);
mod.markUninstalled("ocr");
expect(mod.isFeatureInstalled("ocr")).toBe(false);
expect(mod.isFeatureInstalled("face-detection")).toBe(true);
});
it("invalidateCache is idempotent", () => {
mod.invalidateCache();
mod.invalidateCache();
mod.invalidateCache();
expect(mod.isFeatureInstalled("ocr")).toBe(false);
});
});
describe("Install lock", () => {
it("acquireInstallLock creates lock file with bundleId and startedAt", () => {
mod.acquireInstallLock("ocr");
const data = JSON.parse(readFileSync(lockPath, "utf-8"));
expect(data.bundleId).toBe("ocr");
expect(typeof data.startedAt).toBe("string");
});
it("acquireInstallLock returns true on success", () => {
expect(mod.acquireInstallLock("ocr")).toBe(true);
});
it("acquireInstallLock returns false when lock already exists", () => {
mod.acquireInstallLock("ocr");
expect(mod.acquireInstallLock("face-detection")).toBe(false);
});
it("lock file contains valid JSON with bundleId and startedAt fields", () => {
mod.acquireInstallLock("background-removal");
const data = JSON.parse(readFileSync(lockPath, "utf-8"));
expect(data).toHaveProperty("bundleId", "background-removal");
expect(data).toHaveProperty("startedAt");
expect(new Date(data.startedAt).toISOString()).toBe(data.startedAt);
});
it("releaseInstallLock deletes lock file", () => {
mod.acquireInstallLock("ocr");
expect(existsSync(lockPath)).toBe(true);
mod.releaseInstallLock();
expect(existsSync(lockPath)).toBe(false);
});
it("releaseInstallLock is idempotent", () => {
mod.releaseInstallLock();
mod.releaseInstallLock();
expect(existsSync(lockPath)).toBe(false);
});
it("getInstallingBundle returns null when no lock", () => {
expect(mod.getInstallingBundle()).toBeNull();
});
it("getInstallingBundle returns {bundleId, startedAt} from lock file", () => {
mod.acquireInstallLock("face-detection");
const result = mod.getInstallingBundle();
expect(result).not.toBeNull();
expect(result?.bundleId).toBe("face-detection");
expect(typeof result?.startedAt).toBe("string");
});
it("getInstallingBundle deletes corrupt lock and returns null", () => {
writeFileSync(lockPath, "not-valid-json{{{{");
expect(mod.getInstallingBundle()).toBeNull();
expect(existsSync(lockPath)).toBe(false);
});
});
describe("Feature status queries", () => {
it("isFeatureInstalled returns true for installed bundle", () => {
mod.markInstalled("background-removal", "1.0.0", []);
expect(mod.isFeatureInstalled("background-removal")).toBe(true);
});
it("isFeatureInstalled returns false for not-installed bundle", () => {
expect(mod.isFeatureInstalled("background-removal")).toBe(false);
});
it("isFeatureInstalled returns false for random string", () => {
expect(mod.isFeatureInstalled("this-does-not-exist-at-all")).toBe(false);
});
it("isToolInstalled returns true when bundle is installed", () => {
mod.markInstalled("face-detection", "1.0.0", []);
expect(mod.isToolInstalled("blur-faces")).toBe(true);
});
it("isToolInstalled returns false when bundle not installed", () => {
expect(mod.isToolInstalled("blur-faces")).toBe(false);
});
it("isToolInstalled returns true for non-AI tools like resize", () => {
expect(mod.isToolInstalled("resize")).toBe(true);
});
it("isToolInstalled consistent after install then uninstall", () => {
mod.markInstalled("face-detection", "1.0.0", []);
expect(mod.isToolInstalled("blur-faces")).toBe(true);
mod.markUninstalled("face-detection");
expect(mod.isToolInstalled("blur-faces")).toBe(false);
});
});
describe("Model verification via getFeatureStates", () => {
it("returns installed when all models exist and meet minSize", () => {
mod.markInstalled("background-removal", "1.0.0", ["u2net.onnx"]);
writeTestManifest({
"background-removal": {
models: [{ id: "u2net", path: "u2net.onnx", minSize: 10 }],
},
});
writeFileSync(join(modelsDir, "u2net.onnx"), Buffer.alloc(1024));
mod.invalidateCache();
const states = mod.getFeatureStates();
const bg = states.find((s) => s.id === "background-removal");
expect(bg?.status).toBe("installed");
});
it("returns error with message when model file missing", () => {
mod.markInstalled("background-removal", "1.0.0", ["u2net.onnx"]);
writeTestManifest({
"background-removal": {
models: [{ id: "u2net", path: "u2net.onnx" }],
},
});
mod.invalidateCache();
const states = mod.getFeatureStates();
const bg = states.find((s) => s.id === "background-removal");
expect(bg?.status).toBe("error");
expect(bg?.error).toContain("u2net.onnx");
});
it("returns error when model file is undersized", () => {
mod.markInstalled("background-removal", "1.0.0", ["u2net.onnx"]);
writeTestManifest({
"background-removal": {
models: [{ id: "u2net", path: "u2net.onnx", minSize: 1000 }],
},
});
writeFileSync(join(modelsDir, "u2net.onnx"), Buffer.alloc(10));
mod.invalidateCache();
const states = mod.getFeatureStates();
const bg = states.find((s) => s.id === "background-removal");
expect(bg?.status).toBe("error");
expect(bg?.error).toContain("undersized");
});
it("ignores models without path field", () => {
mod.markInstalled("background-removal", "1.0.0", ["session"]);
writeTestManifest({
"background-removal": {
models: [{ id: "rembg-session" }],
},
});
mod.invalidateCache();
const states = mod.getFeatureStates();
const bg = states.find((s) => s.id === "background-removal");
expect(bg?.status).toBe("installed");
});
it("returns installed when manifest is missing", () => {
mod.markInstalled("background-removal", "1.0.0", []);
mod.invalidateCache();
const states = mod.getFeatureStates();
const bg = states.find((s) => s.id === "background-removal");
expect(bg?.status).toBe("installed");
});
it("returns installed when bundle not in manifest", () => {
mod.markInstalled("background-removal", "1.0.0", []);
writeTestManifest({ "some-other-bundle": { models: [] } });
mod.invalidateCache();
const states = mod.getFeatureStates();
const bg = states.find((s) => s.id === "background-removal");
expect(bg?.status).toBe("installed");
});
it("error message identifies which model failed", () => {
mod.markInstalled("background-removal", "1.0.0", ["a.onnx", "b.onnx"]);
writeTestManifest({
"background-removal": {
models: [
{ id: "a", path: "a.onnx" },
{ id: "b", path: "b.onnx" },
],
},
});
writeFileSync(join(modelsDir, "a.onnx"), Buffer.alloc(100));
mod.invalidateCache();
const states = mod.getFeatureStates();
const bg = states.find((s) => s.id === "background-removal");
expect(bg?.status).toBe("error");
expect(bg?.error).toContain("b.onnx");
});
it("checks minSize only when minSize > 0", () => {
mod.markInstalled("background-removal", "1.0.0", ["small.onnx"]);
writeTestManifest({
"background-removal": {
models: [{ id: "small", path: "small.onnx", minSize: 0 }],
},
});
writeFileSync(join(modelsDir, "small.onnx"), Buffer.alloc(1));
mod.invalidateCache();
const states = mod.getFeatureStates();
const bg = states.find((s) => s.id === "background-removal");
expect(bg?.status).toBe("installed");
});
});
describe("Crash recovery - recoverInterruptedInstalls", () => {
it("deletes .downloading files in models dir", () => {
writeFileSync(join(modelsDir, "model.downloading"), "partial");
mod.recoverInterruptedInstalls();
expect(existsSync(join(modelsDir, "model.downloading"))).toBe(false);
});
it("deletes nested .downloading files", () => {
const subdir = join(modelsDir, "subdir");
mkdirSync(subdir, { recursive: true });
writeFileSync(join(subdir, "nested.downloading"), "partial");
mod.recoverInterruptedInstalls();
expect(existsSync(join(subdir, "nested.downloading"))).toBe(false);
});
it("does NOT delete non-.downloading files", () => {
writeFileSync(join(modelsDir, "real-model.onnx"), "model-data");
mod.recoverInterruptedInstalls();
expect(existsSync(join(modelsDir, "real-model.onnx"))).toBe(true);
});
it("deletes stale installed.json.tmp", () => {
writeFileSync(`${installedPath}.tmp`, "stale");
mod.recoverInterruptedInstalls();
expect(existsSync(`${installedPath}.tmp`)).toBe(false);
});
it("deletes venv.bootstrapping/ directory", () => {
const bootstrapping = join(aiDir, "venv.bootstrapping");
mkdirSync(bootstrapping, { recursive: true });
writeFileSync(join(bootstrapping, "somefile"), "data");
mod.recoverInterruptedInstalls();
expect(existsSync(bootstrapping)).toBe(false);
});
it("removes stale install lock", () => {
writeFileSync(
lockPath,
JSON.stringify({ bundleId: "ocr", startedAt: "2026-01-01T00:00:00.000Z" }),
);
mod.recoverInterruptedInstalls();
expect(existsSync(lockPath)).toBe(false);
});
it("handles missing directories gracefully", async () => {
vi.resetModules();
const emptyTemp = mkdtempSync(join(tmpdir(), "snapotter-empty-"));
process.env.DATA_DIR = emptyTemp;
const freshMod = await import("../../../apps/api/src/lib/feature-status.js");
expect(() => freshMod.recoverInterruptedInstalls()).not.toThrow();
rmSync(emptyTemp, { recursive: true, force: true });
});
it("preserves valid installed.json through recovery", () => {
writeFileSync(
installedPath,
JSON.stringify({
bundles: {
ocr: { version: "1.0.0", installedAt: "2026-01-01T00:00:00.000Z", models: [] },
},
}),
);
mod.recoverInterruptedInstalls();
const data = JSON.parse(readFileSync(installedPath, "utf-8"));
expect(data.bundles.ocr).toBeDefined();
});
it("invalidates cache after recovery", () => {
mod.markInstalled("ocr", "1.0.0", []);
expect(mod.isFeatureInstalled("ocr")).toBe(true);
writeFileSync(installedPath, JSON.stringify({ bundles: {} }));
expect(mod.isFeatureInstalled("ocr")).toBe(true);
mod.recoverInterruptedInstalls();
expect(mod.isFeatureInstalled("ocr")).toBe(false);
});
});
describe("Composite state - getFeatureStates", () => {
it("all bundles not_installed when installed.json is empty", () => {
const states = mod.getFeatureStates();
for (const state of states) {
expect(state.status).toBe("not_installed");
}
expect(states.length).toBe(6);
});
it("installed bundle with valid models returns installed with version", () => {
mod.markInstalled("ocr", "3.5.0", ["ppocr.onnx"]);
writeTestManifest({
ocr: { models: [{ id: "ppocr", path: "ppocr.onnx" }] },
});
writeFileSync(join(modelsDir, "ppocr.onnx"), Buffer.alloc(100));
mod.invalidateCache();
const states = mod.getFeatureStates();
const ocr = states.find((s) => s.id === "ocr");
expect(ocr?.status).toBe("installed");
expect(ocr?.installedVersion).toBe("3.5.0");
});
it("lock held for bundle returns installing", () => {
mod.acquireInstallLock("ocr");
const states = mod.getFeatureStates();
const ocr = states.find((s) => s.id === "ocr");
expect(ocr?.status).toBe("installing");
});
it("lock held + progress set returns installing with progress data", () => {
mod.acquireInstallLock("ocr");
mod.setInstallProgress("ocr", { percent: 42, stage: "downloading" }, null);
const states = mod.getFeatureStates();
const ocr = states.find((s) => s.id === "ocr");
expect(ocr?.status).toBe("installing");
expect(ocr?.progress).toEqual({ percent: 42, stage: "downloading" });
});
it("lock held + progress with error returns error with message", () => {
mod.acquireInstallLock("ocr");
mod.setInstallProgress("ocr", { percent: 80, stage: "verifying" }, "Checksum mismatch");
const states = mod.getFeatureStates();
const ocr = states.find((s) => s.id === "ocr");
expect(ocr?.status).toBe("error");
expect(ocr?.error).toBe("Checksum mismatch");
});
it("installed bundle + missing model returns error with model error", () => {
mod.markInstalled("ocr", "1.0.0", ["ppocr.onnx"]);
writeTestManifest({
ocr: { models: [{ id: "ppocr", path: "ppocr.onnx" }] },
});
mod.invalidateCache();
const states = mod.getFeatureStates();
const ocr = states.find((s) => s.id === "ocr");
expect(ocr?.status).toBe("error");
expect(ocr?.error).toContain("ppocr.onnx");
});
it("not installed + stale error progress returns error", () => {
mod.setInstallProgress("ocr", null, "Install failed: disk full");
const states = mod.getFeatureStates();
const ocr = states.find((s) => s.id === "ocr");
expect(ocr?.status).toBe("error");
expect(ocr?.error).toBe("Install failed: disk full");
});
it("each result has correct shape", () => {
mod.markInstalled("ocr", "1.0.0", []);
const states = mod.getFeatureStates();
for (const state of states) {
expect(state).toHaveProperty("id");
expect(state).toHaveProperty("name");
expect(state).toHaveProperty("description");
expect(state).toHaveProperty("status");
expect(state).toHaveProperty("installedVersion");
expect(state).toHaveProperty("estimatedSize");
expect(state).toHaveProperty("enablesTools");
expect(state).toHaveProperty("progress");
expect(state).toHaveProperty("error");
expect(Array.isArray(state.enablesTools)).toBe(true);
}
});
});