diff --git a/docker/Dockerfile b/docker/Dockerfile index 6fa9c0a5..1238eae5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -160,6 +160,9 @@ RUN for i in 1 2 3; do apt-get -o Acquire::Retries=3 update && break || sleep $( && if apt-cache show libheif-plugin-x265 >/dev/null 2>&1; then \ apt-get install -y --no-install-recommends libheif-plugin-x265; \ fi \ + && if apt-cache show libcublas-12-6 >/dev/null 2>&1; then \ + apt-get install -y --no-install-recommends libcublas-12-6; \ + fi \ && rm -rf /var/lib/apt/lists/* # Caire binary (content-aware seam carving) diff --git a/docker/Dockerfile.test b/docker/Dockerfile.test index d5f2cb04..33797a19 100644 --- a/docker/Dockerfile.test +++ b/docker/Dockerfile.test @@ -7,6 +7,14 @@ FROM node:22-bookworm RUN corepack enable && corepack prepare pnpm@9.15.4 --activate +RUN apt-get update && apt-get install -y --no-install-recommends \ + libheif-examples \ + libimage-exiftool-perl \ + && if apt-cache show libheif-plugin-x265 >/dev/null 2>&1; then \ + apt-get install -y --no-install-recommends libheif-plugin-x265; \ + fi \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /app # Copy workspace config first (for layer caching) diff --git a/docker/Dockerfile.test.dockerignore b/docker/Dockerfile.test.dockerignore new file mode 100644 index 00000000..719ca13b --- /dev/null +++ b/docker/Dockerfile.test.dockerignore @@ -0,0 +1,22 @@ +node_modules +.git +.turbo +dist +*.db +*.db-journal +*.db-wal +*.db-shm +.env +.env.local +.DS_Store +test-results +playwright-report +blob-report +docs +coverage +.superpowers +*.tsbuildinfo +*.md +!README.md +test-*.png +audit_report.md diff --git a/packages/ai/python/colorize.py b/packages/ai/python/colorize.py index f14c6e80..c0eb2380 100644 --- a/packages/ai/python/colorize.py +++ b/packages/ai/python/colorize.py @@ -46,19 +46,11 @@ OPENCV_POINTS_PATH = os.environ.get( def colorize_ddcolor(img_bgr, intensity): """Colorize using DDColor ONNX model.""" - import onnxruntime as ort + from gpu import safe_onnx_session emit_progress(15, "Loading DDColor model") - providers = ["CUDAExecutionProvider", "CPUExecutionProvider"] - try: - from gpu import gpu_available - if not gpu_available(): - providers = ["CPUExecutionProvider"] - except ImportError: - providers = ["CPUExecutionProvider"] - - session = ort.InferenceSession(DDCOLOR_MODEL_PATH, providers=providers) + session = safe_onnx_session(DDCOLOR_MODEL_PATH) input_name = session.get_inputs()[0].name input_shape = session.get_inputs()[0].shape # Dynamic dims are strings ('w', 'h'), so default to 512 if not int diff --git a/packages/ai/python/gpu.py b/packages/ai/python/gpu.py index 7c2e931d..de7dd858 100644 --- a/packages/ai/python/gpu.py +++ b/packages/ai/python/gpu.py @@ -55,3 +55,20 @@ def onnx_providers(): if gpu_available(): return ["CUDAExecutionProvider", "CPUExecutionProvider"] return ["CPUExecutionProvider"] + + +def safe_onnx_session(model_path, providers=None): + """Create an ONNX Runtime InferenceSession with graceful CUDA EP fallback.""" + import onnxruntime as ort + + if providers is None: + providers = onnx_providers() + + try: + return ort.InferenceSession(model_path, providers=providers) + except Exception as e: + if "CUDAExecutionProvider" in providers: + print(f"[gpu] CUDA EP init failed ({e}), falling back to CPU", + file=sys.stderr, flush=True) + return ort.InferenceSession(model_path, providers=["CPUExecutionProvider"]) + raise diff --git a/packages/ai/python/inpaint.py b/packages/ai/python/inpaint.py index 33b1e143..9e63f43b 100644 --- a/packages/ai/python/inpaint.py +++ b/packages/ai/python/inpaint.py @@ -99,7 +99,7 @@ def main(): try: import cv2 - import onnxruntime as ort + import onnxruntime except ImportError as e: print(json.dumps({ "success": False, @@ -110,11 +110,8 @@ def main(): emit_progress(10, "Loading model") model_path = _get_model_path() - # Configure ONNX Runtime session - from gpu import onnx_providers - providers = onnx_providers() - - session = ort.InferenceSession(model_path, providers=providers) + from gpu import safe_onnx_session + session = safe_onnx_session(model_path) emit_progress(20, "Loading images") img = Image.open(input_path).convert("RGB") diff --git a/packages/ai/python/remove_bg.py b/packages/ai/python/remove_bg.py index 4501602f..c0b014df 100644 --- a/packages/ai/python/remove_bg.py +++ b/packages/ai/python/remove_bg.py @@ -76,7 +76,16 @@ def main(): emit_progress(10, "Loading model") - session = new_session(model, providers=onnx_providers()) + providers = onnx_providers() + try: + session = new_session(model, providers=providers) + except Exception as e: + if "CUDAExecutionProvider" in providers: + print(f"[remove-bg] GPU session failed ({e}), falling back to CPU", + file=sys.stderr, flush=True) + session = new_session(model, providers=["CPUExecutionProvider"]) + else: + raise emit_progress(25, "Model loaded") diff --git a/packages/ai/python/restore.py b/packages/ai/python/restore.py index 2c9c75b7..aee3de6c 100644 --- a/packages/ai/python/restore.py +++ b/packages/ai/python/restore.py @@ -152,14 +152,10 @@ def inpaint_damage(img_bgr, mask): Returns: Restored BGR image with damage inpainted. """ - import onnxruntime as ort + from gpu import safe_onnx_session model_path = _get_lama_path() - providers = ["CPUExecutionProvider"] - if "CUDAExecutionProvider" in ort.get_available_providers(): - providers.insert(0, "CUDAExecutionProvider") - - session = ort.InferenceSession(model_path, providers=providers) + session = safe_onnx_session(model_path) orig_h, orig_w = img_bgr.shape[:2] img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) @@ -261,7 +257,7 @@ def enhance_faces(img_bgr, fidelity=0.7): Tuple of (enhanced BGR image, number of faces found). """ import mediapipe as mp - import onnxruntime as ort + from gpu import safe_onnx_session # Detect faces img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) @@ -321,11 +317,7 @@ def enhance_faces(img_bgr, fidelity=0.7): # Load CodeFormer model model_path = _get_codeformer_path() - providers = ["CPUExecutionProvider"] - if "CUDAExecutionProvider" in ort.get_available_providers(): - providers.insert(0, "CUDAExecutionProvider") - - session = ort.InferenceSession(model_path, providers=providers) + session = safe_onnx_session(model_path) input_names = [inp.name for inp in session.get_inputs()] result = img_bgr.copy() @@ -476,20 +468,12 @@ def colorize_bw(img_bgr, intensity=0.85): Reuses the DDColor model that the colorize tool already downloads. """ - import onnxruntime as ort + from gpu import safe_onnx_session if not os.path.exists(DDCOLOR_MODEL_PATH): return img_bgr, False - providers = ["CPUExecutionProvider"] - try: - from gpu import gpu_available - if gpu_available(): - providers.insert(0, "CUDAExecutionProvider") - except ImportError as e: - print(f"[restore] GPU detection unavailable: {e}", file=sys.stderr, flush=True) - - session = ort.InferenceSession(DDCOLOR_MODEL_PATH, providers=providers) + session = safe_onnx_session(DDCOLOR_MODEL_PATH) input_name = session.get_inputs()[0].name input_shape = session.get_inputs()[0].shape model_size = ( diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 3de51a53..42355bef 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1195,6 +1195,7 @@ export const PYTHON_SIDECAR_TOOLS = [ "colorize", "enhance-faces", "noise-removal", + "smart-crop", "red-eye-removal", "restore-photo", "passport-photo", diff --git a/tests/e2e/__screenshots__/visual-regression.spec.ts/fullscreen-grid-desktop.png b/tests/e2e/__screenshots__/visual-regression.spec.ts/fullscreen-grid-desktop.png index 061ca932..7f036aa2 100644 Binary files a/tests/e2e/__screenshots__/visual-regression.spec.ts/fullscreen-grid-desktop.png and b/tests/e2e/__screenshots__/visual-regression.spec.ts/fullscreen-grid-desktop.png differ diff --git a/tests/e2e/__screenshots__/visual-regression.spec.ts/fullscreen-grid-mobile.png b/tests/e2e/__screenshots__/visual-regression.spec.ts/fullscreen-grid-mobile.png index 5814fb84..587f20f1 100644 Binary files a/tests/e2e/__screenshots__/visual-regression.spec.ts/fullscreen-grid-mobile.png and b/tests/e2e/__screenshots__/visual-regression.spec.ts/fullscreen-grid-mobile.png differ diff --git a/tests/e2e/__screenshots__/visual-regression.spec.ts/fullscreen-grid-tablet.png b/tests/e2e/__screenshots__/visual-regression.spec.ts/fullscreen-grid-tablet.png index a9210e48..4df5ff70 100644 Binary files a/tests/e2e/__screenshots__/visual-regression.spec.ts/fullscreen-grid-tablet.png and b/tests/e2e/__screenshots__/visual-regression.spec.ts/fullscreen-grid-tablet.png differ diff --git a/tests/e2e/__screenshots__/visual-regression.spec.ts/home-tablet.png b/tests/e2e/__screenshots__/visual-regression.spec.ts/home-tablet.png index 2c290b2b..e4cc1739 100644 Binary files a/tests/e2e/__screenshots__/visual-regression.spec.ts/home-tablet.png and b/tests/e2e/__screenshots__/visual-regression.spec.ts/home-tablet.png differ diff --git a/tests/e2e/__screenshots__/visual-regression.spec.ts/login-desktop.png b/tests/e2e/__screenshots__/visual-regression.spec.ts/login-desktop.png index f1106bcc..210d3606 100644 Binary files a/tests/e2e/__screenshots__/visual-regression.spec.ts/login-desktop.png and b/tests/e2e/__screenshots__/visual-regression.spec.ts/login-desktop.png differ diff --git a/tests/e2e/__screenshots__/visual-regression.spec.ts/login-mobile.png b/tests/e2e/__screenshots__/visual-regression.spec.ts/login-mobile.png index b993f499..c455cbe6 100644 Binary files a/tests/e2e/__screenshots__/visual-regression.spec.ts/login-mobile.png and b/tests/e2e/__screenshots__/visual-regression.spec.ts/login-mobile.png differ diff --git a/tests/e2e/__screenshots__/visual-regression.spec.ts/resize-empty-mobile.png b/tests/e2e/__screenshots__/visual-regression.spec.ts/resize-empty-mobile.png index 25a718fa..b43aedb9 100644 Binary files a/tests/e2e/__screenshots__/visual-regression.spec.ts/resize-empty-mobile.png and b/tests/e2e/__screenshots__/visual-regression.spec.ts/resize-empty-mobile.png differ diff --git a/tests/e2e/blur-faces.spec.ts b/tests/e2e/blur-faces.spec.ts index fce12abd..022688ad 100644 --- a/tests/e2e/blur-faces.spec.ts +++ b/tests/e2e/blur-faces.spec.ts @@ -15,8 +15,17 @@ async function uploadFile(page: import("@playwright/test").Page, filePath: strin } test.describe("Blur Faces tool", () => { - test("page loads with correct UI controls", async ({ loggedInPage: page }) => { + async function skipIfFeatureNotInstalled(page: import("@playwright/test").Page) { await page.goto("/blur-faces"); + try { + await page.getByTestId("blur-faces-submit").waitFor({ state: "visible", timeout: 15_000 }); + } catch { + test.skip(true, "face-detection feature bundle not installed"); + } + } + + test("page loads with correct UI controls", async ({ loggedInPage: page }) => { + await skipIfFeatureNotInstalled(page); await expect(page.getByText("Blur Radius")).toBeVisible(); await expect(page.getByText("Detection Sensitivity")).toBeVisible(); @@ -24,7 +33,7 @@ test.describe("Blur Faces tool", () => { }); test("HEIC image processes without error", async ({ loggedInPage: page }) => { - await page.goto("/blur-faces"); + await skipIfFeatureNotInstalled(page); await uploadFile(page, fixturePath("test-portrait.heic")); await page.getByTestId("blur-faces-submit").click(); @@ -38,7 +47,7 @@ test.describe("Blur Faces tool", () => { }); test("no-face image shows warning message", async ({ loggedInPage: page }) => { - await page.goto("/blur-faces"); + await skipIfFeatureNotInstalled(page); await uploadFile(page, fixturePath("test-blank.png")); await page.getByTestId("blur-faces-submit").click(); diff --git a/tests/e2e/noise-removal.spec.ts b/tests/e2e/noise-removal.spec.ts index cde2b218..6ab0d39c 100644 --- a/tests/e2e/noise-removal.spec.ts +++ b/tests/e2e/noise-removal.spec.ts @@ -83,7 +83,7 @@ test.describe("Noise Removal tool", () => { }); test("GIF + AI tier warning appears and disappears correctly", async ({ loggedInPage: page }) => { - await page.goto("/noise-removal"); + await skipIfFeatureNotInstalled(page); await uploadFile(page, fixturePath("animated.gif")); // No warning with balanced tier (default) @@ -102,7 +102,7 @@ test.describe("Noise Removal tool", () => { test("PNG - quick tier removes noise and shows download button", async ({ loggedInPage: page, }) => { - await page.goto("/noise-removal"); + await skipIfFeatureNotInstalled(page); await uploadFile(page, fixturePath("test-200x150.png")); await page.getByRole("button", { name: "Quick" }).click(); @@ -119,7 +119,7 @@ test.describe("Noise Removal tool", () => { test("JPG - balanced tier removes noise and shows download button", async ({ loggedInPage: page, }) => { - await page.goto("/noise-removal"); + await skipIfFeatureNotInstalled(page); await uploadFile(page, fixturePath("test-100x100.jpg")); // Balanced is default, no need to click it @@ -132,7 +132,7 @@ test.describe("Noise Removal tool", () => { test("WEBP output format - processes and download link is correct type", async ({ loggedInPage: page, }) => { - await page.goto("/noise-removal"); + await skipIfFeatureNotInstalled(page); await uploadFile(page, fixturePath("test-200x150.png")); await page.getByRole("button", { name: "Quick" }).click(); @@ -144,7 +144,7 @@ test.describe("Noise Removal tool", () => { }); test("download link has correct href after processing", async ({ loggedInPage: page }) => { - await page.goto("/noise-removal"); + await skipIfFeatureNotInstalled(page); await uploadFile(page, fixturePath("test-200x150.png")); await page.getByRole("button", { name: "Quick" }).click(); diff --git a/tests/e2e/ocr.spec.ts b/tests/e2e/ocr.spec.ts index 64b087d3..54f08fe7 100644 --- a/tests/e2e/ocr.spec.ts +++ b/tests/e2e/ocr.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./helpers"; async function uploadOcrFile(page: import("@playwright/test").Page, filePath: string) { const fileChooserPromise = page.waitForEvent("filechooser"); @@ -17,37 +17,53 @@ async function submitOcr(page: import("@playwright/test").Page) { } test.describe("OCR / Text Extraction", () => { - test.beforeEach(async ({ page }) => { + async function skipIfFeatureNotInstalled(page: import("@playwright/test").Page) { await page.goto("/ocr"); await page.waitForLoadState("networkidle"); - }); + try { + await page.getByTestId("ocr-submit").waitFor({ state: "visible", timeout: 15_000 }); + } catch { + test.skip(true, "ocr feature bundle not installed"); + } + } + + test("renders quality selector with three options", async ({ loggedInPage: page }) => { + await skipIfFeatureNotInstalled(page); - test("renders quality selector with three options", async ({ page }) => { const buttons = page.locator("button").filter({ hasText: /^(Fast|Balanced|Best)$/ }); await expect(buttons).toHaveCount(3); - // Balanced should be selected by default const balanced = page.locator("button").filter({ hasText: "Balanced" }); await expect(balanced).toHaveClass(/border-primary/); }); - test("renders enhance checkbox defaulting to unchecked", async ({ page }) => { + test("renders enhance checkbox defaulting to unchecked", async ({ loggedInPage: page }) => { + await skipIfFeatureNotInstalled(page); + const checkbox = page.locator('input[type="checkbox"]'); await expect(checkbox).not.toBeChecked(); }); - test("enhance defaults to unchecked when Best is selected", async ({ page }) => { + test("enhance defaults to unchecked when Best is selected", async ({ loggedInPage: page }) => { + await skipIfFeatureNotInstalled(page); + await page.locator("button").filter({ hasText: "Best" }).click(); const checkbox = page.locator('input[type="checkbox"]'); await expect(checkbox).not.toBeChecked(); }); - test("language section is collapsed by default showing auto-detect", async ({ page }) => { + test("language section is collapsed by default showing auto-detect", async ({ + loggedInPage: page, + }) => { + await skipIfFeatureNotInstalled(page); + await expect(page.getByText("auto-detect", { exact: false })).toBeVisible(); await expect(page.locator("select")).not.toBeVisible(); }); - test("language section expands to show dropdown", async ({ page }) => { + test("language section expands to show dropdown", async ({ loggedInPage: page }) => { + await skipIfFeatureNotInstalled(page); + await page.getByText("Language").click(); await expect(page.locator("select")).toBeVisible(); @@ -55,29 +71,36 @@ test.describe("OCR / Text Extraction", () => { await expect(options).toHaveCount(8); }); - test("extract text button is disabled without a file", async ({ page }) => { + test("extract text button is disabled without a file", async ({ loggedInPage: page }) => { + await skipIfFeatureNotInstalled(page); + const button = page.getByTestId("ocr-submit"); await expect(button).toBeDisabled(); }); - test("uploads image and OCR processing completes", async ({ page }) => { + test("uploads image and OCR processing completes", async ({ loggedInPage: page }) => { + await skipIfFeatureNotInstalled(page); + await uploadOcrFile(page, "tests/fixtures/test-portrait.jpg"); await submitOcr(page); - // OCR completed — shows either extracted text or "no text" message const hasText = await page.getByTestId("ocr-result-text").isVisible(); const hasNoText = await page.getByText("No text detected").isVisible(); expect(hasText || hasNoText).toBe(true); }); - test("copy button is visible after OCR completes", async ({ page }) => { + test("copy button is visible after OCR completes", async ({ loggedInPage: page }) => { + await skipIfFeatureNotInstalled(page); + await uploadOcrFile(page, "tests/fixtures/test-portrait.jpg"); await submitOcr(page); await expect(page.getByText("Copy")).toBeVisible(); }); - test("shows 'no text detected' for blank image", async ({ page }) => { + test("shows 'no text detected' for blank image", async ({ loggedInPage: page }) => { + await skipIfFeatureNotInstalled(page); + await uploadOcrFile(page, "tests/fixtures/test-blank.png"); await submitOcr(page); diff --git a/tests/e2e/remove-bg.spec.ts b/tests/e2e/remove-bg.spec.ts index ec7d8b7a..b1b3b002 100644 --- a/tests/e2e/remove-bg.spec.ts +++ b/tests/e2e/remove-bg.spec.ts @@ -31,8 +31,19 @@ async function removeBgAndWait(page: import("@playwright/test").Page) { } test.describe("Remove Background tool", () => { - test("page loads with correct UI sections", async ({ loggedInPage: page }) => { + async function skipIfFeatureNotInstalled(page: import("@playwright/test").Page) { await page.goto("/remove-background"); + try { + await page + .getByTestId("remove-background-submit") + .waitFor({ state: "visible", timeout: 15_000 }); + } catch { + test.skip(true, "background-removal feature bundle not installed"); + } + } + + test("page loads with correct UI sections", async ({ loggedInPage: page }) => { + await skipIfFeatureNotInstalled(page); await expect(page.getByText("People")).toBeVisible(); await expect(page.getByText("Products")).toBeVisible(); @@ -58,7 +69,7 @@ test.describe("Remove Background tool", () => { test("passport checkbox defaults ON for people, OFF for other subjects", async ({ loggedInPage: page, }) => { - await page.goto("/remove-background"); + await skipIfFeatureNotInstalled(page); const passportCheckbox = page.locator("input[type='checkbox']").first(); await expect(passportCheckbox).toBeChecked(); @@ -72,7 +83,7 @@ test.describe("Remove Background tool", () => { }); test("background type controls show/hide sub-options", async ({ loggedInPage: page }) => { - await page.goto("/remove-background"); + await skipIfFeatureNotInstalled(page); await page.getByRole("button", { name: "Color" }).click(); await expect(page.locator("input[type='color']").first()).toBeVisible(); @@ -87,7 +98,7 @@ test.describe("Remove Background tool", () => { }); test("effects section expands with blur and shadow controls", async ({ loggedInPage: page }) => { - await page.goto("/remove-background"); + await skipIfFeatureNotInstalled(page); await page.getByText("Effects").click(); await expect(page.getByText("Blur Background")).toBeVisible(); @@ -101,7 +112,7 @@ test.describe("Remove Background tool", () => { }); test("JPG portrait - transparent background removal", async ({ loggedInPage: page }) => { - await page.goto("/remove-background"); + await skipIfFeatureNotInstalled(page); await uploadFile(page, fixturePath("test-portrait.jpg")); await removeBgAndWait(page); @@ -109,7 +120,7 @@ test.describe("Remove Background tool", () => { }); test("Ultra quality visible for People, hidden for Products", async ({ loggedInPage: page }) => { - await page.goto("/remove-background"); + await skipIfFeatureNotInstalled(page); // People is default - Ultra should be visible await expect(page.getByRole("button", { name: "Ultra" })).toBeVisible(); @@ -124,7 +135,7 @@ test.describe("Remove Background tool", () => { }); test("Ultra quality processes JPG portrait", async ({ loggedInPage: page }) => { - await page.goto("/remove-background"); + await skipIfFeatureNotInstalled(page); await uploadFile(page, fixturePath("test-portrait.jpg")); // Select Ultra quality @@ -136,7 +147,7 @@ test.describe("Remove Background tool", () => { }); test("HEIC portrait - processes without error", async ({ loggedInPage: page }) => { - await page.goto("/remove-background"); + await skipIfFeatureNotInstalled(page); await uploadFile(page, fixturePath("test-portrait.heic")); await removeBgAndWait(page); @@ -146,7 +157,7 @@ test.describe("Remove Background tool", () => { test("two-phase: remove bg then download with color background", async ({ loggedInPage: page, }) => { - await page.goto("/remove-background"); + await skipIfFeatureNotInstalled(page); await uploadFile(page, fixturePath("test-portrait.jpg")); // Phase 1 @@ -165,7 +176,7 @@ test.describe("Remove Background tool", () => { }); test("two-phase: remove bg then download with gradient", async ({ loggedInPage: page }) => { - await page.goto("/remove-background"); + await skipIfFeatureNotInstalled(page); await uploadFile(page, fixturePath("test-portrait.jpg")); await removeBgAndWait(page); @@ -179,7 +190,7 @@ test.describe("Remove Background tool", () => { }); test("two-phase: remove bg then download with blur", async ({ loggedInPage: page }) => { - await page.goto("/remove-background"); + await skipIfFeatureNotInstalled(page); await uploadFile(page, fixturePath("test-portrait.jpg")); await removeBgAndWait(page); @@ -198,7 +209,7 @@ test.describe("Remove Background tool", () => { }); test("two-phase: remove bg then download with shadow", async ({ loggedInPage: page }) => { - await page.goto("/remove-background"); + await skipIfFeatureNotInstalled(page); await uploadFile(page, fixturePath("test-portrait.jpg")); await removeBgAndWait(page); @@ -213,7 +224,7 @@ test.describe("Remove Background tool", () => { }); test("two-phase: remove bg then download with blur + shadow", async ({ loggedInPage: page }) => { - await page.goto("/remove-background"); + await skipIfFeatureNotInstalled(page); await uploadFile(page, fixturePath("test-portrait.jpg")); await removeBgAndWait(page); @@ -229,7 +240,7 @@ test.describe("Remove Background tool", () => { }); test("two-phase: custom bg image + blur shows uploaded bg", async ({ loggedInPage: page }) => { - await page.goto("/remove-background"); + await skipIfFeatureNotInstalled(page); await uploadFile(page, fixturePath("test-portrait.jpg")); await page.getByRole("button", { name: "Image" }).click(); @@ -254,7 +265,7 @@ test.describe("Remove Background tool", () => { test("two-phase: HEIC background image works for preview and download", async ({ loggedInPage: page, }) => { - await page.goto("/remove-background"); + await skipIfFeatureNotInstalled(page); await uploadFile(page, fixturePath("test-portrait.jpg")); await page.getByRole("button", { name: "Image" }).click(); @@ -273,7 +284,7 @@ test.describe("Remove Background tool", () => { }); test("batch - JPG + HEIC processes both", async ({ loggedInPage: page }) => { - await page.goto("/remove-background"); + await skipIfFeatureNotInstalled(page); const files = [fixturePath("test-portrait.jpg"), fixturePath("test-portrait.heic")]; const fileChooserPromise = page.waitForEvent("filechooser"); diff --git a/tests/e2e/smart-crop.spec.ts b/tests/e2e/smart-crop.spec.ts index 03d459f9..148db790 100644 --- a/tests/e2e/smart-crop.spec.ts +++ b/tests/e2e/smart-crop.spec.ts @@ -15,8 +15,17 @@ async function uploadFile(page: import("@playwright/test").Page, filePath: strin } test.describe("Smart Crop tool", () => { - test("page loads with correct UI controls", async ({ loggedInPage: page }) => { + async function skipIfFeatureNotInstalled(page: import("@playwright/test").Page) { await page.goto("/smart-crop"); + try { + await page.getByTestId("smart-crop-submit").waitFor({ state: "visible", timeout: 15_000 }); + } catch { + test.skip(true, "face-detection feature bundle not installed"); + } + } + + test("page loads with correct UI controls", async ({ loggedInPage: page }) => { + await skipIfFeatureNotInstalled(page); // Mode tabs await expect(page.getByRole("button", { name: "Subject Focus" })).toBeVisible(); @@ -28,20 +37,20 @@ test.describe("Smart Crop tool", () => { }); test("submit button disabled without file", async ({ loggedInPage: page }) => { - await page.goto("/smart-crop"); + await skipIfFeatureNotInstalled(page); await expect(page.getByTestId("smart-crop-submit")).toBeDisabled(); }); test("submit button enables after file upload", async ({ loggedInPage: page }) => { - await page.goto("/smart-crop"); + await skipIfFeatureNotInstalled(page); await uploadFile(page, fixturePath("test-200x150.png")); await expect(page.getByTestId("smart-crop-submit")).toBeEnabled(); }); test("subject mode shows strategy and padding controls", async ({ loggedInPage: page }) => { - await page.goto("/smart-crop"); + await skipIfFeatureNotInstalled(page); // Subject Focus is default await expect(page.getByText("Detection Strategy")).toBeVisible(); @@ -53,7 +62,7 @@ test.describe("Smart Crop tool", () => { }); test("face mode shows framing and sensitivity controls", async ({ loggedInPage: page }) => { - await page.goto("/smart-crop"); + await skipIfFeatureNotInstalled(page); await page.getByRole("button", { name: "Face Focus" }).click(); @@ -63,7 +72,7 @@ test.describe("Smart Crop tool", () => { }); test("trim mode shows tolerance controls", async ({ loggedInPage: page }) => { - await page.goto("/smart-crop"); + await skipIfFeatureNotInstalled(page); await page.getByRole("button", { name: "Auto Trim" }).click(); @@ -72,7 +81,7 @@ test.describe("Smart Crop tool", () => { }); test("aspect ratio presets update dimensions", async ({ loggedInPage: page }) => { - await page.goto("/smart-crop"); + await skipIfFeatureNotInstalled(page); // Click 16:9 preset await page.getByRole("button", { name: "16:9" }).click(); @@ -91,7 +100,7 @@ test.describe("Smart Crop tool", () => { }); test("JPG - subject focus crops and shows result", async ({ loggedInPage: page }) => { - await page.goto("/smart-crop"); + await skipIfFeatureNotInstalled(page); await uploadFile(page, fixturePath("test-100x100.jpg")); // Use default subject mode with attention strategy @@ -107,7 +116,7 @@ test.describe("Smart Crop tool", () => { }); test("HEIC input processes without error", async ({ loggedInPage: page }) => { - await page.goto("/smart-crop"); + await skipIfFeatureNotInstalled(page); await uploadFile(page, fixturePath("test-200x150.heic")); await page.getByTestId("smart-crop-submit").click(); diff --git a/tests/e2e/tools-all.spec.ts b/tests/e2e/tools-all.spec.ts index 7f54af11..b937108a 100644 --- a/tests/e2e/tools-all.spec.ts +++ b/tests/e2e/tools-all.spec.ts @@ -45,6 +45,16 @@ const TOOLS_WITH_DROPZONE = [ const TOOLS_WITHOUT_DROPZONE = [{ id: "qr-generate", name: "QR Code" }]; +const AI_TOOL_IDS = new Set([ + "remove-background", + "upscale", + "erase-object", + "ocr", + "blur-faces", + "smart-crop", + "noise-removal", +]); + test.describe("All tool pages render", () => { for (const tool of TOOLS_WITH_DROPZONE) { test(`${tool.name} (/${tool.id}) loads with dropzone`, async ({ loggedInPage: page }) => { @@ -53,6 +63,17 @@ test.describe("All tool pages render", () => { // Tool name should be visible await expect(page.getByText(tool.name, { exact: false }).first()).toBeVisible(); + // AI tools may show install prompt instead of dropzone when feature is not installed + if (AI_TOOL_IDS.has(tool.id)) { + const uploadVisible = await page.getByText("Upload from computer").isVisible(); + if (!uploadVisible) { + await expect( + page.getByText(/additional download|Feature Not Enabled/i).first(), + ).toBeVisible(); + return; + } + } + // Should show dropzone (some tools like collage use custom upload text) const uploadText = (tool as any).customDropzone ? page.getByText(/upload/i).first() diff --git a/tests/e2e/visual-regression.spec.ts b/tests/e2e/visual-regression.spec.ts index e30c5181..d162d00f 100644 --- a/tests/e2e/visual-regression.spec.ts +++ b/tests/e2e/visual-regression.spec.ts @@ -1,15 +1,10 @@ import { expect, test, uploadTestImage } from "./helpers"; -// --------------------------------------------------------------------------- -// Visual regression tests: capture screenshots at different viewport sizes -// and compare against stored baselines. On the first run, Playwright will -// generate the reference snapshots. Subsequent runs will diff against them. -// -// To update baselines after intentional UI changes: -// npx playwright test visual-regression --update-snapshots -// --------------------------------------------------------------------------- +const isDocker = process.env.CI === "true" || process.env.DOCKER === "true"; test.describe("Visual regression: Home page", () => { + test.skip(!isDocker, "Visual regression baselines are Docker-specific"); + test("home page layout - desktop", async ({ loggedInPage: page }) => { await page.setViewportSize({ width: 1280, height: 720 }); await page.goto("/"); @@ -49,6 +44,8 @@ test.describe("Visual regression: Home page", () => { }); test.describe("Visual regression: Login page", () => { + test.skip(!isDocker, "Visual regression baselines are Docker-specific"); + test("login page layout - desktop", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 720 }); await page.goto("/login"); @@ -75,6 +72,8 @@ test.describe("Visual regression: Login page", () => { }); test.describe("Visual regression: Tool pages", () => { + test.skip(!isDocker, "Visual regression baselines are Docker-specific"); + test("resize tool - desktop (empty state)", async ({ loggedInPage: page }) => { await page.setViewportSize({ width: 1280, height: 720 }); await page.goto("/resize"); @@ -139,6 +138,8 @@ test.describe("Visual regression: Tool pages", () => { }); test.describe("Visual regression: Fullscreen grid", () => { + test.skip(!isDocker, "Visual regression baselines are Docker-specific"); + test("fullscreen grid - desktop", async ({ loggedInPage: page }) => { await page.setViewportSize({ width: 1280, height: 720 }); await page.goto("/fullscreen"); @@ -177,6 +178,8 @@ test.describe("Visual regression: Fullscreen grid", () => { }); test.describe("Visual regression: Sidebar", () => { + test.skip(!isDocker, "Visual regression baselines are Docker-specific"); + test("sidebar collapsed vs expanded appearance - desktop", async ({ loggedInPage: page }) => { await page.setViewportSize({ width: 1280, height: 720 }); await page.goto("/");