From 4acec0846cb84070dae15868d71bc617c511757a Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Tue, 28 Apr 2026 13:27:54 +0800 Subject: [PATCH] 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 --- packages/ai/src/noise-removal.ts | 7 +- tests/e2e-docker/analytics-consent.spec.ts | 2 +- tests/e2e-docker/auth.setup.ts | 2 + tests/e2e-docker/feature-lifecycle.spec.ts | 558 ++++++++++++++++++++ tests/e2e-docker/features.spec.ts | 159 +++--- tests/e2e-docker/fixes-verification.spec.ts | 34 +- tests/unit/ai/noise-removal.test.ts | 2 + tests/unit/features/feature-status.test.ts | 526 ++++++++++++++++++ 8 files changed, 1183 insertions(+), 107 deletions(-) create mode 100644 tests/e2e-docker/feature-lifecycle.spec.ts create mode 100644 tests/unit/features/feature-status.test.ts diff --git a/packages/ai/src/noise-removal.ts b/packages/ai/src/noise-removal.ts index 268fb270..69d85117 100644 --- a/packages/ai/src/noise-removal.ts +++ b/packages/ai/src/noise-removal.ts @@ -31,10 +31,15 @@ export async function noiseRemoval( const pngBuffer = await sharp(inputBuffer).png().toBuffer(); 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( "noise_removal.py", [inputPath, outputPath, JSON.stringify(options)], - { onProgress }, + { onProgress, timeout }, ); const result = parseStdoutJson(stdout); diff --git a/tests/e2e-docker/analytics-consent.spec.ts b/tests/e2e-docker/analytics-consent.spec.ts index 84d8b16e..fc8a15d2 100644 --- a/tests/e2e-docker/analytics-consent.spec.ts +++ b/tests/e2e-docker/analytics-consent.spec.ts @@ -55,7 +55,7 @@ test.describe("Analytics consent page", () => { 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 await loginAndGetToHome(page); await expect(page).toHaveURL("/"); diff --git a/tests/e2e-docker/auth.setup.ts b/tests/e2e-docker/auth.setup.ts index 4610c2d4..006b6865 100644 --- a/tests/e2e-docker/auth.setup.ts +++ b/tests/e2e-docker/auth.setup.ts @@ -1,3 +1,4 @@ +import { mkdirSync } from "node:fs"; import path from "node:path"; 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 await expect(page).toHaveURL("/"); + mkdirSync(path.dirname(authFile), { recursive: true }); await page.context().storageState({ path: authFile }); }); diff --git a/tests/e2e-docker/feature-lifecycle.spec.ts b/tests/e2e-docker/feature-lifecycle.spec.ts new file mode 100644 index 00000000..a3f71a2a --- /dev/null +++ b/tests/e2e-docker/feature-lifecycle.spec.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = {}, +) { + 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"); + }); +}); diff --git a/tests/e2e-docker/features.spec.ts b/tests/e2e-docker/features.spec.ts index 154cd544..62d036c5 100644 --- a/tests/e2e-docker/features.spec.ts +++ b/tests/e2e-docker/features.spec.ts @@ -31,15 +31,6 @@ async function fetchBundleStatuses( return data.bundles as BundleInfo[]; } -async function isBundleInstalled( - request: import("@playwright/test").APIRequestContext, - bundleId: string, -): Promise { - const bundles = await fetchBundleStatuses(request); - const bundle = bundles.find((b) => b.id === bundleId); - return bundle?.status === "installed"; -} - // ─── Feature API tests ───────────────────────────────────────────── test.describe("Feature API", () => { @@ -102,16 +93,6 @@ test.describe("Feature API", () => { 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 }) => { const token = await getToken(request); 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.configure({ mode: "serial" }); + const pngBuffer = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==", "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: "upscale", bundle: "upscale-enhance" }, { tool: "blur-faces", bundle: "face-detection" }, { tool: "erase-object", bundle: "object-eraser-colorize" }, - { tool: "ocr", bundle: "ocr" }, { tool: "colorize", bundle: "object-eraser-colorize" }, { tool: "enhance-faces", bundle: "upscale-enhance" }, { tool: "noise-removal", bundle: "upscale-enhance" }, @@ -145,13 +176,8 @@ test.describe("Tool route guards", () => { { tool: "passport-photo", bundle: "background-removal" }, ]; - for (const { tool, bundle } of aiTools) { - test(`${tool} returns 501 FEATURE_NOT_INSTALLED with correct bundle`, async ({ request }) => { - const installed = await isBundleInstalled(request, bundle); - if (installed) { - test.skip(); - return; - } + for (const { tool } of installedAiTools) { + test(`${tool} returns 200 when bundle is installed`, async ({ request }) => { const response = await request.post(`/api/v1/tools/${tool}`, { multipart: { file: { @@ -162,12 +188,7 @@ test.describe("Tool route guards", () => { settings: JSON.stringify({}), }, }); - expect(response.status()).toBe(501); - 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(); + expect(response.status()).toBe(200); }); } @@ -185,23 +206,18 @@ test.describe("Tool route guards", () => { // Should succeed or fail with a processing error, NOT 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", () => { - const pngBuffer = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==", - "base64", - ); + test("POST uninstall returns 409 for not-installed bundle", async ({ request }) => { + const response = await request.post("/api/v1/admin/features/ocr/uninstall"); + expect(response.status()).toBe(409); + }); + + // ─── Batch guard (ocr is not installed at this point) ───────────── test("batch endpoint returns 501 for uninstalled AI tool", async ({ request }) => { - const installed = await isBundleInstalled(request, "background-removal"); - if (installed) { - test.skip(); - return; - } - const response = await request.post("/api/v1/tools/remove-background/batch", { + const response = await request.post("/api/v1/tools/ocr/batch", { multipart: { "files[]": { name: "test.png", @@ -215,25 +231,33 @@ test.describe("Batch and pipeline guards", () => { const body = await response.json(); 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, request }) => { - const installed = await isBundleInstalled(request, "background-removal"); - if (installed) { - test.skip(); - return; - } - await page.goto("/remove-background"); - await expect(page.getByText("Background Removal")).toBeVisible({ + test("uninstalled AI tool page shows install prompt", async ({ page }) => { + await page.goto("/ocr"); + await expect(page.getByText("OCR")).toBeVisible({ timeout: 10000, }); await expect(page.getByText("additional download")).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 }) => { await page.goto("/resize"); // 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 }) => { await page.goto("/resize"); // Open settings - look for a settings button/gear icon diff --git a/tests/e2e-docker/fixes-verification.spec.ts b/tests/e2e-docker/fixes-verification.spec.ts index 6d8fae10..00e389bc 100644 --- a/tests/e2e-docker/fixes-verification.spec.ts +++ b/tests/e2e-docker/fixes-verification.spec.ts @@ -1,16 +1,8 @@ -import fs from "node:fs"; import path from "node:path"; 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"); -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 { return path.join(FIXTURES_DIR, name); } @@ -207,17 +199,10 @@ test.describe("Batch processing fixes", () => { test("blur-faces processes multiple files", async ({ page }) => { await page.goto("/blur-faces"); - // Use sample portraits - 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 uploadFiles(page, [portrait, getFixture("test-portrait.jpg")]); + await uploadFiles(page, [ + getFixture("content/multi-face.webp"), + getFixture("content/portrait-color.jpg"), + ]); const processBtn = page.getByRole("button", { name: /blur|process/i }); await expect(processBtn).toBeEnabled({ timeout: 5000 }); @@ -272,17 +257,8 @@ test.describe("Passport photo", () => { }); 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 uploadFiles(page, [portrait]); + await uploadFiles(page, [getFixture("content/portrait-color.jpg")]); // Wait for face analysis (uses MediaPipe + rembg, can be slow on CPU) await page.waitForTimeout(5000); diff --git a/tests/unit/ai/noise-removal.test.ts b/tests/unit/ai/noise-removal.test.ts index 66af4a87..06bf0764 100644 --- a/tests/unit/ai/noise-removal.test.ts +++ b/tests/unit/ai/noise-removal.test.ts @@ -5,6 +5,7 @@ vi.mock("sharp", () => { const mockSharp = vi.fn(() => ({ png: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-png-data")), + metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }), })); return { default: mockSharp }; }); @@ -46,6 +47,7 @@ beforeEach(() => { ({ png: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-png-data")), + metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }), }) as unknown as ReturnType, ); }); diff --git a/tests/unit/features/feature-status.test.ts b/tests/unit/features/feature-status.test.ts new file mode 100644 index 00000000..9a49448b --- /dev/null +++ b/tests/unit/features/feature-status.test.ts @@ -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 }>, +) { + 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); + } + }); +});