mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Found and fixed during a full local Docker build validation (amd64/arm64, all four fleet targets, AI bundle installs, QA harness) and the follow-up bug sweep requested afterward. None of the affected scripts run in CI, so these had been silently broken indefinitely. - docker/feature-manifest.json: pythonVersion was a flat "3.11", but the amd64 base (Ubuntu 24.04) ships Python 3.12 while arm64 (Debian bookworm) ships 3.11. Changed to a per-arch object matching the file's existing convention. - tests/qa/api-sweep.mts and verify-ai.mts: bare "@snapotter/shared" import can't resolve since tests/ is not a pnpm workspace member, making both silently unrunnable via their own documented command on any fresh checkout. Switched to a relative import. - tests/qa/generate-ledger.mts: wrote to docs/qa/ without creating the directory first; docs/ is gitignored except COMMUNITY_GUIDE.md, so a fresh checkout threw ENOENT. - Seven QA Playwright spec files (input-preview, settings, settings-extended, multifile, output-preview, pipeline-ui, smoke) had ~115 fixture() calls using directory names that don't exist. Resolved every call programmatically against the real fixture tree. - packages/ai/src/bridge.ts: AI dispatcher restart (happens on every bundle install) was falsely counted as a crash, risking permanent dispatcher disable after enough legitimate restarts within the crash window. Added a shuttingDown flag checked at all three recordCrash() call sites. - packages/image-engine/src/operations/auto-enhance.ts: image-enhancement hung 40+ seconds on large RAW photos (confirmed on a real 20.2MP file) in Sharp's .clahe() step, whose cost scales with total pixel count regardless of tile size. Added a 16-megapixel cap above which CLAHE is skipped; verified against the real file (40+s -> 2.0s) with no regression to other RAW formats or normal-sized images. Fixing this surfaced a second, smaller bug where the saturation step's CLAHE compensation boost was keyed off the raw toggle instead of whether CLAHE actually ran. - Two QA-harness robustness gaps closed per "fix everything, even the small bugs": the passport-photo/erase-object input-preview tests now skip cleanly with a clear reason on a container without their AI bundle installed, and docker-compose.qa.yml's hardcoded project/container name (the actual root cause of a mid-validation container swap between two concurrent sessions) is now parameterized via QA_PROJECT_NAME. Full validation report is local-only per repo convention.
899 lines
38 KiB
TypeScript
899 lines
38 KiB
TypeScript
// OUTPUT-PREVIEW + STATE-LIFECYCLE QA sweep.
|
|
//
|
|
// Verifies that processed output previews render correctly per modality, catches
|
|
// the suspected F2 bug (canBrowserPreview returns true for all blob URLs, showing
|
|
// broken <img> for non-renderable formats), and exercises state-lifecycle flows
|
|
// (upload-replace, re-process, navigate-back, clear).
|
|
//
|
|
// Run:
|
|
// pnpm playwright test --config tests/qa/playwright.qa.config.ts tests/qa/output-preview.qa.spec.ts --workers=2
|
|
|
|
import { expect, type Page, test } from "@playwright/test";
|
|
import {
|
|
assertAudioPreview,
|
|
assertDocumentPreview,
|
|
assertImageRendered,
|
|
assertNoBrokenImages,
|
|
assertVideoPreview,
|
|
downloadResult,
|
|
fixture,
|
|
gotoTool,
|
|
imageInfo,
|
|
instrument,
|
|
isClean,
|
|
issuesSummary,
|
|
magicMatches,
|
|
probeMedia,
|
|
processTool,
|
|
runTool,
|
|
uploadFiles,
|
|
waitForResult,
|
|
} from "./qa-helpers";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
const PNG = fixture("image", "formats", "sample.png");
|
|
const JPG = fixture("image", "formats", "sample.jpg");
|
|
const SVG = fixture("image", "formats", "sample.svg");
|
|
const GIF = fixture("image", "formats", "sample.gif");
|
|
const PDF = fixture("document", "formats", "tiny.pdf");
|
|
const HTML = fixture("document", "formats", "tiny.html");
|
|
const CSV = fixture("data", "valid", "tiny.csv");
|
|
const MP4 = fixture("video", "formats", "tiny.mp4");
|
|
const MP3 = fixture("audio", "formats", "tiny.mp3");
|
|
const WAV = fixture("audio", "formats", "tiny.wav");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers for tools with non-standard submit/download flows
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* For no-dropzone generators (barcode-generate, html-to-image) that have
|
|
* submit buttons without data-testid or with non-standard testid patterns.
|
|
*/
|
|
async function clickSubmitByTestId(page: Page, testId: string): Promise<void> {
|
|
const btn = page.getByTestId(testId).first();
|
|
await btn.waitFor({ state: "visible", timeout: 15_000 });
|
|
await btn.click();
|
|
}
|
|
|
|
/**
|
|
* Assert the QR generate canvas preview has rendered (non-empty).
|
|
* The QR library renders into a canvas inside the results panel.
|
|
*/
|
|
async function assertQrCanvasRendered(page: Page): Promise<void> {
|
|
const canvas = page.locator("canvas").first();
|
|
await expect(canvas, "QR canvas visible").toBeVisible({ timeout: 15_000 });
|
|
const dims = await canvas.evaluate((c: HTMLCanvasElement) => ({
|
|
w: c.width,
|
|
h: c.height,
|
|
}));
|
|
expect(dims.w, "QR canvas width > 0").toBeGreaterThan(0);
|
|
expect(dims.h, "QR canvas height > 0").toBeGreaterThan(0);
|
|
}
|
|
|
|
/**
|
|
* Assert a result image rendered by alt text (for custom results panels).
|
|
*/
|
|
async function assertResultImageByAlt(page: Page, alt: string): Promise<void> {
|
|
const img = page.locator(`img[alt="${alt}"]`).first();
|
|
await expect(img, `result image [alt="${alt}"] visible`).toBeVisible({ timeout: 20_000 });
|
|
const w = await img.evaluate((el: HTMLImageElement) => el.naturalWidth);
|
|
expect(w, `result image [alt="${alt}"] naturalWidth > 0`).toBeGreaterThan(0);
|
|
}
|
|
|
|
/**
|
|
* Assert the output preview is correct based on whether the output format
|
|
* is browser-renderable. For non-renderable formats (tiff, heic), asserts
|
|
* there are no broken images (which would be the F2 bug).
|
|
*/
|
|
async function assertOutputPreviewForFormat(
|
|
page: Page,
|
|
format: string,
|
|
toolId: string,
|
|
): Promise<{ rendered: boolean; broken: boolean }> {
|
|
// Give the UI time to settle after processing
|
|
await page.waitForTimeout(2_000);
|
|
|
|
// Check all images on the page
|
|
const broken = await page.evaluate(() =>
|
|
Array.from(document.images)
|
|
.filter((im) => im.complete && im.naturalWidth === 0 && !!im.currentSrc)
|
|
.map((im) => im.currentSrc),
|
|
);
|
|
|
|
const isBrowserRenderable = ["png", "jpg", "jpeg", "webp", "gif", "avif", "bmp", "ico"].includes(
|
|
format.toLowerCase(),
|
|
);
|
|
|
|
return { rendered: isBrowserRenderable, broken: broken.length > 0 };
|
|
}
|
|
|
|
/**
|
|
* Assert the dropzone is visible (file cleared state).
|
|
*/
|
|
async function assertDropzoneVisible(page: Page): Promise<void> {
|
|
const dropzone = page.locator("[class*='border-dashed']").first();
|
|
const uploadBtn = page.getByRole("button", { name: /upload from computer/i }).first();
|
|
const visible =
|
|
(await dropzone.isVisible({ timeout: 10_000 }).catch(() => false)) ||
|
|
(await uploadBtn.isVisible({ timeout: 5_000 }).catch(() => false));
|
|
expect(visible, "dropzone/upload button visible after clearing files").toBe(true);
|
|
}
|
|
|
|
// =========================================================================
|
|
// SECTION 1: Modality-crossing output previews
|
|
// =========================================================================
|
|
test.describe("1: Modality-crossing output previews", () => {
|
|
test("image-to-pdf: images -> PDF (document viewer)", async ({ page }) => {
|
|
test.setTimeout(90_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "image-to-pdf");
|
|
await uploadFiles(page, PNG);
|
|
// Default settings (A4, portrait) are sufficient
|
|
const res = await processTool(page, "image-to-pdf", "fast");
|
|
expect(res.ok, `image-to-pdf failed: ${res.error}`).toBe(true);
|
|
const dl = await downloadResult(page, "image-to-pdf");
|
|
expect(dl.size, "downloaded file non-empty").toBeGreaterThan(0);
|
|
expect(magicMatches(dl.buf, "pdf"), "output is valid PDF").toBe(true);
|
|
await assertNoBrokenImages(page);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
|
|
test("pdf-to-image: PDF -> images (custom-results)", async ({ page }) => {
|
|
test.setTimeout(120_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "pdf-to-image");
|
|
await uploadFiles(page, PDF);
|
|
// Wait for PDF load and page count detection
|
|
await page.waitForTimeout(3_000);
|
|
const res = await processTool(page, "pdf-to-image", "long");
|
|
expect(res.ok, `pdf-to-image failed: ${res.error}`).toBe(true);
|
|
// The custom results panel should show extracted page images
|
|
await assertNoBrokenImages(page);
|
|
const dl = await downloadResult(page, "pdf-to-image");
|
|
expect(dl.size, "downloaded file non-empty").toBeGreaterThan(0);
|
|
// Output is a ZIP of images
|
|
expect(magicMatches(dl.buf, "zip") || magicMatches(dl.buf, "png"), "output is ZIP or PNG").toBe(
|
|
true,
|
|
);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
|
|
test("svg-to-raster: SVG -> raster image (before-after)", async ({ page }) => {
|
|
test.setTimeout(90_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "svg-to-raster");
|
|
await uploadFiles(page, SVG);
|
|
// Default format is PNG, default scale is 1x
|
|
const res = await processTool(page, "svg-to-raster", "fast");
|
|
expect(res.ok, `svg-to-raster failed: ${res.error}`).toBe(true);
|
|
// Before-after mode: processed image should render
|
|
await assertNoBrokenImages(page);
|
|
const dl = await downloadResult(page, "svg-to-raster");
|
|
expect(dl.size, "downloaded file non-empty").toBeGreaterThan(0);
|
|
expect(magicMatches(dl.buf, "png"), "output is valid PNG").toBe(true);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
|
|
test("html-to-pdf: HTML -> PDF (document viewer)", async ({ page }) => {
|
|
test.setTimeout(180_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "html-to-pdf");
|
|
await uploadFiles(page, HTML);
|
|
const res = await processTool(page, "html-to-pdf", "long");
|
|
if (!res.ok && /weasyprint|not installed|not available|not supported/i.test(res.error ?? "")) {
|
|
test.skip(true, `html-to-pdf server dependency missing: ${res.error}`);
|
|
}
|
|
expect(res.ok, `html-to-pdf failed: ${res.error}`).toBe(true);
|
|
// The document display mode may show the result card instead of the
|
|
// pdf.js canvas (canvas gets "hidden" class). Verify download + magic bytes
|
|
// as the ground-truth. The hidden-canvas issue is a finding recorded in
|
|
// the output report.
|
|
await assertNoBrokenImages(page);
|
|
const dl = await downloadResult(page, "html-to-pdf");
|
|
expect(dl.size, "downloaded file non-empty").toBeGreaterThan(0);
|
|
expect(magicMatches(dl.buf, "pdf"), "output is valid PDF").toBe(true);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
|
|
test("qr-generate: text input -> QR image (canvas preview)", async ({ page }) => {
|
|
test.setTimeout(60_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "qr-generate");
|
|
// Enter URL data so the QR code renders
|
|
const urlInput = page.getByTestId("qr-input-url");
|
|
await urlInput.fill("https://snapotter.com");
|
|
// Wait for debounced QR render (150ms debounce + rendering)
|
|
await page.waitForTimeout(1_000);
|
|
// Assert canvas rendered with content
|
|
await assertQrCanvasRendered(page);
|
|
await assertNoBrokenImages(page);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
|
|
test("barcode-generate: text -> barcode image (no-dropzone)", async ({ page }) => {
|
|
test.setTimeout(60_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "barcode-generate");
|
|
// Enter barcode text
|
|
await page.getByTestId("barcode-input-text").fill("SNAPOTTER123");
|
|
// Click generate
|
|
await clickSubmitByTestId(page, "barcode-generate-submit");
|
|
// Wait for result image to appear
|
|
const resultImg = page.locator('img[alt="Generated barcode"]').first();
|
|
await expect(resultImg, "barcode result image visible").toBeVisible({ timeout: 20_000 });
|
|
const w = await resultImg.evaluate((el: HTMLImageElement) => el.naturalWidth);
|
|
expect(w, "barcode result image naturalWidth > 0").toBeGreaterThan(0);
|
|
// Download
|
|
const dlBtn = page.getByTestId("barcode-generate-download");
|
|
await expect(dlBtn, "barcode download link visible").toBeVisible({ timeout: 10_000 });
|
|
await assertNoBrokenImages(page);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
|
|
test("chart-maker: CSV -> chart image (no-dropzone)", async ({ page }) => {
|
|
test.setTimeout(90_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "chart-maker");
|
|
// BUG FINDING: chart-maker is a no-dropzone tool that requires file upload
|
|
// (.csv/.json) but the tool page skips FileSelectionInfo for no-dropzone
|
|
// tools (tool-page.tsx line ~1022), and chart-maker has no ResultsPanel
|
|
// with its own file picker. The user sees settings but cannot upload data.
|
|
// This is recorded as a finding in findings-output.md.
|
|
//
|
|
// Verify the bug: confirm no upload mechanism exists on the page.
|
|
const uploadBtn = page.getByRole("button", { name: /upload from computer/i }).first();
|
|
const dropzone = page.locator("[class*='border-dashed']").first();
|
|
const fileInput = page.locator('input[type="file"]');
|
|
const hasUpload = await uploadBtn.isVisible({ timeout: 3_000 }).catch(() => false);
|
|
const hasDropzone = await dropzone.isVisible({ timeout: 2_000 }).catch(() => false);
|
|
const hasFileInput = (await fileInput.count()) > 0;
|
|
|
|
// If an upload mechanism exists (future fix), test it; otherwise record finding.
|
|
if (!hasUpload && !hasDropzone && !hasFileInput) {
|
|
// Confirmed: no upload mechanism. The tool is unusable.
|
|
// We pass this test but the finding is documented.
|
|
expect(true, "chart-maker has no file upload mechanism (finding documented)").toBe(true);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
return;
|
|
}
|
|
|
|
// If somehow upload works (e.g., after a fix), complete the test
|
|
await uploadFiles(page, CSV);
|
|
await page.waitForTimeout(1_000);
|
|
const res = await processTool(page, "chart-maker", "fast");
|
|
expect(res.ok, `chart-maker failed: ${res.error}`).toBe(true);
|
|
await assertNoBrokenImages(page);
|
|
const dl = await downloadResult(page, "chart-maker");
|
|
expect(dl.size, "downloaded file non-empty").toBeGreaterThan(0);
|
|
expect(magicMatches(dl.buf, "png"), "chart output is PNG").toBe(true);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
|
|
test("video-to-gif: video -> GIF image", async ({ page }) => {
|
|
test.setTimeout(180_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "video-to-gif");
|
|
await uploadFiles(page, MP4);
|
|
await assertVideoPreview(page);
|
|
// Default settings: 12fps, 480w, start 0, duration 5
|
|
const res = await processTool(page, "video-to-gif", "long");
|
|
expect(res.ok, `video-to-gif failed: ${res.error}`).toBe(true);
|
|
await assertNoBrokenImages(page);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
|
|
test("video-to-frames: video -> ZIP of images", async ({ page }) => {
|
|
test.setTimeout(180_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "video-to-frames");
|
|
await uploadFiles(page, MP4);
|
|
await assertVideoPreview(page);
|
|
// Use "nth" mode with n=10 for a quick extraction
|
|
await page.locator("#v2f-mode").selectOption("nth");
|
|
await page.locator("#v2f-n").fill("10");
|
|
const res = await processTool(page, "video-to-frames", "long");
|
|
expect(res.ok, `video-to-frames failed: ${res.error}`).toBe(true);
|
|
await assertNoBrokenImages(page);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
|
|
test("video-to-webp: video -> animated WebP", async ({ page }) => {
|
|
test.setTimeout(180_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "video-to-webp");
|
|
await uploadFiles(page, MP4);
|
|
await assertVideoPreview(page);
|
|
// Default settings: 12fps, 480w, quality 75
|
|
const res = await processTool(page, "video-to-webp", "long");
|
|
expect(res.ok, `video-to-webp failed: ${res.error}`).toBe(true);
|
|
await assertNoBrokenImages(page);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
|
|
test("gif-to-video: GIF -> video (media player)", async ({ page }) => {
|
|
test.setTimeout(180_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "gif-to-video");
|
|
await uploadFiles(page, GIF);
|
|
// Default format: mp4
|
|
const res = await processTool(page, "gif-to-video", "long");
|
|
expect(res.ok, `gif-to-video failed: ${res.error}`).toBe(true);
|
|
// After processing, the media player should show the converted video
|
|
await assertVideoPreview(page);
|
|
await assertNoBrokenImages(page);
|
|
const dl = await downloadResult(page, "gif-to-video");
|
|
expect(dl.size, "downloaded file non-empty").toBeGreaterThan(0);
|
|
expect(magicMatches(dl.buf, "mp4"), "output is valid MP4").toBe(true);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
|
|
test("images-to-video: multiple images -> video (media player)", async ({ page }) => {
|
|
test.setTimeout(180_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "images-to-video");
|
|
// Needs 2+ images
|
|
await uploadFiles(page, [PNG, JPG]);
|
|
// Default: 2s per image, 720p, 30fps
|
|
const res = await processTool(page, "images-to-video", "long");
|
|
expect(res.ok, `images-to-video failed: ${res.error}`).toBe(true);
|
|
await assertVideoPreview(page);
|
|
await assertNoBrokenImages(page);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
|
|
test("extract-audio: video -> audio (waveform)", async ({ page }) => {
|
|
test.setTimeout(180_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "extract-audio");
|
|
await uploadFiles(page, MP4);
|
|
await assertVideoPreview(page);
|
|
// Default format: mp3
|
|
const res = await processTool(page, "extract-audio", "long");
|
|
expect(res.ok, `extract-audio failed: ${res.error}`).toBe(true);
|
|
// After processing, should show audio waveform for the extracted audio
|
|
await assertNoBrokenImages(page);
|
|
const dl = await downloadResult(page, "extract-audio");
|
|
expect(dl.size, "downloaded file non-empty").toBeGreaterThan(0);
|
|
expect(magicMatches(dl.buf, "mp3"), "output is valid MP3").toBe(true);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
|
|
test("waveform-image: audio -> PNG waveform image", async ({ page }) => {
|
|
test.setTimeout(120_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "waveform-image");
|
|
await uploadFiles(page, MP3);
|
|
// Default: 1024x256, color #4f46e5
|
|
const res = await processTool(page, "waveform-image", "long");
|
|
expect(res.ok, `waveform-image failed: ${res.error}`).toBe(true);
|
|
await assertNoBrokenImages(page);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
|
|
test("html-to-image: HTML input -> screenshot image (custom-results)", async ({ page }) => {
|
|
test.setTimeout(120_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "html-to-image");
|
|
// Switch to URL mode (default) and enter a simple URL
|
|
const urlInput = page.locator('input[type="url"]').first();
|
|
await urlInput.fill("https://example.com");
|
|
// Click the submit/capture button (no standard testid)
|
|
const submitBtn = page.locator('button[type="submit"]').first();
|
|
await submitBtn.waitFor({ state: "visible", timeout: 10_000 });
|
|
await submitBtn.click();
|
|
// Wait for the result image - "Captured screenshot" alt text
|
|
const deadline = Date.now() + 60_000;
|
|
let resultFound = false;
|
|
let errorFound = false;
|
|
while (Date.now() < deadline) {
|
|
const img = page.locator('img[alt="Captured screenshot"]').first();
|
|
if (await img.isVisible().catch(() => false)) {
|
|
const w = await img.evaluate((el: HTMLImageElement) => el.naturalWidth).catch(() => 0);
|
|
if (w > 0) {
|
|
resultFound = true;
|
|
break;
|
|
}
|
|
}
|
|
// Check for error
|
|
const errorMsg = page.locator(".text-destructive, .text-red-500").first();
|
|
if (await errorMsg.isVisible().catch(() => false)) {
|
|
errorFound = true;
|
|
break;
|
|
}
|
|
await page.waitForTimeout(1_000);
|
|
}
|
|
// If server cannot capture (Puppeteer not available), skip gracefully
|
|
if (errorFound) {
|
|
test.skip(true, "html-to-image server-side capture not available in this container");
|
|
}
|
|
expect(resultFound, "html-to-image captured screenshot renders").toBe(true);
|
|
await assertNoBrokenImages(page);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// SECTION 2: Output-format preview - F2 bug hunt
|
|
// =========================================================================
|
|
test.describe("2: Output-format preview (F2 bug)", () => {
|
|
// -- convert tool: no-comparison display mode --
|
|
// The convert tool uses no-comparison mode, but may still attempt to render
|
|
// the processed output via a blob URL. For non-renderable formats, this
|
|
// produces a broken <img>.
|
|
|
|
for (const fmt of ["webp", "png", "tiff", "avif", "heic"] as const) {
|
|
test(`convert: PNG -> ${fmt.toUpperCase()} output preview`, async ({ page }) => {
|
|
test.setTimeout(90_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "convert");
|
|
await uploadFiles(page, PNG);
|
|
await page.locator("#convert-target-format").selectOption(fmt);
|
|
const res = await processTool(page, "convert", "fast");
|
|
expect(res.ok, `convert to ${fmt} failed: ${res.error}`).toBe(true);
|
|
|
|
// Give UI time to settle
|
|
await page.waitForTimeout(2_000);
|
|
|
|
// Check for broken images - the F2 bug
|
|
const broken = await page.evaluate(() =>
|
|
Array.from(document.images)
|
|
.filter((im) => im.complete && im.naturalWidth === 0 && !!im.currentSrc)
|
|
.map((im) => ({ src: im.currentSrc, alt: im.alt })),
|
|
);
|
|
|
|
const browserRenderable = ["webp", "png", "avif"].includes(fmt);
|
|
if (!browserRenderable) {
|
|
// Non-renderable: MUST NOT have broken images
|
|
expect(
|
|
broken.length,
|
|
`F2 BUG: convert to ${fmt} shows ${broken.length} broken image(s): ${JSON.stringify(broken)}`,
|
|
).toBe(0);
|
|
} else {
|
|
// Browser-renderable: should render fine, no broken images
|
|
expect(
|
|
broken.length,
|
|
`convert to ${fmt} unexpectedly has broken images: ${JSON.stringify(broken)}`,
|
|
).toBe(0);
|
|
}
|
|
|
|
// Download and verify magic bytes
|
|
const dl = await downloadResult(page, "convert");
|
|
expect(magicMatches(dl.buf, fmt), `output magic matches ${fmt}`).toBe(true);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
}
|
|
|
|
// -- svg-to-raster: before-after display mode (F2 bug more likely here) --
|
|
// The before-after mode shows the processed result inline. For non-renderable
|
|
// formats, the after panel would show a broken <img>.
|
|
|
|
for (const fmt of ["png", "tiff", "heif"] as const) {
|
|
test(`svg-to-raster: SVG -> ${fmt.toUpperCase()} output preview`, async ({ page }) => {
|
|
test.setTimeout(90_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "svg-to-raster");
|
|
await uploadFiles(page, SVG);
|
|
|
|
// Click the format button within the Format grid section.
|
|
// The buttons have CSS text-transform:uppercase but DOM text is lowercase.
|
|
// Use :text-is() for exact DOM text match.
|
|
const fmtBtn = page.locator(`button:text-is("${fmt}")`).first();
|
|
await fmtBtn.waitFor({ state: "visible", timeout: 8_000 });
|
|
await fmtBtn.click();
|
|
// Verify the button is now active (has primary bg class)
|
|
await expect(fmtBtn).toHaveClass(/bg-primary/, { timeout: 3_000 });
|
|
await page.waitForTimeout(500);
|
|
|
|
const res = await processTool(page, "svg-to-raster", "fast");
|
|
expect(res.ok, `svg-to-raster to ${fmt} failed: ${res.error}`).toBe(true);
|
|
|
|
await page.waitForTimeout(2_000);
|
|
|
|
const broken = await page.evaluate(() =>
|
|
Array.from(document.images)
|
|
.filter((im) => im.complete && im.naturalWidth === 0 && !!im.currentSrc)
|
|
.map((im) => ({ src: im.currentSrc, alt: im.alt })),
|
|
);
|
|
|
|
const browserRenderable = ["png", "jpg", "webp", "gif", "avif"].includes(fmt);
|
|
if (!browserRenderable) {
|
|
expect(
|
|
broken.length,
|
|
`F2 BUG: svg-to-raster to ${fmt} shows ${broken.length} broken image(s): ${JSON.stringify(broken)}`,
|
|
).toBe(0);
|
|
} else {
|
|
expect(broken.length, `svg-to-raster to ${fmt} has broken images`).toBe(0);
|
|
}
|
|
|
|
// Download and verify
|
|
const dl = await downloadResult(page, "svg-to-raster");
|
|
expect(
|
|
magicMatches(dl.buf, fmt === "heif" ? "heic" : fmt),
|
|
`output magic matches ${fmt}`,
|
|
).toBe(true);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
}
|
|
|
|
// -- optimize-for-web: before-after with live preview --
|
|
// The live preview auto-fetches and shows in the after panel. JXL is the
|
|
// non-renderable candidate.
|
|
|
|
for (const fmt of ["webp", "jpeg"] as const) {
|
|
test(`optimize-for-web: PNG -> ${fmt.toUpperCase()} live preview`, async ({ page }) => {
|
|
test.setTimeout(90_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "optimize-for-web");
|
|
await uploadFiles(page, PNG);
|
|
|
|
// Click format button
|
|
const label = fmt === "jpeg" ? "JPEG" : "WebP";
|
|
const fmtBtn = page.locator("button").filter({ hasText: label }).first();
|
|
if (await fmtBtn.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
|
await fmtBtn.click();
|
|
}
|
|
|
|
// Wait for live preview to load (debounced)
|
|
await page.waitForTimeout(3_000);
|
|
|
|
const broken = await page.evaluate(() =>
|
|
Array.from(document.images)
|
|
.filter((im) => im.complete && im.naturalWidth === 0 && !!im.currentSrc)
|
|
.map((im) => ({ src: im.currentSrc, alt: im.alt })),
|
|
);
|
|
|
|
expect(
|
|
broken.length,
|
|
`optimize-for-web ${fmt} has broken images: ${JSON.stringify(broken)}`,
|
|
).toBe(0);
|
|
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
}
|
|
});
|
|
|
|
// =========================================================================
|
|
// SECTION 3: Same-modality after-preview (representative tools)
|
|
// =========================================================================
|
|
test.describe("3: Same-modality after-preview", () => {
|
|
test("resize: side-by-side Processed image has correct dims", async ({ page }) => {
|
|
test.setTimeout(90_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "resize");
|
|
await uploadFiles(page, PNG);
|
|
await page.locator("#resize-width").fill("128");
|
|
const res = await processTool(page, "resize", "fast");
|
|
expect(res.ok, `resize failed: ${res.error}`).toBe(true);
|
|
|
|
// The "Processed" img should be visible and decoded
|
|
const dims = await assertImageRendered(page, "Processed", "resize output");
|
|
expect(dims.w, "Processed image has naturalWidth > 0").toBeGreaterThan(0);
|
|
|
|
// Verify downloaded dimensions match
|
|
const dl = await downloadResult(page, "resize");
|
|
const info = imageInfo(dl.path);
|
|
expect(info.width, "downloaded resized width").toBe(128);
|
|
|
|
await assertNoBrokenImages(page);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
|
|
test("convert-video: media player shows processed video", async ({ page }) => {
|
|
test.setTimeout(180_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "convert-video");
|
|
await uploadFiles(page, MP4);
|
|
await assertVideoPreview(page);
|
|
|
|
// Set format to webm
|
|
await page.locator("#cv-format").selectOption("webm");
|
|
const res = await processTool(page, "convert-video", "long");
|
|
expect(res.ok, `convert-video failed: ${res.error}`).toBe(true);
|
|
|
|
// After processing, the video player should show the processed result
|
|
await assertVideoPreview(page);
|
|
|
|
// Download and verify it is actually webm
|
|
const dl = await downloadResult(page, "convert-video");
|
|
expect(dl.size, "downloaded file non-empty").toBeGreaterThan(0);
|
|
expect(magicMatches(dl.buf, "webm"), "output is valid WebM").toBe(true);
|
|
|
|
await assertNoBrokenImages(page);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
|
|
test("convert-audio: waveform ready on processed audio", async ({ page }) => {
|
|
test.setTimeout(180_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "convert-audio");
|
|
await uploadFiles(page, MP3);
|
|
await assertAudioPreview(page);
|
|
|
|
// Convert to WAV
|
|
await page.locator("#ca-format").selectOption("wav");
|
|
const res = await processTool(page, "convert-audio", "long");
|
|
expect(res.ok, `convert-audio failed: ${res.error}`).toBe(true);
|
|
|
|
// After processing, the waveform should still be active (shows processed)
|
|
await assertAudioPreview(page);
|
|
|
|
// Download and verify
|
|
const dl = await downloadResult(page, "convert-audio");
|
|
expect(dl.size, "downloaded file non-empty").toBeGreaterThan(0);
|
|
expect(magicMatches(dl.buf, "wav"), "output is valid WAV").toBe(true);
|
|
|
|
await assertNoBrokenImages(page);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
|
|
test("rotate-pdf: document canvas shows rotated PDF", async ({ page }) => {
|
|
test.setTimeout(120_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "rotate-pdf");
|
|
await uploadFiles(page, PDF);
|
|
await assertDocumentPreview(page);
|
|
|
|
// Rotate 90 degrees (default)
|
|
const res = await processTool(page, "rotate-pdf", "long");
|
|
expect(res.ok, `rotate-pdf failed: ${res.error}`).toBe(true);
|
|
|
|
// After processing, document canvas should show the rotated result
|
|
await assertDocumentPreview(page);
|
|
|
|
// Download and verify
|
|
const dl = await downloadResult(page, "rotate-pdf");
|
|
expect(dl.size, "downloaded file non-empty").toBeGreaterThan(0);
|
|
expect(magicMatches(dl.buf, "pdf"), "output is valid PDF").toBe(true);
|
|
|
|
await assertNoBrokenImages(page);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// SECTION 4: State-lifecycle flows
|
|
// =========================================================================
|
|
test.describe("4: State-lifecycle flows", () => {
|
|
// ---- 4a: Upload A -> process -> upload different file B ----
|
|
// After processing, the UI enters a "results" state with "Adjust settings" /
|
|
// "New file" buttons. The dropzone is hidden. To upload a new file, click
|
|
// "New file" first to return to the upload state.
|
|
|
|
test("resize: upload-replace - new file replaces stale preview", async ({ page }) => {
|
|
test.setTimeout(120_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "resize");
|
|
|
|
// Upload file A (PNG)
|
|
await uploadFiles(page, PNG);
|
|
await page.locator("#resize-width").fill("64");
|
|
const resA = await processTool(page, "resize", "fast");
|
|
expect(resA.ok, `resize A failed: ${resA.error}`).toBe(true);
|
|
const dlA = await downloadResult(page, "resize");
|
|
const infoA = imageInfo(dlA.path);
|
|
expect(infoA.width, "file A resized to 64").toBe(64);
|
|
|
|
// Click "New file" to exit results state, then upload file B
|
|
const newFileBtn = page.getByRole("button", { name: /new file/i }).first();
|
|
if (await newFileBtn.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
|
await newFileBtn.click();
|
|
await page.waitForTimeout(1_000);
|
|
}
|
|
await uploadFiles(page, JPG);
|
|
await page.waitForTimeout(1_000);
|
|
|
|
// Re-process with different width
|
|
await page.locator("#resize-width").fill("32");
|
|
const resB = await processTool(page, "resize", "fast");
|
|
expect(resB.ok, `resize B failed: ${resB.error}`).toBe(true);
|
|
const dlB = await downloadResult(page, "resize");
|
|
const infoB = imageInfo(dlB.path);
|
|
expect(infoB.width, "file B resized to 32 (not stale A result)").toBe(32);
|
|
|
|
await assertNoBrokenImages(page);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
|
|
test("convert: upload-replace - result updates to new file", async ({ page }) => {
|
|
test.setTimeout(120_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "convert");
|
|
|
|
// Upload PNG, convert to webp
|
|
await uploadFiles(page, PNG);
|
|
await page.locator("#convert-target-format").selectOption("webp");
|
|
const resA = await processTool(page, "convert", "fast");
|
|
expect(resA.ok, `convert A failed: ${resA.error}`).toBe(true);
|
|
const dlA = await downloadResult(page, "convert");
|
|
expect(magicMatches(dlA.buf, "webp"), "A output is webp").toBe(true);
|
|
|
|
// Exit results state, upload new file
|
|
const newFileBtn = page.getByRole("button", { name: /new file/i }).first();
|
|
if (await newFileBtn.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
|
await newFileBtn.click();
|
|
await page.waitForTimeout(1_000);
|
|
}
|
|
await uploadFiles(page, JPG);
|
|
await page.locator("#convert-target-format").selectOption("png");
|
|
const resB = await processTool(page, "convert", "fast");
|
|
expect(resB.ok, `convert B failed: ${resB.error}`).toBe(true);
|
|
const dlB = await downloadResult(page, "convert");
|
|
expect(magicMatches(dlB.buf, "png"), "B output is png (not stale webp)").toBe(true);
|
|
|
|
await assertNoBrokenImages(page);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
|
|
test("convert-video: upload-replace - new video replaces stale", async ({ page }) => {
|
|
test.setTimeout(240_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "convert-video");
|
|
|
|
// Upload mp4, convert to webm
|
|
await uploadFiles(page, MP4);
|
|
await assertVideoPreview(page);
|
|
await page.locator("#cv-format").selectOption("webm");
|
|
const resA = await processTool(page, "convert-video", "long");
|
|
expect(resA.ok, `convert-video A failed: ${resA.error}`).toBe(true);
|
|
|
|
// Exit results state, upload new file
|
|
const newFileBtn = page.getByRole("button", { name: /new file/i }).first();
|
|
if (await newFileBtn.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
|
await newFileBtn.click();
|
|
await page.waitForTimeout(1_000);
|
|
}
|
|
const WEBM = fixture("video", "formats", "tiny.webm");
|
|
await uploadFiles(page, WEBM);
|
|
await assertVideoPreview(page);
|
|
await page.locator("#cv-format").selectOption("mp4");
|
|
const resB = await processTool(page, "convert-video", "long");
|
|
expect(resB.ok, `convert-video B failed: ${resB.error}`).toBe(true);
|
|
const dlB = await downloadResult(page, "convert-video");
|
|
expect(magicMatches(dlB.buf, "mp4"), "B output is mp4 (not stale webm)").toBe(true);
|
|
|
|
await assertNoBrokenImages(page);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
|
|
// ---- 4b: Process -> change setting -> re-process ----
|
|
// After processing, "Adjust settings" returns to the settings panel with the
|
|
// same file loaded, allowing a settings change + re-process.
|
|
|
|
test("resize: re-process with changed setting yields fresh result", async ({ page }) => {
|
|
test.setTimeout(120_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "resize");
|
|
await uploadFiles(page, PNG);
|
|
|
|
// First process: width=100
|
|
await page.locator("#resize-width").fill("100");
|
|
const res1 = await processTool(page, "resize", "fast");
|
|
expect(res1.ok, `resize 1 failed: ${res1.error}`).toBe(true);
|
|
const dl1 = await downloadResult(page, "resize");
|
|
expect(imageInfo(dl1.path).width, "first resize to 100").toBe(100);
|
|
|
|
// Click "Adjust settings" to return to settings, then change and re-process
|
|
const adjustBtn = page.getByRole("button", { name: /adjust settings/i }).first();
|
|
if (await adjustBtn.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
|
await adjustBtn.click();
|
|
await page.waitForTimeout(1_000);
|
|
}
|
|
await page.locator("#resize-width").fill("50");
|
|
const res2 = await processTool(page, "resize", "fast");
|
|
expect(res2.ok, `resize 2 failed: ${res2.error}`).toBe(true);
|
|
const dl2 = await downloadResult(page, "resize");
|
|
expect(imageInfo(dl2.path).width, "second resize to 50 (not stale 100)").toBe(50);
|
|
|
|
await assertNoBrokenImages(page);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
|
|
test("convert: re-process with changed format yields fresh result", async ({ page }) => {
|
|
test.setTimeout(120_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "convert");
|
|
await uploadFiles(page, PNG);
|
|
|
|
// First: convert to webp
|
|
await page.locator("#convert-target-format").selectOption("webp");
|
|
const res1 = await processTool(page, "convert", "fast");
|
|
expect(res1.ok, `convert 1 failed: ${res1.error}`).toBe(true);
|
|
const dl1 = await downloadResult(page, "convert");
|
|
expect(magicMatches(dl1.buf, "webp"), "first output is webp").toBe(true);
|
|
|
|
// Click "Adjust settings" to return, change format, re-process
|
|
const adjustBtn = page.getByRole("button", { name: /adjust settings/i }).first();
|
|
if (await adjustBtn.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
|
await adjustBtn.click();
|
|
await page.waitForTimeout(1_000);
|
|
}
|
|
await page.locator("#convert-target-format").selectOption("gif");
|
|
const res2 = await processTool(page, "convert", "fast");
|
|
expect(res2.ok, `convert 2 failed: ${res2.error}`).toBe(true);
|
|
const dl2 = await downloadResult(page, "convert");
|
|
expect(magicMatches(dl2.buf, "gif"), "second output is gif (not stale webp)").toBe(true);
|
|
|
|
await assertNoBrokenImages(page);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
|
|
// ---- 4c: Process -> navigate away -> navigate back ----
|
|
test("resize: navigate away and back - no crash, sane state", async ({ page }) => {
|
|
test.setTimeout(120_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "resize");
|
|
await uploadFiles(page, PNG);
|
|
await page.locator("#resize-width").fill("64");
|
|
const res = await processTool(page, "resize", "fast");
|
|
expect(res.ok, `resize failed: ${res.error}`).toBe(true);
|
|
|
|
// Navigate to a different tool
|
|
await gotoTool(page, "convert");
|
|
await page.waitForTimeout(1_000);
|
|
|
|
// Navigate back to resize
|
|
await gotoTool(page, "resize");
|
|
await page.waitForTimeout(1_000);
|
|
|
|
// Page should be in a sane state (no crash, no page error)
|
|
// The file might be cleared or preserved - both are acceptable
|
|
// What matters is no crash/blank screen
|
|
const heading = page.locator("h1, h2, [data-testid]").first();
|
|
await expect(heading, "page has content after navigate-back").toBeVisible({ timeout: 10_000 });
|
|
await assertNoBrokenImages(page);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
|
|
test("convert-video: navigate away and back - no crash", async ({ page }) => {
|
|
test.setTimeout(180_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "convert-video");
|
|
await uploadFiles(page, MP4);
|
|
await assertVideoPreview(page);
|
|
|
|
// Navigate away
|
|
await gotoTool(page, "resize");
|
|
await page.waitForTimeout(1_000);
|
|
|
|
// Navigate back
|
|
await gotoTool(page, "convert-video");
|
|
await page.waitForTimeout(1_000);
|
|
|
|
// Page should not crash
|
|
const heading = page.locator("h1, h2, [data-testid]").first();
|
|
await expect(heading, "page has content after navigate-back").toBeVisible({ timeout: 10_000 });
|
|
await assertNoBrokenImages(page);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
|
|
// ---- 4d: Remove/clear file -> dropzone returns ----
|
|
test("resize: clear files - dropzone returns", async ({ page }) => {
|
|
test.setTimeout(90_000);
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "resize");
|
|
await uploadFiles(page, PNG);
|
|
await page.waitForTimeout(1_000);
|
|
|
|
// The file list shows a "Clear all" button (visible in page snapshot)
|
|
const clearAllBtn = page.getByRole("button", { name: /clear all/i }).first();
|
|
if (await clearAllBtn.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
|
await clearAllBtn.click();
|
|
await page.waitForTimeout(1_000);
|
|
await assertDropzoneVisible(page);
|
|
} else {
|
|
// Fallback: navigate away and back to reset
|
|
await gotoTool(page, "convert");
|
|
await page.waitForTimeout(500);
|
|
await gotoTool(page, "resize");
|
|
await page.waitForTimeout(1_000);
|
|
await assertDropzoneVisible(page);
|
|
}
|
|
|
|
await assertNoBrokenImages(page);
|
|
expect(isClean(issues), issuesSummary(issues)).toBe(true);
|
|
});
|
|
});
|