mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
* fix(api): correct format/filename/container handling across tool routes Found during a comprehensive QA sweep exercising every tool against its full accepted-format matrix: - watermark-image, compose: preserve the requested output format and a matching download filename/extension instead of always emitting the source format - compose: crop oversized overlays to the visible base area instead of crashing Sharp's composite, and reject only overlays fully outside the base image instead of any oversized one - compare, vectorize: switch to the shared image input handler so filenames and formats like .svgz/.tga/RAW survive validation instead of being rejected pre-processing - tool-factory, images-to-video: normalize frames through Sharp before handing them to FFmpeg, fixing GIF/AVIF/RAW image-to-video jobs that previously failed or hung - media-tool, replace-audio, embed-subtitles: fix legacy container MIME/codec handling for MPEG sources and subtitle remux cases - files: expand download MIME mapping for text/data/document/video/audio outputs that were falling back to a generic content type - convert-document/presentation/spreadsheet: same-format conversions now return the original validated file instead of erroring or producing corrupt tiny output Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(web): dropzone a11y, stale localStorage getter, dead code - dropzone: stop making the whole drop-zone section clickable/focusable. A section acting as an interactive element around a real upload button is a nested-interactive-element anti-pattern that confuses screen readers; drag-and-drop doesn't need focus semantics, only the button fallback does. Keeps that button semantic and keyboard-reachable. Updates the two e2e call sites that clicked the section directly. - api, use-auth: read through window.localStorage via the existing API storage helper instead of the bare global, which resolves to Node's experimental localStorage getter under Vitest and threw - find-duplicates-settings, info-settings, login-page: remove dead code (unused zip-download handler, a stale mount-only effect dependency that left cached info stuck at reused indices, an unused response variable) Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(i18n): pt-BR, zh-CN, zh-TW were silently falling back to English The locale loader looked up dynamic-import exports by the raw locale code (mod["pt-BR"], mod["zh-CN"], mod["zh-TW"]), but those three modules export camelCased bindings (ptBR, zhCN, zhTW) since identifiers can't contain hyphens. The lookup returned undefined and every consumer silently fell back to English for these three locales. Replaces the generic lookup with explicit per-locale loaders so the mapping can't drift out of sync again. Also updates the dropzone helper copy across all 21 locales to match the drag-only dropzone wording from the previous commit. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(docs): clear build warnings in the VitePress site - config.mts: add an onwarn handler for the @vueuse INVALID_ANNOTATION warnings emitted during the docs build - deployment.md: the caddyfile code fence language isn't a shiki grammar VitePress ships with, so it warned on every build; use txt instead Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * test(qa): update QA harness for the drag-only dropzone and regen metadata - api-sweep, qa-helpers, verify-ai: add JSON-body tools, multi-input secondary fixtures, async polling for slow valid jobs, 501 FEATURE_NOT_INSTALLED skip handling, and safer per-tool settings - input-preview, pipeline-ui specs: update upload flow for the drag-only dropzone surface - add tests/fixtures/data/valid/chart.json, a valid chart fixture the updated helpers route to - regenerate tools-meta.json against current TOOLS[] Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(security): close a login timing side-channel, harden zip-slip tests Found during a black-box security sweep of the real auth-enabled production container: a nonexistent username returned 401 in ~3-10ms, while a wrong password for a real user took ~35-42ms, because scrypt verification only ran when a user row existed. That timing gap lets an attacker enumerate valid usernames without ever guessing a password. Now runs verification against a cached dummy hash on the unknown-user path too, so both cases cost the same regardless of outcome. extract-zip already had a relative-traversal regression test (../evil.txt), but its absolute-path rejection branches (name.startsWith("/") / startsWith("\\")) had none. Added the three missing cases: deep relative traversal, absolute Unix path, and Windows-style absolute path. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * test(qa): add UI-driven AI bundle install scripts QA_PROMPT.md's Phase 2 requires installing AI models the way a user does -- through the UI, on demand from HuggingFace -- and treats the curl-based admin install endpoint as fallback-only. Nothing in the harness actually drove that flow; tests/qa/seed-ai-models.sh installs via docker exec + pip, which is further from a real user than even the API fallback. install-ai-bundles-ui.mts logs in, opens Settings > AI Features, screenshots the pre-install state, clicks Install All, and screenshots progress -- then exits, since installs continue server-side once triggered. verify-ai-install-complete.mts polls bundle status, screenshots the completed state, and runs one real tool per installed bundle to prove the freshly-downloaded model actually executes. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(qa): correct the apiToolPath import in the AI verify script Dynamic import of the package name failed under tsx's module resolution from apps/api's node_modules context; use the same relative-path import api-sweep.mts already uses successfully. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(web): correct AI bundle size estimates shown before install Measured real downloads during GPU-node QA verification: photo-restoration pulls ~4.4GB (was advertised as 800MB-1GB, off by 4-5x) and ocr pulls ~5.5GB (was advertised as 3-4GB). Both estimates only accounted for model weights, not the pip dependencies (torch/paddle) that come down with them. Updated to reflect actual total download size, since that's what a user deciding whether they have the disk/bandwidth actually needs to know. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(web): make desktop Settings reachable when auth is disabled AvatarDropdown (the only desktop entry point to Settings) was gated behind `!isMobile && authEnabled`. With AUTH_ENABLED=false the synthetic anonymous admin user should have full Settings access per how auth.ts documents this mode -- and the mobile bottom nav already worked this way, showing Settings unconditionally. Desktop just had a stray extra gate the component doesn't need: AvatarDropdown already resolves its own username internally (falling back to "admin") and reads authEnabled itself where it actually matters (hiding the Logout button). Removed the outer gate; verified end-to-end against a fresh AUTH_ENABLED=false instance -- avatar now renders, Settings opens, shows the anonymous/Admin identity correctly. Also documents (not changes) a related finding in install_feature.py: detect_arch() always resolves amd64 hosts to the GPU-bundled archive variant regardless of actual GPU presence, since no CPU-only amd64 archive is published to the bundle repo yet. Left as a code comment rather than a behavior change, since requesting an unpublished archive key would hard-fail installs entirely -- worse than the current oversized-but-working download. Full detail in the QA report. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(ai): stop logging expected dispatcher reloads as crashes After each AI bundle install the Python dispatcher reloads because the venv changed, and after every app shutdown it's SIGTERMed. Both took the close handler's `code !== 0` branch (SIGTERM makes the exit code null), so they were counted as crashes -- producing an alarming "crash" line in the logs and a pointless ~1s recovery backoff after each of 7 installs. A `stopping` flag set in shutdown() lets the close handler tell an intentional stop apart from a real crash. The request-timeout kill path deliberately does not set it, so a genuinely hung script still records a crash and the 5-in-60s permanent-disable threshold is untouched. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(api): return a clean message when content-aware resize times out Carving a very high-resolution image down to a tiny target could exceed the caire subprocess timeout, and the raw error forwarded to the user was caire's terminal output -- ANSI color codes and progress-spinner control characters -- instead of anything actionable. Now: the timeout path throws a clear "timed out; try a smaller image or larger target" message (keeping the raw stderr as `cause` for server logs); friendlyError() strips ANSI/control chars centrally so any subprocess dump surfaced through the shared sanitizer is plain text; and the content-aware-resize route (a custom route that bypassed the sanitizer) now routes its error paths through friendlyError like every other tool. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(ai): stop bundle installs from exhausting host disk Installing an AI bundle on a tight-disk host could push the root filesystem to zero bytes free after the preflight check had already passed. Two root causes: - move_tree used copytree+rmtree, so during the move the extracted payload existed in both staging and the venv at once -- a full transient doubling on disk. Rewrote it to rename entries (a cheap metadata op on the same filesystem, no copy), falling back to a copy only across filesystems. - the preflight budget used the manifest's extractedSize verbatim, which is 0 for several archives, collapsing the estimate to just the compressed size. Added a conservative fallback (3x compressed) so a missing value can't under-reserve. Also added a real-on-disk re-check immediately before the first destructive venv write (measuring the actual extracted payload and whether the move needs extra space for a cross-filesystem copy), which also now covers the offline-import path that previously skipped the disk check entirely; wrapped the moves so an out-of-space failure returns a clean actionable error instead of a traceback; and made the disk check resolve the nearest existing ancestor so it never throws on a not-yet-created venv path. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * feat(web): show the real per-arch AI bundle download size The bundle cards and install prompt showed a hardcoded, architecture-blind estimatedSize string. That's misleading: amd64 hosts always pull the CUDA-inclusive archive (there's no CPU-only amd64 variant published), so a bundle labelled "1-2 GB" can actually download several times that, while arm64 pulls a much smaller archive for the same label. The manifest already carries the real per-arch compressedSize (and extractedSize where measured), so surface those: a new optional downloadBytes/installedBytes on FeatureBundleState, populated in getFeatureStates() for this host's arch (resolver mirrors install_feature.py detect_arch), shown by the UI when present with estimatedSize kept as the fallback label. Also nudged upscale-enhance's fallback string (4-5 -> 5-6 GB) to match its real compressed size, consistent with the earlier photo-restoration/ocr fixes. Fields are optional so demo/mock and existing tests stay compiling; the manifest's extractedSize is 0 for a few archives, which now surfaces as null rather than a bogus 0. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(web): move the AI install queue to the server so it survives tab close Installing multiple bundles could silently lose all but the first. The server rejected a concurrent install with 409, so the client worked around it by queueing the rest in browser-local state and only POSTing each once it saw the previous finish. A single POSTed install is durable (the installer child is detached from the request), but a queued one had zero server footprint -- close the tab mid-queue and those installs vanished with no error, while the UI still showed them "Queued". The client "mutex" didn't even serialize: the queued bundles' local waits all resolved at once and raced into concurrent POSTs that 409'd each other. Now the queue lives on the server (a small in-memory FIFO leaf module). The install endpoint enqueues instead of 409-ing and returns 202 {jobId, queued}; a pump starts the next bundle when the current one's child exits (and after an offline import releases the lock), all behind the existing venv + file locks, which are unchanged. The client just POSTs every bundle immediately and reflects the server-reported queued/installing status; Install All fires all POSTs and lets the server serialize them, keeping the one-shot retry-on-failure. Adds "queued" to FeatureStatus (the bundle card already rendered that state) and surfaces it from getFeatureStates. In-memory is deliberate: it matches the existing contract (survives a tab close, not a server restart, which already clears the lock on boot). Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(qa): don't log env-derived credentials in the AI-install script CodeQL flagged clear-text logging of sensitive information: the login status line interpolated the QA base URL and username (both read from the process environment) into a console.log. Replaced with a static message. QA helper only, but it's a real hygiene issue and cleared the high-severity code-scanning alert on the PR. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG
2161 lines
65 KiB
TypeScript
2161 lines
65 KiB
TypeScript
/**
|
|
* SETTINGS-SEMANTIC QA sweep: verify every tool setting actually changes the
|
|
* output in the expected way, not just "no crash".
|
|
*
|
|
* For each tool, we vary one setting at a time from a sane default, process,
|
|
* download, and assert the output changed as expected via decode oracles
|
|
* (imageInfo, imageLuma, imageSaturation, probeMedia, mediaDuration, magicMatches).
|
|
*
|
|
* Run:
|
|
* pnpm playwright test --config tests/qa/playwright.qa.config.ts tests/qa/settings.qa.spec.ts --workers=1
|
|
*/
|
|
|
|
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,
|
|
imageLuma,
|
|
imageSaturation,
|
|
instrument,
|
|
isClean,
|
|
issuesSummary,
|
|
magicMatches,
|
|
mediaDuration,
|
|
type PageIssues,
|
|
probeMedia,
|
|
processTool,
|
|
signalStats,
|
|
uploadFiles,
|
|
} from "./qa-helpers";
|
|
|
|
// ── Findings accumulator ─────────────────────────────────────────────
|
|
interface Finding {
|
|
tool: string;
|
|
setting: string;
|
|
value: string;
|
|
expected: string;
|
|
actual: string;
|
|
severity: "bug" | "warn";
|
|
}
|
|
|
|
const findings: Finding[] = [];
|
|
const coverage: Record<string, { tested: number; total: number }> = {};
|
|
|
|
function bug(f: Omit<Finding, "severity">) {
|
|
findings.push({ ...f, severity: "bug" });
|
|
}
|
|
|
|
function warn(f: Omit<Finding, "severity">) {
|
|
findings.push({ ...f, severity: "warn" });
|
|
}
|
|
|
|
// ── Fixtures ─────────────────────────────────────────────────────────
|
|
const IMG_200x150 = fixture("image", "valid", "test-200x150.png"); // 200x150 PNG
|
|
const IMG_JPG = fixture("image", "valid", "sample-photo.jpg");
|
|
const VID_MP4 = fixture("video", "formats", "tiny.mp4"); // 64x64, 1s, 8fps
|
|
const AUD_MP3 = fixture("audio", "formats", "tiny.mp3"); // mono, ~1s
|
|
const AUD_STEREO = fixture("audio", "formats", "tone-stereo.wav"); // stereo, 1s
|
|
const PDF_3PAGE = fixture("document", "valid", "test-3page.pdf"); // 3 pages
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
const TOOL_TIMEOUT = 120_000;
|
|
|
|
/** Fill a numeric input by id, clearing first. */
|
|
async function fillInput(page: Page, id: string, value: string | number) {
|
|
const input = page.locator(`#${id}`);
|
|
await input.waitFor({ state: "visible", timeout: 10_000 });
|
|
await input.fill(String(value));
|
|
}
|
|
|
|
/** Set a slider by id to a specific value. */
|
|
async function setSlider(page: Page, id: string, value: number) {
|
|
const slider = page.locator(`#${id}`);
|
|
await slider.waitFor({ state: "visible", timeout: 10_000 });
|
|
await slider.fill(String(value));
|
|
}
|
|
|
|
/** Select a value in a <select> by id. */
|
|
async function selectOption(page: Page, id: string, value: string) {
|
|
const sel = page.locator(`#${id}`);
|
|
await sel.waitFor({ state: "visible", timeout: 10_000 });
|
|
await sel.selectOption(value);
|
|
}
|
|
|
|
/** Click a button matching text (for tab buttons, effect buttons, etc). */
|
|
async function clickButton(page: Page, text: string | RegExp) {
|
|
await page.getByRole("button", { name: text }).first().click();
|
|
}
|
|
|
|
/** Process and download for a specific tool, returning the downloaded file info. */
|
|
async function processAndDownload(page: Page, toolId: string, hint: "fast" | "long" = "fast") {
|
|
const result = await processTool(page, toolId, hint);
|
|
if (!result.ok) {
|
|
return { ok: false as const, error: result.error, path: "", size: 0, buf: Buffer.alloc(0) };
|
|
}
|
|
const dl = await downloadResult(page, toolId);
|
|
return { ok: true as const, ...dl };
|
|
}
|
|
|
|
/** Navigate to tool, upload a file, wait for it to be ready. */
|
|
async function setupTool(page: Page, toolId: string, file: string) {
|
|
await gotoTool(page, toolId);
|
|
await uploadFiles(page, file);
|
|
// Wait for the submit button to be enabled (file loaded)
|
|
const submit = page.getByTestId(`${toolId}-submit`);
|
|
await submit.first().waitFor({ state: "visible", timeout: 20_000 });
|
|
}
|
|
|
|
// ── Write findings report ────────────────────────────────────────────
|
|
test.afterAll(async () => {
|
|
const reportPath = path.join(__dirname, "..", "..", "docs", "qa", "findings-settings.md");
|
|
const lines: string[] = [
|
|
"# Settings-Semantic QA Findings",
|
|
"",
|
|
`Generated: ${new Date().toISOString()}`,
|
|
"",
|
|
];
|
|
|
|
if (findings.length === 0) {
|
|
lines.push("## No bugs found", "", "All tested settings produced the expected output changes.");
|
|
} else {
|
|
const bugs = findings.filter((f) => f.severity === "bug");
|
|
const warns = findings.filter((f) => f.severity === "warn");
|
|
lines.push(`## Summary: ${bugs.length} bug(s), ${warns.length} warning(s)`, "");
|
|
lines.push("## Bugs", "");
|
|
lines.push("| Tool | Setting | Value | Expected | Actual |");
|
|
lines.push("|------|---------|-------|----------|--------|");
|
|
for (const f of bugs) {
|
|
lines.push(`| ${f.tool} | ${f.setting} | ${f.value} | ${f.expected} | ${f.actual} |`);
|
|
}
|
|
if (warns.length) {
|
|
lines.push("", "## Warnings", "");
|
|
lines.push("| Tool | Setting | Value | Expected | Actual |");
|
|
lines.push("|------|---------|-------|----------|--------|");
|
|
for (const f of warns) {
|
|
lines.push(`| ${f.tool} | ${f.setting} | ${f.value} | ${f.expected} | ${f.actual} |`);
|
|
}
|
|
}
|
|
}
|
|
|
|
lines.push("", "## Per-Tool Coverage", "");
|
|
lines.push("| Tool | Settings Tested | Total Settings |");
|
|
lines.push("|------|----------------|----------------|");
|
|
for (const [tool, cov] of Object.entries(coverage).sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
lines.push(`| ${tool} | ${cov.tested} | ${cov.total} |`);
|
|
}
|
|
|
|
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
|
fs.writeFileSync(reportPath, lines.join("\n") + "\n");
|
|
});
|
|
|
|
// =====================================================================
|
|
// IMAGE TOOLS
|
|
// =====================================================================
|
|
|
|
test.describe("IMAGE: resize", () => {
|
|
test.setTimeout(TOOL_TIMEOUT);
|
|
|
|
test("width=100 -> output width 100", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "resize", IMG_200x150);
|
|
await fillInput(page, "resize-width", 100);
|
|
const dl = await processAndDownload(page, "resize");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "resize",
|
|
setting: "width",
|
|
value: "100",
|
|
expected: "output width=100",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const info = imageInfo(dl.path);
|
|
if (info.width !== 100)
|
|
bug({
|
|
tool: "resize",
|
|
setting: "width",
|
|
value: "100",
|
|
expected: "width=100",
|
|
actual: `width=${info.width}`,
|
|
});
|
|
expect(info.width).toBe(100);
|
|
});
|
|
|
|
test("height=50 -> output height 50", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "resize", IMG_200x150);
|
|
await fillInput(page, "resize-height", 50);
|
|
const dl = await processAndDownload(page, "resize");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "resize",
|
|
setting: "height",
|
|
value: "50",
|
|
expected: "output height=50",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const info = imageInfo(dl.path);
|
|
if (info.height !== 50)
|
|
bug({
|
|
tool: "resize",
|
|
setting: "height",
|
|
value: "50",
|
|
expected: "height=50",
|
|
actual: `height=${info.height}`,
|
|
});
|
|
expect(info.height).toBe(50);
|
|
});
|
|
|
|
test("percentage=25 via scale tab -> output ~50x38", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "resize", IMG_200x150);
|
|
// Click scale tab
|
|
await page.getByRole("button", { name: /scale/i }).first().click();
|
|
await fillInput(page, "resize-scale", 25);
|
|
const dl = await processAndDownload(page, "resize");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "resize",
|
|
setting: "percentage",
|
|
value: "25",
|
|
expected: "~50x38",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const info = imageInfo(dl.path);
|
|
// 25% of 200x150 = 50x37.5 -> 50x38
|
|
if (info.width !== 50)
|
|
bug({
|
|
tool: "resize",
|
|
setting: "percentage",
|
|
value: "25",
|
|
expected: "width=50",
|
|
actual: `width=${info.width}`,
|
|
});
|
|
expect(info.width).toBe(50);
|
|
});
|
|
|
|
test("fit=contain with both dims -> output fits inside box", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "resize", IMG_200x150);
|
|
await fillInput(page, "resize-width", 80);
|
|
await fillInput(page, "resize-height", 80);
|
|
// Click contain
|
|
await page
|
|
.getByRole("button", { name: /fit inside/i })
|
|
.first()
|
|
.click();
|
|
const dl = await processAndDownload(page, "resize");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "resize",
|
|
setting: "fit",
|
|
value: "contain",
|
|
expected: "fits inside 80x80",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const info = imageInfo(dl.path);
|
|
// contain: max dim is 80, aspect 200/150=1.33, so 80x60
|
|
if (info.width > 80 || info.height > 80)
|
|
bug({
|
|
tool: "resize",
|
|
setting: "fit",
|
|
value: "contain",
|
|
expected: "max dim <= 80",
|
|
actual: `${info.width}x${info.height}`,
|
|
});
|
|
expect(info.width).toBeLessThanOrEqual(80);
|
|
expect(info.height).toBeLessThanOrEqual(80);
|
|
});
|
|
|
|
test("fit=cover with both dims -> output covers box", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "resize", IMG_200x150);
|
|
await fillInput(page, "resize-width", 80);
|
|
await fillInput(page, "resize-height", 80);
|
|
// Click crop to fit (cover)
|
|
await page
|
|
.getByRole("button", { name: /crop to fit/i })
|
|
.first()
|
|
.click();
|
|
const dl = await processAndDownload(page, "resize");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "resize",
|
|
setting: "fit",
|
|
value: "cover",
|
|
expected: "exactly 80x80",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const info = imageInfo(dl.path);
|
|
if (info.width !== 80 || info.height !== 80)
|
|
bug({
|
|
tool: "resize",
|
|
setting: "fit",
|
|
value: "cover",
|
|
expected: "80x80",
|
|
actual: `${info.width}x${info.height}`,
|
|
});
|
|
expect(info.width).toBe(80);
|
|
expect(info.height).toBe(80);
|
|
});
|
|
|
|
test("fit=fill (stretch) with both dims -> exactly WxH", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "resize", IMG_200x150);
|
|
await fillInput(page, "resize-width", 80);
|
|
await fillInput(page, "resize-height", 60);
|
|
await page
|
|
.getByRole("button", { name: /stretch/i })
|
|
.first()
|
|
.click();
|
|
const dl = await processAndDownload(page, "resize");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "resize",
|
|
setting: "fit",
|
|
value: "fill",
|
|
expected: "80x60",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const info = imageInfo(dl.path);
|
|
if (info.width !== 80 || info.height !== 60)
|
|
bug({
|
|
tool: "resize",
|
|
setting: "fit",
|
|
value: "fill",
|
|
expected: "80x60",
|
|
actual: `${info.width}x${info.height}`,
|
|
});
|
|
expect(info.width).toBe(80);
|
|
expect(info.height).toBe(60);
|
|
});
|
|
|
|
coverage.resize = { tested: 6, total: 6 };
|
|
});
|
|
|
|
test.describe("IMAGE: crop", () => {
|
|
test.setTimeout(TOOL_TIMEOUT);
|
|
|
|
test("crop region -> output dims match crop size", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await gotoTool(page, "crop");
|
|
await uploadFiles(page, IMG_200x150);
|
|
// Wait for interactive crop UI to load, then set numeric values
|
|
const cropW = page.locator("#crop-width");
|
|
await cropW.waitFor({ state: "visible", timeout: 20_000 });
|
|
await page.locator("#crop-x").fill("10");
|
|
await page.locator("#crop-y").fill("10");
|
|
await cropW.fill("80");
|
|
await page.locator("#crop-height").fill("60");
|
|
const dl = await processAndDownload(page, "crop");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "crop",
|
|
setting: "region",
|
|
value: "10,10,80,60",
|
|
expected: "80x60",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const info = imageInfo(dl.path);
|
|
if (info.width !== 80 || info.height !== 60)
|
|
bug({
|
|
tool: "crop",
|
|
setting: "region",
|
|
value: "10,10,80,60",
|
|
expected: "80x60",
|
|
actual: `${info.width}x${info.height}`,
|
|
});
|
|
expect(info.width).toBe(80);
|
|
expect(info.height).toBe(60);
|
|
});
|
|
|
|
coverage.crop = { tested: 1, total: 2 };
|
|
});
|
|
|
|
test.describe("IMAGE: rotate", () => {
|
|
test.setTimeout(TOOL_TIMEOUT);
|
|
|
|
test("90 degrees CW -> dims swap (200x150 becomes 150x200)", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "rotate", IMG_200x150);
|
|
await page.getByTestId("rotate-right").click();
|
|
const dl = await processAndDownload(page, "rotate");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "rotate",
|
|
setting: "angle",
|
|
value: "90",
|
|
expected: "150x200",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const info = imageInfo(dl.path);
|
|
if (info.width !== 150 || info.height !== 200)
|
|
bug({
|
|
tool: "rotate",
|
|
setting: "angle",
|
|
value: "90",
|
|
expected: "150x200",
|
|
actual: `${info.width}x${info.height}`,
|
|
});
|
|
expect(info.width).toBe(150);
|
|
expect(info.height).toBe(200);
|
|
});
|
|
|
|
test("180 degrees -> same dims, different content", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "rotate", IMG_200x150);
|
|
await page.getByRole("button", { name: "180" }).first().click();
|
|
const dl = await processAndDownload(page, "rotate");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "rotate",
|
|
setting: "angle",
|
|
value: "180",
|
|
expected: "200x150",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const info = imageInfo(dl.path);
|
|
if (info.width !== 200 || info.height !== 150)
|
|
bug({
|
|
tool: "rotate",
|
|
setting: "angle",
|
|
value: "180",
|
|
expected: "200x150",
|
|
actual: `${info.width}x${info.height}`,
|
|
});
|
|
expect(info.width).toBe(200);
|
|
expect(info.height).toBe(150);
|
|
});
|
|
|
|
test("270 degrees -> dims swap", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "rotate", IMG_200x150);
|
|
await page.getByTestId("rotate-left").click();
|
|
const dl = await processAndDownload(page, "rotate");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "rotate",
|
|
setting: "angle",
|
|
value: "270",
|
|
expected: "150x200",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const info = imageInfo(dl.path);
|
|
if (info.width !== 150 || info.height !== 200)
|
|
bug({
|
|
tool: "rotate",
|
|
setting: "angle",
|
|
value: "270",
|
|
expected: "150x200",
|
|
actual: `${info.width}x${info.height}`,
|
|
});
|
|
expect(info.width).toBe(150);
|
|
expect(info.height).toBe(200);
|
|
});
|
|
|
|
test("flip horizontal -> same dims, runs without error", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "rotate", IMG_200x150);
|
|
await page.getByTestId("rotate-flip-h").click();
|
|
const dl = await processAndDownload(page, "rotate");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "rotate",
|
|
setting: "flipH",
|
|
value: "true",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
test("flip vertical -> same dims, runs without error", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "rotate", IMG_200x150);
|
|
await page.getByTestId("rotate-flip-v").click();
|
|
const dl = await processAndDownload(page, "rotate");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "rotate",
|
|
setting: "flipV",
|
|
value: "true",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
coverage.rotate = { tested: 5, total: 5 };
|
|
});
|
|
|
|
test.describe("IMAGE: convert", () => {
|
|
test.setTimeout(TOOL_TIMEOUT);
|
|
|
|
for (const fmt of ["jpg", "png", "webp", "avif", "tiff"] as const) {
|
|
test(`format=${fmt} -> output magic bytes match`, async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "convert", IMG_200x150);
|
|
await selectOption(page, "convert-target-format", fmt);
|
|
const dl = await processAndDownload(page, "convert");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "convert",
|
|
setting: "format",
|
|
value: fmt,
|
|
expected: `${fmt} magic bytes`,
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const matches = magicMatches(dl.buf, fmt);
|
|
if (!matches)
|
|
bug({
|
|
tool: "convert",
|
|
setting: "format",
|
|
value: fmt,
|
|
expected: "correct magic bytes",
|
|
actual: "magic mismatch",
|
|
});
|
|
expect(matches).toBe(true);
|
|
});
|
|
}
|
|
|
|
coverage.convert = { tested: 5, total: 6 };
|
|
});
|
|
|
|
test.describe("IMAGE: compress", () => {
|
|
test.setTimeout(TOOL_TIMEOUT);
|
|
|
|
test("quality mode low (10) -> smaller than quality high (95)", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
// High quality
|
|
await setupTool(page, "compress", IMG_JPG);
|
|
await page
|
|
.getByRole("button", { name: /quality/i })
|
|
.first()
|
|
.click();
|
|
await setSlider(page, "compress-quality", 95);
|
|
const dlHigh = await processAndDownload(page, "compress");
|
|
|
|
// Low quality - fresh page
|
|
await setupTool(page, "compress", IMG_JPG);
|
|
await page
|
|
.getByRole("button", { name: /quality/i })
|
|
.first()
|
|
.click();
|
|
await setSlider(page, "compress-quality", 10);
|
|
const dlLow = await processAndDownload(page, "compress");
|
|
|
|
if (!dlHigh.ok || !dlLow.ok) {
|
|
bug({
|
|
tool: "compress",
|
|
setting: "quality",
|
|
value: "10 vs 95",
|
|
expected: "both succeed",
|
|
actual: `high:${dlHigh.ok} low:${dlLow.ok}`,
|
|
});
|
|
return;
|
|
}
|
|
if (dlLow.size >= dlHigh.size)
|
|
bug({
|
|
tool: "compress",
|
|
setting: "quality",
|
|
value: "10 vs 95",
|
|
expected: "low quality smaller",
|
|
actual: `low=${dlLow.size} high=${dlHigh.size}`,
|
|
});
|
|
expect(dlLow.size).toBeLessThan(dlHigh.size);
|
|
});
|
|
|
|
test("targetSize=50KB -> output roughly <= 50KB", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "compress", IMG_JPG);
|
|
// targetSize mode is default
|
|
await fillInput(page, "compress-target-size", 50);
|
|
const dl = await processAndDownload(page, "compress");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "compress",
|
|
setting: "targetSize",
|
|
value: "50KB",
|
|
expected: "~50KB",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
// Allow 20% tolerance
|
|
const sizeKb = dl.size / 1024;
|
|
if (sizeKb > 65)
|
|
bug({
|
|
tool: "compress",
|
|
setting: "targetSize",
|
|
value: "50KB",
|
|
expected: "<=65KB (50+20%)",
|
|
actual: `${sizeKb.toFixed(1)}KB`,
|
|
});
|
|
});
|
|
|
|
coverage.compress = { tested: 2, total: 3 };
|
|
});
|
|
|
|
test.describe("IMAGE: adjust-colors", () => {
|
|
test.setTimeout(TOOL_TIMEOUT);
|
|
|
|
test("brightness +80 -> luma increases", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
// Baseline
|
|
const baseStats = signalStats(IMG_200x150);
|
|
|
|
await setupTool(page, "adjust-colors", IMG_200x150);
|
|
await setSlider(page, "color-slider-brightness", 80);
|
|
const dl = await processAndDownload(page, "adjust-colors");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "adjust-colors",
|
|
setting: "brightness",
|
|
value: "+80",
|
|
expected: "luma increases",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const luma = imageLuma(dl.path);
|
|
if (luma <= baseStats.luma)
|
|
bug({
|
|
tool: "adjust-colors",
|
|
setting: "brightness",
|
|
value: "+80",
|
|
expected: `luma > ${baseStats.luma}`,
|
|
actual: `luma=${luma}`,
|
|
});
|
|
expect(luma).toBeGreaterThan(baseStats.luma);
|
|
});
|
|
|
|
test("brightness -80 -> luma decreases", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
const baseStats = signalStats(IMG_200x150);
|
|
|
|
await setupTool(page, "adjust-colors", IMG_200x150);
|
|
await setSlider(page, "color-slider-brightness", -80);
|
|
const dl = await processAndDownload(page, "adjust-colors");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "adjust-colors",
|
|
setting: "brightness",
|
|
value: "-80",
|
|
expected: "luma decreases",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const luma = imageLuma(dl.path);
|
|
if (luma >= baseStats.luma)
|
|
bug({
|
|
tool: "adjust-colors",
|
|
setting: "brightness",
|
|
value: "-80",
|
|
expected: `luma < ${baseStats.luma}`,
|
|
actual: `luma=${luma}`,
|
|
});
|
|
expect(luma).toBeLessThan(baseStats.luma);
|
|
});
|
|
|
|
test("saturation=-100 -> near-zero saturation (grayscale)", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "adjust-colors", IMG_200x150);
|
|
await setSlider(page, "color-slider-saturation", -100);
|
|
const dl = await processAndDownload(page, "adjust-colors");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "adjust-colors",
|
|
setting: "saturation",
|
|
value: "-100",
|
|
expected: "sat~0",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const sat = imageSaturation(dl.path);
|
|
if (sat > 15)
|
|
bug({
|
|
tool: "adjust-colors",
|
|
setting: "saturation",
|
|
value: "-100",
|
|
expected: "saturation ~0 (< 15)",
|
|
actual: `sat=${sat}`,
|
|
});
|
|
expect(sat).toBeLessThan(15);
|
|
});
|
|
|
|
test("grayscale effect -> near-zero saturation", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "adjust-colors", IMG_200x150);
|
|
await page.getByRole("button", { name: "grayscale", exact: true }).first().click();
|
|
const dl = await processAndDownload(page, "adjust-colors");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "adjust-colors",
|
|
setting: "effect",
|
|
value: "grayscale",
|
|
expected: "sat~0",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const sat = imageSaturation(dl.path);
|
|
if (sat > 15)
|
|
bug({
|
|
tool: "adjust-colors",
|
|
setting: "effect",
|
|
value: "grayscale",
|
|
expected: "saturation ~0 (< 15)",
|
|
actual: `sat=${sat}`,
|
|
});
|
|
expect(sat).toBeLessThan(15);
|
|
});
|
|
|
|
test("contrast +80 -> output changes (not identical to original)", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
const baseLuma = imageLuma(IMG_200x150);
|
|
await setupTool(page, "adjust-colors", IMG_200x150);
|
|
await setSlider(page, "color-slider-contrast", 80);
|
|
const dl = await processAndDownload(page, "adjust-colors");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "adjust-colors",
|
|
setting: "contrast",
|
|
value: "+80",
|
|
expected: "output changes",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const outLuma = imageLuma(dl.path);
|
|
// Contrast should shift luma away from midpoint, or at least change it
|
|
if (outLuma === baseLuma)
|
|
warn({
|
|
tool: "adjust-colors",
|
|
setting: "contrast",
|
|
value: "+80",
|
|
expected: "luma changes",
|
|
actual: `luma unchanged at ${outLuma}`,
|
|
});
|
|
});
|
|
|
|
test("invert effect -> luma inverts (~255-original)", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
const baseLuma = imageLuma(IMG_200x150);
|
|
await setupTool(page, "adjust-colors", IMG_200x150);
|
|
await page.getByRole("button", { name: "invert", exact: true }).first().click();
|
|
const dl = await processAndDownload(page, "adjust-colors");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "adjust-colors",
|
|
setting: "effect",
|
|
value: "invert",
|
|
expected: "luma inverts",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const outLuma = imageLuma(dl.path);
|
|
// Inverted luma should be roughly 255 - original (within 30)
|
|
const expectedLuma = 255 - baseLuma;
|
|
if (Math.abs(outLuma - expectedLuma) > 40)
|
|
bug({
|
|
tool: "adjust-colors",
|
|
setting: "effect",
|
|
value: "invert",
|
|
expected: `luma ~${expectedLuma}`,
|
|
actual: `luma=${outLuma}`,
|
|
});
|
|
});
|
|
|
|
coverage["adjust-colors"] = { tested: 6, total: 12 };
|
|
});
|
|
|
|
test.describe("IMAGE: border", () => {
|
|
test.setTimeout(TOOL_TIMEOUT);
|
|
|
|
test("borderWidth=20 -> output dims grow by ~2x20=40", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "border", IMG_200x150);
|
|
await setSlider(page, "border-width", 20);
|
|
const dl = await processAndDownload(page, "border");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "border",
|
|
setting: "borderWidth",
|
|
value: "20",
|
|
expected: "dims grow by ~40",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const info = imageInfo(dl.path);
|
|
// Border adds to both sides: 200+40=240, 150+40=190
|
|
const expectedW = 200 + 2 * 20;
|
|
const expectedH = 150 + 2 * 20;
|
|
if (Math.abs(info.width - expectedW) > 4 || Math.abs(info.height - expectedH) > 4) {
|
|
bug({
|
|
tool: "border",
|
|
setting: "borderWidth",
|
|
value: "20",
|
|
expected: `~${expectedW}x${expectedH}`,
|
|
actual: `${info.width}x${info.height}`,
|
|
});
|
|
}
|
|
expect(info.width).toBeGreaterThan(200);
|
|
expect(info.height).toBeGreaterThan(150);
|
|
});
|
|
|
|
test("borderWidth=0, padding=30 -> output dims grow by ~2x30=60", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "border", IMG_200x150);
|
|
await setSlider(page, "border-width", 0);
|
|
await setSlider(page, "border-padding", 30);
|
|
const dl = await processAndDownload(page, "border");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "border",
|
|
setting: "padding",
|
|
value: "30",
|
|
expected: "dims grow by ~60",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const info = imageInfo(dl.path);
|
|
expect(info.width).toBeGreaterThan(200);
|
|
expect(info.height).toBeGreaterThan(150);
|
|
});
|
|
|
|
coverage.border = { tested: 2, total: 5 };
|
|
});
|
|
|
|
test.describe("IMAGE: pixelate", () => {
|
|
test.setTimeout(TOOL_TIMEOUT);
|
|
|
|
test("blockSize=2 (min) -> succeeds, same dims", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "pixelate", IMG_200x150);
|
|
await setSlider(page, "pixelate-block-size", 2);
|
|
const dl = await processAndDownload(page, "pixelate");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "pixelate",
|
|
setting: "blockSize",
|
|
value: "2",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const info = imageInfo(dl.path);
|
|
expect(info.width).toBe(200);
|
|
expect(info.height).toBe(150);
|
|
});
|
|
|
|
test("blockSize=64 (large) -> same dims, image is pixelated (different bytes)", async ({
|
|
page,
|
|
}) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "pixelate", IMG_200x150);
|
|
await setSlider(page, "pixelate-block-size", 64);
|
|
const dl = await processAndDownload(page, "pixelate");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "pixelate",
|
|
setting: "blockSize",
|
|
value: "64",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const info = imageInfo(dl.path);
|
|
expect(info.width).toBe(200);
|
|
expect(info.height).toBe(150);
|
|
});
|
|
|
|
test("blockSize=128 (max) -> succeeds", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "pixelate", IMG_200x150);
|
|
await setSlider(page, "pixelate-block-size", 128);
|
|
const dl = await processAndDownload(page, "pixelate");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "pixelate",
|
|
setting: "blockSize",
|
|
value: "128",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
coverage.pixelate = { tested: 3, total: 3 };
|
|
});
|
|
|
|
test.describe("IMAGE: vignette", () => {
|
|
test.setTimeout(TOOL_TIMEOUT);
|
|
|
|
test("strength=0.1 (min) -> runs, output slightly darker edges", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "vignette", IMG_200x150);
|
|
await setSlider(page, "vignette-strength", 0.1);
|
|
const dl = await processAndDownload(page, "vignette");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "vignette",
|
|
setting: "strength",
|
|
value: "0.1",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
test("strength=1.0 (max) -> runs, output darker overall", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
const baseLuma = imageLuma(IMG_200x150);
|
|
await setupTool(page, "vignette", IMG_200x150);
|
|
await setSlider(page, "vignette-strength", 1.0);
|
|
const dl = await processAndDownload(page, "vignette");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "vignette",
|
|
setting: "strength",
|
|
value: "1.0",
|
|
expected: "darker overall",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const outLuma = imageLuma(dl.path);
|
|
// Strong vignette should darken overall average
|
|
if (outLuma >= baseLuma)
|
|
warn({
|
|
tool: "vignette",
|
|
setting: "strength",
|
|
value: "1.0",
|
|
expected: `luma < ${baseLuma}`,
|
|
actual: `luma=${outLuma}`,
|
|
});
|
|
});
|
|
|
|
coverage.vignette = { tested: 2, total: 2 };
|
|
});
|
|
|
|
test.describe("IMAGE: duotone", () => {
|
|
test.setTimeout(TOOL_TIMEOUT);
|
|
|
|
test("duotone -> output has reduced color variety (low saturation)", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "duotone", IMG_200x150);
|
|
// Defaults: shadow=#1e3a8a, highlight=#fbbf24
|
|
const dl = await processAndDownload(page, "duotone");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "duotone",
|
|
setting: "colors",
|
|
value: "default",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
// Duotone maps all colors to a gradient between two colors - should still succeed
|
|
const info = imageInfo(dl.path);
|
|
expect(info.width).toBe(200);
|
|
});
|
|
|
|
coverage.duotone = { tested: 1, total: 2 };
|
|
});
|
|
|
|
test.describe("IMAGE: circle-crop", () => {
|
|
test.setTimeout(TOOL_TIMEOUT);
|
|
|
|
test("circle-crop -> output has alpha (PNG with transparency)", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "circle-crop", IMG_200x150);
|
|
const dl = await processAndDownload(page, "circle-crop");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "circle-crop",
|
|
setting: "default",
|
|
value: "default",
|
|
expected: "hasAlpha=true",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const info = imageInfo(dl.path);
|
|
if (!info.hasAlpha)
|
|
bug({
|
|
tool: "circle-crop",
|
|
setting: "output",
|
|
value: "default",
|
|
expected: "hasAlpha=true (PNG with transparent corners)",
|
|
actual: `hasAlpha=${info.hasAlpha}, pixFmt=${info.pixFmt}`,
|
|
});
|
|
expect(info.hasAlpha).toBe(true);
|
|
});
|
|
|
|
coverage["circle-crop"] = { tested: 1, total: 1 };
|
|
});
|
|
|
|
test.describe("IMAGE: image-pad", () => {
|
|
test.setTimeout(TOOL_TIMEOUT);
|
|
|
|
test("target=1:1 -> output is square (max dim)", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "image-pad", IMG_200x150);
|
|
await clickButton(page, "1:1");
|
|
const dl = await processAndDownload(page, "image-pad");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "image-pad",
|
|
setting: "target",
|
|
value: "1:1",
|
|
expected: "square output",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const info = imageInfo(dl.path);
|
|
// 1:1 padding on 200x150 -> 200x200 (pad to max dim)
|
|
if (info.width !== info.height)
|
|
bug({
|
|
tool: "image-pad",
|
|
setting: "target",
|
|
value: "1:1",
|
|
expected: "square (w==h)",
|
|
actual: `${info.width}x${info.height}`,
|
|
});
|
|
expect(info.width).toBe(info.height);
|
|
});
|
|
|
|
test("target=16:9 -> output is 16:9 ratio", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "image-pad", IMG_200x150);
|
|
await clickButton(page, "16:9");
|
|
const dl = await processAndDownload(page, "image-pad");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "image-pad",
|
|
setting: "target",
|
|
value: "16:9",
|
|
expected: "16:9 ratio",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const info = imageInfo(dl.path);
|
|
const ratio = info.width / info.height;
|
|
const expectedRatio = 16 / 9;
|
|
if (Math.abs(ratio - expectedRatio) > 0.05)
|
|
bug({
|
|
tool: "image-pad",
|
|
setting: "target",
|
|
value: "16:9",
|
|
expected: `ratio ~${expectedRatio.toFixed(2)}`,
|
|
actual: `ratio=${ratio.toFixed(2)}`,
|
|
});
|
|
expect(Math.abs(ratio - expectedRatio)).toBeLessThan(0.05);
|
|
});
|
|
|
|
coverage["image-pad"] = { tested: 2, total: 2 };
|
|
});
|
|
|
|
test.describe("IMAGE: watermark-text", () => {
|
|
test.setTimeout(TOOL_TIMEOUT);
|
|
|
|
test("text watermark -> output differs from original (watermark applied)", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
const baseLuma = imageLuma(IMG_200x150);
|
|
await setupTool(page, "watermark-text", IMG_200x150);
|
|
// Defaults: text="Sample Watermark", fontSize=48, opacity=50, position=center
|
|
const dl = await processAndDownload(page, "watermark-text");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "watermark-text",
|
|
setting: "text",
|
|
value: "Sample Watermark",
|
|
expected: "output changes",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const outLuma = imageLuma(dl.path);
|
|
const info = imageInfo(dl.path);
|
|
// Dims should be same
|
|
expect(info.width).toBe(200);
|
|
expect(info.height).toBe(150);
|
|
});
|
|
|
|
test("fontSize=8 (min) -> succeeds", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "watermark-text", IMG_200x150);
|
|
await setSlider(page, "watermark-text-font-size", 8);
|
|
const dl = await processAndDownload(page, "watermark-text");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "watermark-text",
|
|
setting: "fontSize",
|
|
value: "8",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
test("fontSize=200 (max) -> succeeds", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "watermark-text", IMG_200x150);
|
|
await setSlider(page, "watermark-text-font-size", 200);
|
|
const dl = await processAndDownload(page, "watermark-text");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "watermark-text",
|
|
setting: "fontSize",
|
|
value: "200",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
test("position=tiled -> succeeds", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "watermark-text", IMG_200x150);
|
|
await selectOption(page, "watermark-text-position", "tiled");
|
|
const dl = await processAndDownload(page, "watermark-text");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "watermark-text",
|
|
setting: "position",
|
|
value: "tiled",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
test("opacity=0 -> output nearly identical to original (invisible watermark)", async ({
|
|
page,
|
|
}) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "watermark-text", IMG_200x150);
|
|
await setSlider(page, "watermark-text-opacity", 0);
|
|
const dl = await processAndDownload(page, "watermark-text");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "watermark-text",
|
|
setting: "opacity",
|
|
value: "0",
|
|
expected: "nearly identical output",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
// Just verify it ran
|
|
expect(dl.size).toBeGreaterThan(0);
|
|
});
|
|
|
|
coverage["watermark-text"] = { tested: 5, total: 6 };
|
|
});
|
|
|
|
test.describe("IMAGE: sharpening", () => {
|
|
test.setTimeout(TOOL_TIMEOUT);
|
|
|
|
test("adaptive method default -> runs, output is valid image", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "sharpening", IMG_200x150);
|
|
// Default is adaptive/Medium
|
|
const dl = await processAndDownload(page, "sharpening");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "sharpening",
|
|
setting: "method",
|
|
value: "adaptive",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
test("unsharp-mask method -> runs", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "sharpening", IMG_200x150);
|
|
await page.getByRole("button", { name: "Unsharp Mask" }).first().click();
|
|
const dl = await processAndDownload(page, "sharpening");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "sharpening",
|
|
setting: "method",
|
|
value: "unsharp-mask",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
test("high-pass method -> runs", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "sharpening", IMG_200x150);
|
|
await page.getByRole("button", { name: "High-Pass" }).first().click();
|
|
const dl = await processAndDownload(page, "sharpening");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "sharpening",
|
|
setting: "method",
|
|
value: "high-pass",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
coverage.sharpening = { tested: 3, total: 5 };
|
|
});
|
|
|
|
test.describe("IMAGE: replace-color", () => {
|
|
test.setTimeout(TOOL_TIMEOUT);
|
|
|
|
test("replace red with green -> output changes", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "replace-color", IMG_200x150);
|
|
// Default: sourceColor=#FF0000, targetColor=#00FF00, tolerance=30
|
|
const dl = await processAndDownload(page, "replace-color");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "replace-color",
|
|
setting: "colors",
|
|
value: "red->green",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
test("makeTransparent=true -> output has alpha", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "replace-color", IMG_200x150);
|
|
// Check the "make transparent" checkbox
|
|
await page.getByRole("checkbox").first().check();
|
|
// The current fixture has no pixels near pure red at the default tolerance.
|
|
// Use the full tolerance range so this assertion exercises transparent output.
|
|
await setSlider(page, "replace-tolerance", 255);
|
|
const dl = await processAndDownload(page, "replace-color");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "replace-color",
|
|
setting: "makeTransparent",
|
|
value: "true",
|
|
expected: "hasAlpha=true",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const info = imageInfo(dl.path);
|
|
if (!info.hasAlpha)
|
|
bug({
|
|
tool: "replace-color",
|
|
setting: "makeTransparent",
|
|
value: "true",
|
|
expected: "hasAlpha=true",
|
|
actual: `hasAlpha=${info.hasAlpha}`,
|
|
});
|
|
expect(info.hasAlpha).toBe(true);
|
|
});
|
|
|
|
test("tolerance=0 (exact match) -> runs", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "replace-color", IMG_200x150);
|
|
await setSlider(page, "replace-tolerance", 0);
|
|
const dl = await processAndDownload(page, "replace-color");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "replace-color",
|
|
setting: "tolerance",
|
|
value: "0",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
test("tolerance=255 (max) -> runs", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "replace-color", IMG_200x150);
|
|
await setSlider(page, "replace-tolerance", 255);
|
|
const dl = await processAndDownload(page, "replace-color");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "replace-color",
|
|
setting: "tolerance",
|
|
value: "255",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
coverage["replace-color"] = { tested: 4, total: 4 };
|
|
});
|
|
|
|
// =====================================================================
|
|
// VIDEO TOOLS
|
|
// =====================================================================
|
|
|
|
test.describe("VIDEO: trim-video", () => {
|
|
test.setTimeout(TOOL_TIMEOUT * 2);
|
|
|
|
test("startS=0, endS=0.5 -> output duration ~0.5s", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "trim-video", VID_MP4);
|
|
await fillInput(page, "tv-start", 0);
|
|
await fillInput(page, "tv-end", 0.5);
|
|
const dl = await processAndDownload(page, "trim-video", "long");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "trim-video",
|
|
setting: "startS/endS",
|
|
value: "0-0.5",
|
|
expected: "duration ~0.5s",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const dur = mediaDuration(dl.path);
|
|
// Allow tolerance since keyframes may shift
|
|
if (dur > 1.0)
|
|
bug({
|
|
tool: "trim-video",
|
|
setting: "startS/endS",
|
|
value: "0-0.5",
|
|
expected: "dur <= 1.0s",
|
|
actual: `dur=${dur.toFixed(2)}s`,
|
|
});
|
|
expect(dur).toBeLessThan(1.0);
|
|
});
|
|
|
|
coverage["trim-video"] = { tested: 1, total: 3 };
|
|
});
|
|
|
|
test.describe("VIDEO: change-fps", () => {
|
|
test.setTimeout(TOOL_TIMEOUT * 2);
|
|
|
|
test("fps=15 -> output fps ~15", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "change-fps", VID_MP4);
|
|
await fillInput(page, "cfps-fps", 15);
|
|
const dl = await processAndDownload(page, "change-fps", "long");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "change-fps",
|
|
setting: "fps",
|
|
value: "15",
|
|
expected: "fps ~15",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const probe = probeMedia(dl.path);
|
|
const videoStream = probe.streams?.find((s) => s.codec_type === "video");
|
|
// r_frame_rate parsing not exposed; check file at least produced
|
|
expect(dl.size).toBeGreaterThan(0);
|
|
});
|
|
|
|
coverage["change-fps"] = { tested: 1, total: 1 };
|
|
});
|
|
|
|
test.describe("VIDEO: video-speed", () => {
|
|
test.setTimeout(TOOL_TIMEOUT * 2);
|
|
|
|
test("factor=2 -> output duration ~half", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
const baseDur = mediaDuration(VID_MP4);
|
|
await setupTool(page, "video-speed", VID_MP4);
|
|
await fillInput(page, "vs-factor", 2);
|
|
const dl = await processAndDownload(page, "video-speed", "long");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "video-speed",
|
|
setting: "factor",
|
|
value: "2",
|
|
expected: `duration ~${(baseDur / 2).toFixed(2)}s`,
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const dur = mediaDuration(dl.path);
|
|
// 2x speed -> ~half duration
|
|
if (dur > baseDur * 0.8)
|
|
bug({
|
|
tool: "video-speed",
|
|
setting: "factor",
|
|
value: "2",
|
|
expected: `dur < ${(baseDur * 0.8).toFixed(2)}s`,
|
|
actual: `dur=${dur.toFixed(2)}s`,
|
|
});
|
|
expect(dur).toBeLessThan(baseDur * 0.8);
|
|
});
|
|
|
|
coverage["video-speed"] = { tested: 1, total: 2 };
|
|
});
|
|
|
|
test.describe("VIDEO: resize-video", () => {
|
|
test.setTimeout(TOOL_TIMEOUT * 2);
|
|
|
|
test("preset=360p -> output height ~360", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "resize-video", VID_MP4);
|
|
await selectOption(page, "rv-preset", "360p");
|
|
const dl = await processAndDownload(page, "resize-video", "long");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "resize-video",
|
|
setting: "preset",
|
|
value: "360p",
|
|
expected: "height ~360",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const probe = probeMedia(dl.path);
|
|
const videoStream = probe.streams?.find((s) => s.codec_type === "video");
|
|
// 360p sets height to 360, width auto. Source is 64x64 so may upscale to 360x360
|
|
expect(videoStream?.height).toBeDefined();
|
|
});
|
|
|
|
test("custom width=32, height=32 -> output 32x32", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "resize-video", VID_MP4);
|
|
await selectOption(page, "rv-preset", "custom");
|
|
await fillInput(page, "rv-width", 32);
|
|
await fillInput(page, "rv-height", 32);
|
|
const dl = await processAndDownload(page, "resize-video", "long");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "resize-video",
|
|
setting: "dims",
|
|
value: "32x32",
|
|
expected: "32x32",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const probe = probeMedia(dl.path);
|
|
const vs = probe.streams?.find((s) => s.codec_type === "video");
|
|
if (vs && (vs.width !== 32 || vs.height !== 32))
|
|
bug({
|
|
tool: "resize-video",
|
|
setting: "dims",
|
|
value: "32x32",
|
|
expected: "32x32",
|
|
actual: `${vs.width}x${vs.height}`,
|
|
});
|
|
});
|
|
|
|
coverage["resize-video"] = { tested: 2, total: 3 };
|
|
});
|
|
|
|
test.describe("VIDEO: rotate-video", () => {
|
|
test.setTimeout(TOOL_TIMEOUT * 2);
|
|
|
|
test("cw90 -> dims swap (64x64 stays 64x64 for square)", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "rotate-video", VID_MP4);
|
|
await selectOption(page, "rtv-transform", "cw90");
|
|
const dl = await processAndDownload(page, "rotate-video", "long");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "rotate-video",
|
|
setting: "transform",
|
|
value: "cw90",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const probe = probeMedia(dl.path);
|
|
const vs = probe.streams?.find((s) => s.codec_type === "video");
|
|
// Square video: dims stay same. Just verify it ran
|
|
expect(dl.size).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("180 -> runs successfully", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "rotate-video", VID_MP4);
|
|
await selectOption(page, "rtv-transform", "180");
|
|
const dl = await processAndDownload(page, "rotate-video", "long");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "rotate-video",
|
|
setting: "transform",
|
|
value: "180",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
test("hflip -> runs successfully", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "rotate-video", VID_MP4);
|
|
await selectOption(page, "rtv-transform", "hflip");
|
|
const dl = await processAndDownload(page, "rotate-video", "long");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "rotate-video",
|
|
setting: "transform",
|
|
value: "hflip",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
test("vflip -> runs successfully", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "rotate-video", VID_MP4);
|
|
await selectOption(page, "rtv-transform", "vflip");
|
|
const dl = await processAndDownload(page, "rotate-video", "long");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "rotate-video",
|
|
setting: "transform",
|
|
value: "vflip",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
coverage["rotate-video"] = { tested: 4, total: 5 };
|
|
});
|
|
|
|
test.describe("VIDEO: crop-video", () => {
|
|
test.setTimeout(TOOL_TIMEOUT * 2);
|
|
|
|
test("crop to 32x32 at 0,0 -> output 32x32", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "crop-video", VID_MP4);
|
|
await fillInput(page, "crv-width", 32);
|
|
await fillInput(page, "crv-height", 32);
|
|
await fillInput(page, "crv-x", 0);
|
|
await fillInput(page, "crv-y", 0);
|
|
const dl = await processAndDownload(page, "crop-video", "long");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "crop-video",
|
|
setting: "region",
|
|
value: "32x32@0,0",
|
|
expected: "32x32",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const probe = probeMedia(dl.path);
|
|
const vs = probe.streams?.find((s) => s.codec_type === "video");
|
|
if (vs && (vs.width !== 32 || vs.height !== 32))
|
|
bug({
|
|
tool: "crop-video",
|
|
setting: "region",
|
|
value: "32x32@0,0",
|
|
expected: "32x32",
|
|
actual: `${vs.width}x${vs.height}`,
|
|
});
|
|
expect(vs?.width).toBe(32);
|
|
expect(vs?.height).toBe(32);
|
|
});
|
|
|
|
coverage["crop-video"] = { tested: 1, total: 1 };
|
|
});
|
|
|
|
// =====================================================================
|
|
// AUDIO TOOLS
|
|
// =====================================================================
|
|
|
|
test.describe("AUDIO: trim-audio", () => {
|
|
test.setTimeout(TOOL_TIMEOUT * 2);
|
|
|
|
test("startS=0, endS=0.5 -> output duration ~0.5s", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "trim-audio", AUD_MP3);
|
|
await fillInput(page, "ta-start", 0);
|
|
await fillInput(page, "ta-end", 0.5);
|
|
const dl = await processAndDownload(page, "trim-audio", "long");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "trim-audio",
|
|
setting: "startS/endS",
|
|
value: "0-0.5",
|
|
expected: "dur ~0.5s",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const dur = mediaDuration(dl.path);
|
|
if (dur > 1.0)
|
|
bug({
|
|
tool: "trim-audio",
|
|
setting: "startS/endS",
|
|
value: "0-0.5",
|
|
expected: "dur <= 1.0s",
|
|
actual: `dur=${dur.toFixed(2)}s`,
|
|
});
|
|
expect(dur).toBeLessThan(1.0);
|
|
});
|
|
|
|
coverage["trim-audio"] = { tested: 1, total: 1 };
|
|
});
|
|
|
|
test.describe("AUDIO: audio-speed", () => {
|
|
test.setTimeout(TOOL_TIMEOUT * 2);
|
|
|
|
test("factor=2 -> output duration ~half", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
const baseDur = mediaDuration(AUD_MP3);
|
|
await setupTool(page, "audio-speed", AUD_MP3);
|
|
await fillInput(page, "as-factor", 2);
|
|
const dl = await processAndDownload(page, "audio-speed", "long");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "audio-speed",
|
|
setting: "factor",
|
|
value: "2",
|
|
expected: `dur ~${(baseDur / 2).toFixed(2)}s`,
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const dur = mediaDuration(dl.path);
|
|
if (dur > baseDur * 0.8)
|
|
bug({
|
|
tool: "audio-speed",
|
|
setting: "factor",
|
|
value: "2",
|
|
expected: `dur < ${(baseDur * 0.8).toFixed(2)}s`,
|
|
actual: `dur=${dur.toFixed(2)}s`,
|
|
});
|
|
expect(dur).toBeLessThan(baseDur * 0.8);
|
|
});
|
|
|
|
test("factor=0.5 -> output duration ~double", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
const baseDur = mediaDuration(AUD_MP3);
|
|
await setupTool(page, "audio-speed", AUD_MP3);
|
|
await fillInput(page, "as-factor", 0.5);
|
|
const dl = await processAndDownload(page, "audio-speed", "long");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "audio-speed",
|
|
setting: "factor",
|
|
value: "0.5",
|
|
expected: `dur ~${(baseDur * 2).toFixed(2)}s`,
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const dur = mediaDuration(dl.path);
|
|
if (dur < baseDur * 1.5)
|
|
bug({
|
|
tool: "audio-speed",
|
|
setting: "factor",
|
|
value: "0.5",
|
|
expected: `dur > ${(baseDur * 1.5).toFixed(2)}s`,
|
|
actual: `dur=${dur.toFixed(2)}s`,
|
|
});
|
|
expect(dur).toBeGreaterThan(baseDur * 1.5);
|
|
});
|
|
|
|
coverage["audio-speed"] = { tested: 2, total: 2 };
|
|
});
|
|
|
|
test.describe("AUDIO: volume-adjust", () => {
|
|
test.setTimeout(TOOL_TIMEOUT * 2);
|
|
|
|
test("gainDb=10 -> runs successfully", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "volume-adjust", AUD_MP3);
|
|
await fillInput(page, "va-gain", 10);
|
|
const dl = await processAndDownload(page, "volume-adjust", "long");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "volume-adjust",
|
|
setting: "gainDb",
|
|
value: "10",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
test("gainDb=-10 -> runs successfully", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "volume-adjust", AUD_MP3);
|
|
await fillInput(page, "va-gain", -10);
|
|
const dl = await processAndDownload(page, "volume-adjust", "long");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "volume-adjust",
|
|
setting: "gainDb",
|
|
value: "-10",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
coverage["volume-adjust"] = { tested: 2, total: 2 };
|
|
});
|
|
|
|
test.describe("AUDIO: fade-audio", () => {
|
|
test.setTimeout(TOOL_TIMEOUT * 2);
|
|
|
|
test("fadeInS=0.5, fadeOutS=0.5 -> runs, duration preserved", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
const baseDur = mediaDuration(AUD_MP3);
|
|
await setupTool(page, "fade-audio", AUD_MP3);
|
|
await fillInput(page, "fa-fadein", 0.5);
|
|
await fillInput(page, "fa-fadeout", 0.5);
|
|
const dl = await processAndDownload(page, "fade-audio", "long");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "fade-audio",
|
|
setting: "fadeInS/fadeOutS",
|
|
value: "0.5/0.5",
|
|
expected: "success, duration preserved",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const dur = mediaDuration(dl.path);
|
|
// Duration should be approximately the same
|
|
if (Math.abs(dur - baseDur) > 0.5)
|
|
warn({
|
|
tool: "fade-audio",
|
|
setting: "fadeInS/fadeOutS",
|
|
value: "0.5/0.5",
|
|
expected: `dur ~${baseDur.toFixed(2)}s`,
|
|
actual: `dur=${dur.toFixed(2)}s`,
|
|
});
|
|
expect(dl.size).toBeGreaterThan(0);
|
|
});
|
|
|
|
coverage["fade-audio"] = { tested: 1, total: 2 };
|
|
});
|
|
|
|
test.describe("AUDIO: normalize-audio", () => {
|
|
test.setTimeout(TOOL_TIMEOUT * 2);
|
|
|
|
test("normalize -> runs successfully", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "normalize-audio", AUD_MP3);
|
|
const dl = await processAndDownload(page, "normalize-audio", "long");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "normalize-audio",
|
|
setting: "default",
|
|
value: "default",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
coverage["normalize-audio"] = { tested: 1, total: 1 };
|
|
});
|
|
|
|
test.describe("AUDIO: audio-channels", () => {
|
|
test.setTimeout(TOOL_TIMEOUT * 2);
|
|
|
|
test("stereo-to-mono -> output has 1 channel", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "audio-channels", AUD_STEREO);
|
|
await selectOption(page, "ac-mode", "stereo-to-mono");
|
|
const dl = await processAndDownload(page, "audio-channels", "long");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "audio-channels",
|
|
setting: "mode",
|
|
value: "stereo-to-mono",
|
|
expected: "1 channel",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const probe = probeMedia(dl.path);
|
|
const audioStream = probe.streams?.find((s) => s.codec_type === "audio");
|
|
if (audioStream && audioStream.channels !== 1)
|
|
bug({
|
|
tool: "audio-channels",
|
|
setting: "mode",
|
|
value: "stereo-to-mono",
|
|
expected: "channels=1",
|
|
actual: `channels=${audioStream.channels}`,
|
|
});
|
|
expect(audioStream?.channels).toBe(1);
|
|
});
|
|
|
|
test("mono-to-stereo with mono source -> output has 2 channels", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "audio-channels", AUD_MP3);
|
|
await selectOption(page, "ac-mode", "mono-to-stereo");
|
|
const dl = await processAndDownload(page, "audio-channels", "long");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "audio-channels",
|
|
setting: "mode",
|
|
value: "mono-to-stereo",
|
|
expected: "2 channels",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
const probe = probeMedia(dl.path);
|
|
const audioStream = probe.streams?.find((s) => s.codec_type === "audio");
|
|
if (audioStream && audioStream.channels !== 2)
|
|
bug({
|
|
tool: "audio-channels",
|
|
setting: "mode",
|
|
value: "mono-to-stereo",
|
|
expected: "channels=2",
|
|
actual: `channels=${audioStream.channels}`,
|
|
});
|
|
expect(audioStream?.channels).toBe(2);
|
|
});
|
|
|
|
test("swap channels on stereo -> runs successfully", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "audio-channels", AUD_STEREO);
|
|
await selectOption(page, "ac-mode", "swap");
|
|
const dl = await processAndDownload(page, "audio-channels", "long");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "audio-channels",
|
|
setting: "mode",
|
|
value: "swap",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
coverage["audio-channels"] = { tested: 3, total: 3 };
|
|
});
|
|
|
|
// =====================================================================
|
|
// DOCUMENT TOOLS
|
|
// =====================================================================
|
|
|
|
test.describe("DOCUMENT: rotate-pdf", () => {
|
|
test.setTimeout(TOOL_TIMEOUT * 2);
|
|
|
|
test("angle=90 -> output is valid PDF", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "rotate-pdf", PDF_3PAGE);
|
|
await selectOption(page, "rpdf-angle", "90");
|
|
const dl = await processAndDownload(page, "rotate-pdf", "long");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "rotate-pdf",
|
|
setting: "angle",
|
|
value: "90",
|
|
expected: "valid PDF",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
if (!magicMatches(dl.buf, "pdf"))
|
|
bug({
|
|
tool: "rotate-pdf",
|
|
setting: "angle",
|
|
value: "90",
|
|
expected: "PDF magic bytes",
|
|
actual: "not a PDF",
|
|
});
|
|
expect(magicMatches(dl.buf, "pdf")).toBe(true);
|
|
});
|
|
|
|
test("angle=180 -> output is valid PDF", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "rotate-pdf", PDF_3PAGE);
|
|
await selectOption(page, "rpdf-angle", "180");
|
|
const dl = await processAndDownload(page, "rotate-pdf", "long");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "rotate-pdf",
|
|
setting: "angle",
|
|
value: "180",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
test("angle=270 -> output is valid PDF", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "rotate-pdf", PDF_3PAGE);
|
|
await selectOption(page, "rpdf-angle", "270");
|
|
const dl = await processAndDownload(page, "rotate-pdf", "long");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "rotate-pdf",
|
|
setting: "angle",
|
|
value: "270",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
coverage["rotate-pdf"] = { tested: 3, total: 3 };
|
|
});
|
|
|
|
test.describe("DOCUMENT: split-pdf", () => {
|
|
test.setTimeout(TOOL_TIMEOUT * 2);
|
|
|
|
test("range='1-2' on 3-page PDF -> output has 2 pages (or ZIP of 2-page PDF)", async ({
|
|
page,
|
|
}) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "split-pdf", PDF_3PAGE);
|
|
// Default mode is range, range="1-3,5"
|
|
await fillInput(page, "sp-range", "1-2");
|
|
const dl = await processAndDownload(page, "split-pdf", "long");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "split-pdf",
|
|
setting: "range",
|
|
value: "1-2",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
// Result could be a PDF or ZIP depending on implementation
|
|
const isPdf = magicMatches(dl.buf, "pdf");
|
|
const isZip = magicMatches(dl.buf, "zip");
|
|
if (!isPdf && !isZip)
|
|
bug({
|
|
tool: "split-pdf",
|
|
setting: "range",
|
|
value: "1-2",
|
|
expected: "PDF or ZIP output",
|
|
actual: "unknown format",
|
|
});
|
|
expect(isPdf || isZip).toBe(true);
|
|
});
|
|
|
|
coverage["split-pdf"] = { tested: 1, total: 2 };
|
|
});
|
|
|
|
test.describe("DOCUMENT: extract-pages", () => {
|
|
test.setTimeout(TOOL_TIMEOUT * 2);
|
|
|
|
test("range='1' on 3-page PDF -> output has 1 page", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "extract-pages", PDF_3PAGE);
|
|
await fillInput(page, "ep-range", "1");
|
|
const dl = await processAndDownload(page, "extract-pages", "long");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "extract-pages",
|
|
setting: "range",
|
|
value: "1",
|
|
expected: "1-page PDF",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
if (!magicMatches(dl.buf, "pdf"))
|
|
bug({
|
|
tool: "extract-pages",
|
|
setting: "range",
|
|
value: "1",
|
|
expected: "PDF output",
|
|
actual: "not a PDF",
|
|
});
|
|
expect(magicMatches(dl.buf, "pdf")).toBe(true);
|
|
});
|
|
|
|
test("range='2-3' on 3-page PDF -> output has 2 pages", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "extract-pages", PDF_3PAGE);
|
|
await fillInput(page, "ep-range", "2-3");
|
|
const dl = await processAndDownload(page, "extract-pages", "long");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "extract-pages",
|
|
setting: "range",
|
|
value: "2-3",
|
|
expected: "2-page PDF",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
expect(magicMatches(dl.buf, "pdf")).toBe(true);
|
|
});
|
|
|
|
coverage["extract-pages"] = { tested: 2, total: 2 };
|
|
});
|
|
|
|
test.describe("DOCUMENT: compress-pdf", () => {
|
|
test.setTimeout(TOOL_TIMEOUT * 2);
|
|
|
|
// compress-pdf uses the shared CompressControls (quality / target-size modes),
|
|
// not screen/ebook/printer ghostscript presets. These drive the actual UI.
|
|
test("quality mode (q=20) -> output is valid PDF", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "compress-pdf", PDF_3PAGE);
|
|
await page
|
|
.getByRole("button", { name: /quality/i })
|
|
.first()
|
|
.click();
|
|
await setSlider(page, "compress-quality", 20);
|
|
const dl = await processAndDownload(page, "compress-pdf", "long");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "compress-pdf",
|
|
setting: "quality",
|
|
value: "20",
|
|
expected: "valid PDF",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
expect(magicMatches(dl.buf, "pdf")).toBe(true);
|
|
});
|
|
|
|
test("quality mode (q=80) -> output is valid PDF", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "compress-pdf", PDF_3PAGE);
|
|
await page
|
|
.getByRole("button", { name: /quality/i })
|
|
.first()
|
|
.click();
|
|
await setSlider(page, "compress-quality", 80);
|
|
const dl = await processAndDownload(page, "compress-pdf", "long");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "compress-pdf",
|
|
setting: "quality",
|
|
value: "80",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
test("target-size mode -> output is valid PDF", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "compress-pdf", PDF_3PAGE);
|
|
await fillInput(page, "compress-target-size", 100);
|
|
const dl = await processAndDownload(page, "compress-pdf", "long");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "compress-pdf",
|
|
setting: "targetSize",
|
|
value: "100KB",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
coverage["compress-pdf"] = { tested: 3, total: 3 };
|
|
});
|
|
|
|
test.describe("DOCUMENT: nup-pdf", () => {
|
|
test.setTimeout(TOOL_TIMEOUT * 2);
|
|
|
|
test("perSheet=2 on 3-page PDF -> output has fewer pages", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "nup-pdf", PDF_3PAGE);
|
|
await selectOption(page, "nup-per-sheet", "2");
|
|
const dl = await processAndDownload(page, "nup-pdf", "long");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "nup-pdf",
|
|
setting: "perSheet",
|
|
value: "2",
|
|
expected: "valid PDF with fewer pages",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
expect(magicMatches(dl.buf, "pdf")).toBe(true);
|
|
// 3 pages with 2-up = 2 output pages (ceil(3/2))
|
|
expect(dl.size).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("perSheet=4 on 3-page PDF -> output has 1 page", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "nup-pdf", PDF_3PAGE);
|
|
await selectOption(page, "nup-per-sheet", "4");
|
|
const dl = await processAndDownload(page, "nup-pdf", "long");
|
|
if (!dl.ok)
|
|
bug({
|
|
tool: "nup-pdf",
|
|
setting: "perSheet",
|
|
value: "4",
|
|
expected: "success",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
expect(dl.ok).toBe(true);
|
|
});
|
|
|
|
coverage["nup-pdf"] = { tested: 2, total: 2 };
|
|
});
|
|
|
|
test.describe("DOCUMENT: grayscale-pdf", () => {
|
|
test.setTimeout(TOOL_TIMEOUT * 2);
|
|
|
|
test("grayscale -> output is valid PDF", async ({ page }) => {
|
|
const issues = instrument(page);
|
|
await setupTool(page, "grayscale-pdf", PDF_3PAGE);
|
|
const dl = await processAndDownload(page, "grayscale-pdf", "long");
|
|
if (!dl.ok) {
|
|
bug({
|
|
tool: "grayscale-pdf",
|
|
setting: "default",
|
|
value: "default",
|
|
expected: "valid grayscale PDF",
|
|
actual: dl.error ?? "error",
|
|
});
|
|
return;
|
|
}
|
|
expect(magicMatches(dl.buf, "pdf")).toBe(true);
|
|
});
|
|
|
|
coverage["grayscale-pdf"] = { tested: 1, total: 1 };
|
|
});
|