mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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);
|
||||||
|
|||||||
@@ -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("/");
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user