Files
SnapOtter/tests/qa/multifile.qa.spec.ts
T
SnapOtterandGitHub 51022628dc fix(a11y): WCAG AA contrast retune for the Otter Orange palette (#567)
Fixes #557. Vivid fill, ink label: brand #E07832 stays on fills while primary-foreground flips to #1A1814 (5.83:1); new theme-aware ink tokens carry orange, destructive, and success text roles; opacity-modified text purged; landing, demo, and the docs fund button retuned. Guarded by a CSS-parsing unit contrast test, rebuilt axe baselines with zero contrast entries, a new landing axe smoke, and fully regenerated darwin visual baselines.
2026-07-18 12:56:48 +08:00

888 lines
34 KiB
TypeScript

import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { expect, type Page, test } from "@playwright/test";
import {
downloadResult,
fixture,
gotoTool,
imageInfo,
instrument,
isClean,
issuesSummary,
magicMatches,
mediaDuration,
probeMedia,
processTool,
setSecondaryInput,
uploadFiles,
waitForResult,
} from "./qa-helpers";
// ---------------------------------------------------------------------------
// Multi-file and batch QA sweep.
// Covers: A) multi-input tools, B) batch processing, C) edge cases.
// Config: tests/qa/playwright.qa.config.ts (QA container, auth off).
// Run: pnpm playwright test --config tests/qa/playwright.qa.config.ts tests/qa/multifile.qa.spec.ts --workers=2
// ---------------------------------------------------------------------------
// Generous timeout: multi-file uploads + media processing can be slow.
test.setTimeout(180_000);
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Click the tool's run/submit button by test-id. */
async function clickSubmit(page: Page, toolId: string) {
const submit = page.getByTestId(`${toolId}-submit`);
await submit.first().waitFor({ state: "visible", timeout: 15_000 });
await submit.first().click();
}
/** Download via a specific test-id (for tools whose download is not `${toolId}-download`). */
async function downloadViaTestId(
page: Page,
testId: string,
): Promise<{ path: string; name: string; size: number; buf: Buffer }> {
const dlPromise = page.waitForEvent("download", { timeout: 60_000 });
await page.getByTestId(testId).first().click();
const dl = await dlPromise;
const name = dl.suggestedFilename();
const out = path.join(os.tmpdir(), `qa-mf-${process.hrtime.bigint()}-${name}`);
await dl.saveAs(out);
const buf = fs.readFileSync(out);
return { path: out, name, size: buf.length, buf };
}
/** Count pages in a PDF using pdfinfo or qpdf (whatever is available). */
function pdfPageCount(file: string): number {
try {
const out = execFileSync("pdfinfo", [file], { encoding: "utf8", timeout: 10_000 });
const m = out.match(/Pages:\s+(\d+)/);
return m ? Number(m[1]) : -1;
} catch {
// Fallback: qpdf
try {
const out = execFileSync("qpdf", ["--show-npages", file], {
encoding: "utf8",
timeout: 10_000,
});
return Number(out.trim());
} catch {
return -1;
}
}
}
/**
* List entries inside a ZIP file. Uses Python's zipfile module which handles
* fflate-generated ZIPs (the browser-side batch ZIP is built with fflate).
* Falls back to the `unzip` CLI if Python is unavailable.
*/
function zipEntries(fileOrBuf: string | Buffer): string[] {
let zipPath: string;
if (typeof fileOrBuf === "string") {
zipPath = fileOrBuf;
} else {
zipPath = path.join(os.tmpdir(), `qa-zip-${process.hrtime.bigint()}.zip`);
fs.writeFileSync(zipPath, fileOrBuf);
}
// Approach 1: Python's zipfile (handles fflate ZIPs reliably)
try {
const out = execFileSync(
"python3",
[
"-c",
`import zipfile, json, sys
zf = zipfile.ZipFile(sys.argv[1])
print(json.dumps([n for n in zf.namelist() if not n.endswith('/')]))`,
zipPath,
],
{ encoding: "utf8", timeout: 10_000 },
);
return JSON.parse(out.trim());
} catch {
// ignore
}
// Approach 2: unzip -l (parse table output)
try {
const out = execFileSync("unzip", ["-Z1", zipPath], {
encoding: "utf8",
timeout: 10_000,
});
return out
.trim()
.split("\n")
.filter((l) => l.length > 0 && !l.endsWith("/"));
} catch {
// ignore
}
return [];
}
/**
* Wait for the batch "Download All (ZIP)" button, click it, and save the file.
* The batch ZIP is created client-side via fflate.
*/
async function downloadBatchZip(
page: Page,
): Promise<{ path: string; name: string; size: number; buf: Buffer }> {
const zipBtn = page.getByRole("button", { name: /download all.*zip/i }).first();
await expect(zipBtn).toBeVisible({ timeout: 90_000 });
// Start listening for the download BEFORE clicking
const dlPromise = page.waitForEvent("download", { timeout: 60_000 });
await zipBtn.click();
const dl = await dlPromise;
const name = dl.suggestedFilename();
const out = path.join(os.tmpdir(), `qa-batch-${process.hrtime.bigint()}-${name}`);
await dl.saveAs(out);
const buf = fs.readFileSync(out);
return { path: out, name, size: buf.length, buf };
}
/**
* Wait for either the standard per-tool download button OR the batch "Download
* All (ZIP)" button to appear. Returns which was found.
*/
async function waitForAnyDownload(
page: Page,
toolId: string,
timeoutMs = 60_000,
): Promise<"tool-download" | "batch-zip" | "none"> {
const toolDl = page.getByTestId(`${toolId}-download`).first();
const genericDl = page.locator("[data-download-button]").first();
const batchZip = page.getByRole("button", { name: /download all.*zip/i }).first();
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await toolDl.isVisible().catch(() => false)) return "tool-download";
if (await genericDl.isVisible().catch(() => false)) return "tool-download";
if (await batchZip.isVisible().catch(() => false)) return "batch-zip";
await page.waitForTimeout(500);
}
return "none";
}
// =========================================================================
// A) MULTI-INPUT TOOLS
// =========================================================================
test.describe("A) Multi-input tools", () => {
test("merge-pdf: 3pg + 2pg -> 5 pages", async ({ page }) => {
const issues = instrument(page);
await gotoTool(page, "merge-pdf");
await uploadFiles(page, [
fixture("document", "formats", "tiny.pdf"),
fixture("document", "valid", "alt-2page.pdf"),
]);
await page.waitForTimeout(1_000);
const res = await processTool(page, "merge-pdf", "fast");
expect(res.ok, `merge-pdf failed: ${res.error}`).toBe(true);
const dl = await downloadResult(page, "merge-pdf");
expect(dl.size, "downloaded file is non-empty").toBeGreaterThan(0);
expect(magicMatches(dl.buf, "pdf"), "output is a valid PDF").toBe(true);
const pages = pdfPageCount(dl.path);
expect(pages, `merge-pdf produced ${pages} pages, expected 5`).toBe(5);
expect(isClean(issues), issuesSummary(issues)).toBe(true);
});
test("merge-audio: mp3 + wav -> duration ~= sum", async ({ page }) => {
// BUG CHECK: merge-audio-settings.tsx uses processAllFiles (batch) instead
// of processFiles (merge) when multiple files are present. This means it
// tries to process each file individually instead of merging them. merge-pdf
// correctly uses processFiles for its merge. If this test fails, the bug is
// confirmed: the UI sends files to the batch endpoint instead of the merge
// endpoint.
const issues = instrument(page);
await gotoTool(page, "merge-audio");
await uploadFiles(page, [
fixture("audio", "formats", "tiny.mp3"),
fixture("audio", "formats", "tiny.wav"),
]);
await page.waitForTimeout(1_000);
// Click submit and watch what happens
await clickSubmit(page, "merge-audio");
const which = await waitForAnyDownload(page, "merge-audio", 60_000);
if (which === "batch-zip") {
// BUG: merge-audio routed through batch endpoint instead of merge.
// The output is a ZIP of individually processed files, NOT a merged audio.
expect(
false,
"BUG: merge-audio uses processAllFiles (batch) instead of processFiles (merge). " +
"Files are processed individually, not concatenated. " +
"Fix: change merge-audio-settings.tsx to call processFiles(files, settings) " +
"for multiple files, like merge-pdf does.",
).toBe(true);
} else if (which === "tool-download") {
// Correct behavior: single merged download
const dl = await downloadResult(page, "merge-audio");
expect(dl.size).toBeGreaterThan(0);
const dur = mediaDuration(dl.path);
expect(dur, `merge-audio output duration ${dur.toFixed(2)}s, expected ~2.0s`).toBeGreaterThan(
1.5,
);
expect(dur, `merge-audio output too long: ${dur.toFixed(2)}s`).toBeLessThan(3.5);
} else {
// Neither download nor batch ZIP appeared. The UI sent files to the batch
// endpoint (processAllFiles) which tried to process each file individually.
// Since merge-audio requires 2+ inputs, each individual file fails, yielding
// "All files failed processing". This confirms the batch-instead-of-merge bug.
const errorText = await page
.locator("[role='alert'], .text-destructive-ink, .text-red-500")
.first()
.innerText()
.catch(() => "no error visible");
expect(
false,
"BUG: merge-audio uses processAllFiles (batch) instead of processFiles (merge). " +
"Each file is sent individually to the batch endpoint, failing because merge-audio " +
"requires 2+ files. Visible error: " +
errorText +
". Fix: change merge-audio-settings.tsx to always call processFiles(files, settings), " +
"like merge-pdf does.",
).toBe(true);
}
expect(isClean(issues), issuesSummary(issues)).toBe(true);
});
test("merge-videos: mp4 + mov -> duration ~= sum", async ({ page }) => {
const issues = instrument(page);
await gotoTool(page, "merge-videos");
await uploadFiles(page, [
fixture("video", "formats", "tiny.mp4"),
fixture("video", "formats", "tiny.mov"),
]);
await page.waitForTimeout(1_000);
// merge-videos has executionHint "long" => SSE/async
const res = await processTool(page, "merge-videos", "long");
expect(res.ok, `merge-videos failed: ${res.error}`).toBe(true);
const dl = await downloadResult(page, "merge-videos");
expect(dl.size).toBeGreaterThan(0);
// Source durations: mp4 ~1.0s, mov ~1.0s => sum ~2.0s
const dur = mediaDuration(dl.path);
expect(dur, `merge-videos duration ${dur.toFixed(2)}s, expected ~2.0s`).toBeGreaterThan(1.5);
expect(dur, `merge-videos output too long: ${dur.toFixed(2)}s`).toBeLessThan(3.5);
expect(isClean(issues), issuesSummary(issues)).toBe(true);
});
test("merge-csvs: tiny-a + tiny-b -> combined rows", async ({ page }) => {
// BUG CHECK: Same as merge-audio -- merge-csvs-settings.tsx uses
// processAllFiles (batch) instead of processFiles (merge) when multiple
// files are present.
const issues = instrument(page);
await gotoTool(page, "merge-csvs");
await uploadFiles(page, [
fixture("data", "valid", "tiny-a.csv"),
fixture("data", "valid", "tiny-b.csv"),
]);
await page.waitForTimeout(1_000);
await clickSubmit(page, "merge-csvs");
const which = await waitForAnyDownload(page, "merge-csvs", 60_000);
if (which === "batch-zip") {
expect(
false,
"BUG: merge-csvs uses processAllFiles (batch) instead of processFiles (merge). " +
"Files are processed individually, not concatenated. " +
"Fix: change merge-csvs-settings.tsx to always call processFiles(files, settings).",
).toBe(true);
} else if (which === "tool-download") {
const dl = await downloadResult(page, "merge-csvs");
expect(dl.size).toBeGreaterThan(0);
const csv = dl.buf.toString("utf8");
const lines = csv
.trim()
.split(/\r?\n/)
.filter((l) => l.trim().length > 0);
expect(
lines.length,
`merge-csvs output has ${lines.length} lines, expected >= 3`,
).toBeGreaterThanOrEqual(3);
expect(csv, "merge-csvs output should contain 'alpha'").toContain("alpha");
expect(csv, "merge-csvs output should contain 'beta'").toContain("beta");
} else {
// Same bug as merge-audio: processAllFiles sends each file individually,
// merge-csvs needs 2+ files, so each individual file fails.
const errorText = await page
.locator("[role='alert'], .text-destructive-ink, .text-red-500")
.first()
.innerText()
.catch(() => "no error visible");
expect(
false,
"BUG: merge-csvs uses processAllFiles (batch) instead of processFiles (merge). " +
"Each file is sent individually, failing because merge needs 2+ files. " +
"Visible error: " +
errorText +
". Fix: change merge-csvs-settings.tsx to always call processFiles(files, settings).",
).toBe(true);
}
expect(isClean(issues), issuesSummary(issues)).toBe(true);
});
test("stitch: 2 images -> stitched output with combined dimensions", async ({ page }) => {
const issues = instrument(page);
await gotoTool(page, "stitch");
await uploadFiles(page, [
fixture("image", "formats", "sample.png"),
fixture("image", "formats", "sample.jpg"),
]);
await page.waitForTimeout(1_000);
await clickSubmit(page, "stitch");
const dlBtn = page.getByTestId("stitch-download").first();
await expect(dlBtn).toBeVisible({ timeout: 60_000 });
const dl = await downloadViaTestId(page, "stitch-download");
expect(dl.size).toBeGreaterThan(0);
const info = imageInfo(dl.path);
const src1 = imageInfo(fixture("image", "formats", "sample.png"));
const src2 = imageInfo(fixture("image", "formats", "sample.jpg"));
// Default direction is horizontal => width should be roughly sum of inputs
expect(
info.width,
`stitch output width ${info.width} should exceed each input (${src1.width}, ${src2.width})`,
).toBeGreaterThan(Math.max(src1.width, src2.width));
expect(info.height, "stitch output has nonzero height").toBeGreaterThan(0);
expect(isClean(issues), issuesSummary(issues)).toBe(true);
});
test("collage: 2 images -> collage output present with sane dimensions", async ({ page }) => {
const issues = instrument(page);
await gotoTool(page, "collage");
await uploadFiles(page, [
fixture("image", "formats", "sample.png"),
fixture("image", "formats", "sample.jpg"),
]);
await page.waitForTimeout(1_500);
await clickSubmit(page, "collage");
const dlBtn = page.getByTestId("collage-download").first();
await expect(dlBtn).toBeVisible({ timeout: 60_000 });
const dl = await downloadViaTestId(page, "collage-download");
expect(dl.size).toBeGreaterThan(0);
const info = imageInfo(dl.path);
expect(info.width, "collage output has nonzero width").toBeGreaterThan(0);
expect(info.height, "collage output has nonzero height").toBeGreaterThan(0);
expect(isClean(issues), issuesSummary(issues)).toBe(true);
});
test("compose: base + overlay -> composited image", async ({ page }) => {
const issues = instrument(page);
await gotoTool(page, "compose");
await uploadFiles(page, fixture("image", "formats", "sample.png"));
await page.waitForTimeout(500);
await setSecondaryInput(
page,
"#compose-overlay-image",
fixture("image", "formats", "sample.jpg"),
);
await page.waitForTimeout(500);
await clickSubmit(page, "compose");
const dlBtn = page.getByTestId("compose-download").first();
await expect(dlBtn).toBeVisible({ timeout: 60_000 });
const dl = await downloadViaTestId(page, "compose-download");
expect(dl.size).toBeGreaterThan(0);
const info = imageInfo(dl.path);
expect(info.width, "compose output has nonzero width").toBeGreaterThan(0);
expect(info.height, "compose output has nonzero height").toBeGreaterThan(0);
expect(isClean(issues), issuesSummary(issues)).toBe(true);
});
test("compare: two distinct images -> similarity score + diff image", async ({ page }) => {
const issues = instrument(page);
await gotoTool(page, "compare");
await uploadFiles(page, fixture("image", "formats", "sample.png"));
await page.waitForTimeout(500);
await setSecondaryInput(
page,
"#compare-second-image",
fixture("image", "valid", "portrait-color.jpg"),
);
await page.waitForTimeout(500);
await clickSubmit(page, "compare");
const simText = page.locator("text=/Similarity:\\s*\\d/").first();
await expect(simText).toBeVisible({ timeout: 30_000 });
const dlBtn = page.getByTestId("compare-download").first();
await expect(dlBtn).toBeVisible({ timeout: 30_000 });
const dl = await downloadViaTestId(page, "compare-download");
expect(dl.size, "compare diff image is non-empty").toBeGreaterThan(0);
expect(isClean(issues), issuesSummary(issues)).toBe(true);
});
test("find-duplicates: identical pair + distinct -> reports duplicate group", async ({
page,
}) => {
const issues = instrument(page);
await gotoTool(page, "find-duplicates");
await uploadFiles(page, [
fixture("image", "valid", "portrait-color.jpg"),
fixture("image", "valid", "portrait-color-dup.jpg"),
fixture("image", "formats", "sample.png"),
]);
await page.waitForTimeout(1_500);
await clickSubmit(page, "find-duplicates");
const groupsText = page.locator("text=/Duplicate groups/").first();
await expect(groupsText).toBeVisible({ timeout: 60_000 });
const groupCountEl = groupsText.locator("..").locator("span.text-yellow-500").first();
const groupCountText = await groupCountEl.innerText();
const groupCount = Number(groupCountText.trim());
expect(
groupCount,
`find-duplicates reported ${groupCount} duplicate groups, expected >= 1`,
).toBeGreaterThanOrEqual(1);
const uniqueText = page.locator("text=/Unique images/").first();
await expect(uniqueText).toBeVisible({ timeout: 5_000 });
expect(isClean(issues), issuesSummary(issues)).toBe(true);
});
test("images-to-video: 3 images -> playable video", async ({ page }) => {
const issues = instrument(page);
await gotoTool(page, "images-to-video");
await uploadFiles(page, [
fixture("image", "formats", "sample.png"),
fixture("image", "formats", "sample.jpg"),
fixture("image", "formats", "sample.webp"),
]);
await page.waitForTimeout(1_000);
const res = await processTool(page, "images-to-video", "fast");
expect(res.ok, `images-to-video failed: ${res.error}`).toBe(true);
const dl = await downloadResult(page, "images-to-video");
expect(dl.size).toBeGreaterThan(0);
const probe = probeMedia(dl.path);
const videoStream = probe.streams?.find((s) => s.codec_type === "video");
expect(videoStream, "images-to-video output has a video stream").toBeTruthy();
expect(videoStream?.width, "video has nonzero width").toBeGreaterThan(0);
// With 3 images at 2s each (default) => ~6s
const dur = mediaDuration(dl.path);
expect(
dur,
`images-to-video duration ${dur.toFixed(2)}s, expected ~6s (3 images x 2s)`,
).toBeGreaterThan(4);
expect(dur).toBeLessThan(10);
expect(isClean(issues), issuesSummary(issues)).toBe(true);
});
test("create-zip: several files -> ZIP with correct entry count", async ({ page }) => {
// BUG CHECK: create-zip-settings.tsx uses processAllFiles (batch) instead
// of processFiles when multiple files are present. Also, create-zip has
// acceptedInputs:[] which may cause the dropzone to reject file uploads
// client-side.
const issues = instrument(page);
await gotoTool(page, "create-zip");
// Use a mix of file types since create-zip should accept all types
const inputFiles = [
fixture("document", "formats", "tiny.pdf"),
fixture("data", "valid", "tiny-a.csv"),
fixture("document", "formats", "tiny.txt"),
];
await uploadFiles(page, inputFiles);
await page.waitForTimeout(2_000);
// Check if the submit button is visible (files were accepted)
const submitBtn = page.getByTestId("create-zip-submit").first();
const submitVisible = await submitBtn.isVisible().catch(() => false);
if (!submitVisible) {
// Files were likely rejected by the dropzone or the settings component
// did not render. This could mean create-zip's empty acceptedInputs []
// is being interpreted as "reject all" by the file validation logic.
// Check if there are any files in the file list
const hasFiles = await page
.locator("text=/file|image|uploaded/i")
.first()
.isVisible()
.catch(() => false);
expect(
false,
"BUG: create-zip submit button not visible after uploading files. " +
"The tool has acceptedInputs:[] which may cause client-side file rejection. " +
"Files accepted: " +
hasFiles +
". " +
"Check if the file store or dropzone treats empty acceptedInputs as 'reject all' " +
"instead of 'accept all'.",
).toBe(true);
} else {
await submitBtn.click();
const which = await waitForAnyDownload(page, "create-zip", 60_000);
if (which === "batch-zip") {
const dl = await downloadBatchZip(page);
expect(dl.size).toBeGreaterThan(0);
const entries = zipEntries(dl.buf);
expect(
entries.length,
`BUG: create-zip used batch mode (processAllFiles). Batch ZIP has ${entries.length} entries. ` +
"Fix: change create-zip-settings.tsx to always call processFiles(files, settings).",
).toBe(inputFiles.length);
} else if (which === "tool-download") {
const dl = await downloadResult(page, "create-zip");
expect(dl.size).toBeGreaterThan(0);
expect(magicMatches(dl.buf, "zip"), "output is a valid ZIP").toBe(true);
const entries = zipEntries(dl.buf);
expect(
entries.length,
`create-zip produced ${entries.length} entries, expected ${inputFiles.length}`,
).toBe(inputFiles.length);
} else {
const errorText = await page
.locator("[role='alert'], .text-destructive-ink, .text-red-500")
.first()
.innerText()
.catch(() => "no error visible");
expect(
false,
"BUG: create-zip processed but no download appeared. " +
"Likely the processAllFiles batch call failed for each individual file. " +
"Error: " +
errorText,
).toBe(true);
}
}
expect(isClean(issues), issuesSummary(issues)).toBe(true);
});
test("replace-audio: video + audio -> output video has audio track", async ({ page }) => {
const issues = instrument(page);
await gotoTool(page, "replace-audio");
await uploadFiles(page, [
fixture("video", "formats", "tiny.mp4"),
fixture("audio", "formats", "tiny.mp3"),
]);
await page.waitForTimeout(1_000);
const res = await processTool(page, "replace-audio", "fast");
expect(res.ok, `replace-audio failed: ${res.error}`).toBe(true);
const dl = await downloadResult(page, "replace-audio");
expect(dl.size).toBeGreaterThan(0);
const probe = probeMedia(dl.path);
const videoStream = probe.streams?.find((s) => s.codec_type === "video");
const audioStream = probe.streams?.find((s) => s.codec_type === "audio");
expect(videoStream, "replace-audio output has video stream").toBeTruthy();
expect(audioStream, "replace-audio output has audio stream").toBeTruthy();
expect(isClean(issues), issuesSummary(issues)).toBe(true);
});
test("burn-subtitles: video + srt -> output produced", async ({ page }) => {
const issues = instrument(page);
await gotoTool(page, "burn-subtitles");
await uploadFiles(page, [
fixture("video", "formats", "tiny.mp4"),
fixture("video", "formats", "tiny.srt"),
]);
await page.waitForTimeout(1_000);
const res = await processTool(page, "burn-subtitles", "long");
expect(res.ok, `burn-subtitles failed: ${res.error}`).toBe(true);
const dl = await downloadResult(page, "burn-subtitles");
expect(dl.size, "burn-subtitles output is non-empty").toBeGreaterThan(0);
const probe = probeMedia(dl.path);
const videoStream = probe.streams?.find((s) => s.codec_type === "video");
expect(videoStream, "burn-subtitles output has video stream").toBeTruthy();
expect(isClean(issues), issuesSummary(issues)).toBe(true);
});
test("embed-subtitles: video + srt -> output has subtitle stream", async ({ page }) => {
const issues = instrument(page);
await gotoTool(page, "embed-subtitles");
await uploadFiles(page, [
fixture("video", "formats", "tiny.mp4"),
fixture("video", "formats", "tiny.srt"),
]);
await page.waitForTimeout(1_000);
const res = await processTool(page, "embed-subtitles", "fast");
expect(res.ok, `embed-subtitles failed: ${res.error}`).toBe(true);
const dl = await downloadResult(page, "embed-subtitles");
expect(dl.size, "embed-subtitles output is non-empty").toBeGreaterThan(0);
const probe = probeMedia(dl.path);
const subStream = probe.streams?.find((s) => s.codec_type === "subtitle");
const videoStream = probe.streams?.find((s) => s.codec_type === "video");
expect(videoStream, "embed-subtitles output has video stream").toBeTruthy();
expect(subStream, "embed-subtitles output has subtitle stream").toBeTruthy();
expect(isClean(issues), issuesSummary(issues)).toBe(true);
});
});
// =========================================================================
// B) BATCH PROCESSING (same tool, many files)
// =========================================================================
test.describe("B) Batch processing", () => {
test("resize: batch 5 images -> ZIP with 5 outputs", async ({ page }) => {
const issues = instrument(page);
await gotoTool(page, "resize");
const batchFiles = [
fixture("image", "formats", "sample.png"),
fixture("image", "formats", "sample.jpg"),
fixture("image", "formats", "sample.webp"),
fixture("image", "formats", "sample.bmp"),
fixture("image", "formats", "sample.gif"),
];
await uploadFiles(page, batchFiles);
await page.waitForTimeout(1_500);
// Set a resize width so all 5 process
await page.locator("#resize-width").fill("128");
await page.waitForTimeout(300);
// Submit triggers batch mode for 5 files
const res = await processTool(page, "resize", "fast");
expect(res.ok, `resize batch failed: ${res.error}`).toBe(true);
// The batch "Download All (ZIP)" button should appear
const dl = await downloadBatchZip(page);
expect(dl.size).toBeGreaterThan(0);
expect(magicMatches(dl.buf, "zip"), "batch output is a valid ZIP").toBe(true);
const entries = zipEntries(dl.buf);
expect(entries.length, `resize batch ZIP has ${entries.length} entries, expected 5`).toBe(5);
expect(isClean(issues), issuesSummary(issues)).toBe(true);
});
test("convert: batch 5 images to webp -> ZIP with 5 outputs", async ({ page }) => {
const issues = instrument(page);
await gotoTool(page, "convert");
const batchFiles = [
fixture("image", "formats", "sample.png"),
fixture("image", "formats", "sample.jpg"),
fixture("image", "formats", "sample.bmp"),
fixture("image", "formats", "sample.gif"),
fixture("image", "formats", "sample.tiff"),
];
await uploadFiles(page, batchFiles);
await page.waitForTimeout(1_500);
await page.locator("#convert-target-format").selectOption("webp");
await page.waitForTimeout(300);
const res = await processTool(page, "convert", "fast");
expect(res.ok, `convert batch failed: ${res.error}`).toBe(true);
const dl = await downloadBatchZip(page);
expect(dl.size).toBeGreaterThan(0);
expect(magicMatches(dl.buf, "zip"), "batch ZIP is valid").toBe(true);
const entries = zipEntries(dl.buf);
expect(entries.length, `convert batch ZIP has ${entries.length} entries, expected 5`).toBe(5);
expect(isClean(issues), issuesSummary(issues)).toBe(true);
});
test("compress: batch 5 images -> ZIP with 5 outputs", async ({ page }) => {
const issues = instrument(page);
await gotoTool(page, "compress");
const batchFiles = [
fixture("image", "formats", "sample.png"),
fixture("image", "formats", "sample.jpg"),
fixture("image", "formats", "sample.webp"),
fixture("image", "formats", "sample.gif"),
fixture("image", "formats", "sample.tiff"),
];
await uploadFiles(page, batchFiles);
await page.waitForTimeout(1_500);
// Compress defaults to "targetSize" mode which requires entering a value.
// Switch to "quality" mode so the submit button becomes enabled.
const qualityBtn = page.getByRole("button", { name: /quality/i }).first();
if (await qualityBtn.isVisible().catch(() => false)) {
await qualityBtn.click();
await page.waitForTimeout(300);
}
const res = await processTool(page, "compress", "fast");
expect(res.ok, `compress batch failed: ${res.error}`).toBe(true);
const dl = await downloadBatchZip(page);
expect(dl.size).toBeGreaterThan(0);
expect(magicMatches(dl.buf, "zip"), "batch ZIP is valid").toBe(true);
const entries = zipEntries(dl.buf);
expect(entries.length, `compress batch ZIP has ${entries.length} entries, expected 5`).toBe(5);
expect(isClean(issues), issuesSummary(issues)).toBe(true);
});
});
// =========================================================================
// C) EDGE CASES
// =========================================================================
test.describe("C) Edge cases", () => {
test("merge-videos: exceed maxInputs (> 10) -> clean limit error", async ({ page }) => {
const issues = instrument(page);
await gotoTool(page, "merge-videos");
// Upload 11 copies of the same video to exceed the 10-file limit.
const manyFiles: string[] = [];
for (let i = 0; i < 11; i++) {
manyFiles.push(fixture("video", "formats", "tiny.mp4"));
}
await uploadFiles(page, manyFiles);
await page.waitForTimeout(2_000);
// Try to process
await clickSubmit(page, "merge-videos");
// Wait with a reasonable timeout for either error or completion.
// The server's createToolRoute validates maxInputs and should return 400.
// Give the SSE response time to arrive (merge-videos is "long").
const deadline = Date.now() + 90_000;
let gotError = false;
let gotDownload = false;
while (Date.now() < deadline) {
const errorRegion = page
.locator("[role='alert'], [aria-live='assertive'], .text-destructive-ink, .text-red-500")
.filter({ hasText: /error|failed|too many|maximum|limit|exceeded|invalid/i })
.first();
if (await errorRegion.isVisible().catch(() => false)) {
gotError = true;
break;
}
const dl = page.getByTestId("merge-videos-download").first();
if (await dl.isVisible().catch(() => false)) {
gotDownload = true;
break;
}
await page.waitForTimeout(1_000);
}
if (gotDownload) {
// The server processed 11 files without enforcing the limit -- this is a bug.
expect(
false,
"BUG: merge-videos accepted 11 files but maxInputs is 10. " +
"The server should reject with a 400 error.",
).toBe(true);
} else if (gotError) {
// Good: a clean error was shown
expect(true, "merge-videos correctly rejected > 10 files").toBe(true);
} else {
// Timeout: neither error nor result within 90s -- the server is processing
// slowly rather than rejecting. Still a bug but not a crash.
expect(
false,
"merge-videos with 11 files: no error or result within 90s. " +
"Expected a quick rejection, got a timeout.",
).toBe(true);
}
// No unhandled page crashes
expect(
issues.pageErrors.length,
`unexpected page errors: ${issues.pageErrors.join(", ")}`,
).toBe(0);
});
test("collage: wrong-type file (.txt) -> clean rejection", async ({ page }) => {
const issues = instrument(page);
await gotoTool(page, "collage");
// Upload a text file into collage (expects images)
await uploadFiles(page, fixture("document", "formats", "tiny.txt"));
await page.waitForTimeout(1_500);
// Try to submit -- the button may be disabled (no valid images) or server rejects
const submitBtn = page.getByTestId("collage-submit").first();
const isDisabled = await submitBtn.getAttribute("disabled");
if (isDisabled !== null) {
expect(true, "collage correctly disables submit for non-image file").toBe(true);
} else {
await submitBtn.click();
await page.waitForTimeout(3_000);
const errorVisible = await page
.locator("text=/error|invalid|unsupported|failed/i")
.first()
.isVisible()
.catch(() => false);
expect(
errorVisible,
"collage should show an error for non-image file, not silently fail",
).toBe(true);
}
expect(
issues.pageErrors.length,
`unexpected page errors: ${issues.pageErrors.join(", ")}`,
).toBe(0);
});
test("batch resize: duplicate filenames -> outputs do not collide", async ({ page }) => {
const issues = instrument(page);
await gotoTool(page, "resize");
// Upload the same file twice (same filename) plus a different one.
const dupeFiles = [
fixture("image", "formats", "sample.png"),
fixture("image", "formats", "sample.png"),
fixture("image", "formats", "sample.jpg"),
];
await uploadFiles(page, dupeFiles);
await page.waitForTimeout(1_500);
await page.locator("#resize-width").fill("64");
await page.waitForTimeout(300);
const res = await processTool(page, "resize", "fast");
expect(res.ok, `resize with duplicate names failed: ${res.error}`).toBe(true);
const dl = await downloadBatchZip(page);
expect(dl.size).toBeGreaterThan(0);
const entries = zipEntries(dl.buf);
// Should have 3 entries (deduped names). If 2, duplicates overwrote each other.
expect(
entries.length,
`batch ZIP has ${entries.length} entries, expected 3 (duplicate filenames should not collide)`,
).toBe(3);
// All entry names should be unique
const uniqueNames = new Set(entries);
expect(
uniqueNames.size,
`batch ZIP has ${uniqueNames.size} unique names out of ${entries.length} entries`,
).toBe(entries.length);
expect(isClean(issues), issuesSummary(issues)).toBe(true);
});
});