mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Merge branch 'worktree-test+suite-overhaul-and-real-fixtures' into chore/consolidate-v2.0.0
# Conflicts: # tests/integration/generated/settings-matrix.test.ts # tests/integration/platform/api.test.ts # tests/integration/platform/concurrent.test.ts # tests/integration/platform/factory-multi-input.test.ts # tests/integration/security/adversarial-comprehensive.test.ts # tests/integration/security/adversarial-coverage-gaps.test.ts # tests/integration/security/adversarial-extended.test.ts # tests/integration/security/adversarial-final-gaps.test.ts # tests/integration/security/adversarial-matrix.test.ts # tests/integration/security/adversarial-security.test.ts # tests/integration/security/adversarial.test.ts # tests/integration/tools/image/color-adjustments.test.ts
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import sharp from "sharp";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
generateBackground,
|
||||
getDominantBackground,
|
||||
} from "../../../apps/api/src/lib/beautify/backgrounds.js";
|
||||
|
||||
describe("generateBackground", () => {
|
||||
it("generates a solid color background", async () => {
|
||||
const buf = await generateBackground({
|
||||
type: "solid",
|
||||
color: "#ff0000",
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
const meta = await sharp(buf).metadata();
|
||||
expect(meta.width).toBe(200);
|
||||
expect(meta.height).toBe(100);
|
||||
expect(meta.channels).toBe(4);
|
||||
});
|
||||
|
||||
it("generates a linear gradient background", async () => {
|
||||
const buf = await generateBackground({
|
||||
type: "linear-gradient",
|
||||
stops: [
|
||||
{ color: "#667eea", position: 0 },
|
||||
{ color: "#764ba2", position: 100 },
|
||||
],
|
||||
angle: 135,
|
||||
width: 400,
|
||||
height: 300,
|
||||
});
|
||||
const meta = await sharp(buf).metadata();
|
||||
expect(meta.width).toBe(400);
|
||||
expect(meta.height).toBe(300);
|
||||
});
|
||||
|
||||
it("generates a radial gradient background", async () => {
|
||||
const buf = await generateBackground({
|
||||
type: "radial-gradient",
|
||||
stops: [
|
||||
{ color: "#667eea", position: 0 },
|
||||
{ color: "#764ba2", position: 100 },
|
||||
],
|
||||
width: 400,
|
||||
height: 300,
|
||||
});
|
||||
const meta = await sharp(buf).metadata();
|
||||
expect(meta.width).toBe(400);
|
||||
expect(meta.height).toBe(300);
|
||||
});
|
||||
|
||||
it("generates a transparent background", async () => {
|
||||
const buf = await generateBackground({
|
||||
type: "transparent",
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
const meta = await sharp(buf).metadata();
|
||||
expect(meta.width).toBe(200);
|
||||
expect(meta.height).toBe(100);
|
||||
expect(meta.hasAlpha).toBe(true);
|
||||
});
|
||||
|
||||
it("generates an image background", async () => {
|
||||
const red = await sharp({
|
||||
create: {
|
||||
width: 800,
|
||||
height: 600,
|
||||
channels: 4,
|
||||
background: { r: 255, g: 0, b: 0, alpha: 1 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const buf = await generateBackground({
|
||||
type: "image",
|
||||
imageBuffer: red,
|
||||
width: 400,
|
||||
height: 300,
|
||||
});
|
||||
const meta = await sharp(buf).metadata();
|
||||
expect(meta.width).toBe(400);
|
||||
expect(meta.height).toBe(300);
|
||||
});
|
||||
|
||||
it("generates multi-stop linear gradient", async () => {
|
||||
const buf = await generateBackground({
|
||||
type: "linear-gradient",
|
||||
stops: [
|
||||
{ color: "#0f0c29", position: 0 },
|
||||
{ color: "#302b63", position: 50 },
|
||||
{ color: "#24243e", position: 100 },
|
||||
],
|
||||
angle: 135,
|
||||
width: 400,
|
||||
height: 300,
|
||||
});
|
||||
const meta = await sharp(buf).metadata();
|
||||
expect(meta.width).toBe(400);
|
||||
expect(meta.height).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDominantBackground", () => {
|
||||
it("returns solid color for solid type", () => {
|
||||
const result = getDominantBackground({ type: "solid", color: "#ff0000" });
|
||||
expect(result).toEqual({ r: 255, g: 0, b: 0, alpha: 1 });
|
||||
});
|
||||
|
||||
it("returns last gradient stop for gradient type", () => {
|
||||
const result = getDominantBackground({
|
||||
type: "linear-gradient",
|
||||
stops: [
|
||||
{ color: "#667eea", position: 0 },
|
||||
{ color: "#764ba2", position: 100 },
|
||||
],
|
||||
});
|
||||
expect(result.r).toBe(118);
|
||||
expect(result.g).toBe(75);
|
||||
expect(result.b).toBe(162);
|
||||
});
|
||||
|
||||
it("returns transparent for transparent type", () => {
|
||||
const result = getDominantBackground({ type: "transparent" });
|
||||
expect(result.alpha).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import sharp from "sharp";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderFrame } from "../../../apps/api/src/lib/beautify/frames.js";
|
||||
|
||||
describe("renderFrame", () => {
|
||||
const makeImage = (w: number, h: number) =>
|
||||
sharp({
|
||||
create: {
|
||||
width: w,
|
||||
height: h,
|
||||
channels: 4,
|
||||
background: { r: 200, g: 200, b: 200, alpha: 1 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
it("returns original image for frame 'none'", async () => {
|
||||
const img = await makeImage(400, 300);
|
||||
const result = await renderFrame(img, "none");
|
||||
const meta = await sharp(result).metadata();
|
||||
expect(meta.width).toBe(400);
|
||||
expect(meta.height).toBe(300);
|
||||
});
|
||||
|
||||
it("renders macOS light frame with title bar above image", async () => {
|
||||
const img = await makeImage(400, 300);
|
||||
const result = await renderFrame(img, "macos-light", "My App");
|
||||
const meta = await sharp(result).metadata();
|
||||
expect(meta.width).toBe(400);
|
||||
expect(meta.height!).toBeGreaterThan(300);
|
||||
});
|
||||
|
||||
it("renders macOS dark frame", async () => {
|
||||
const img = await makeImage(400, 300);
|
||||
const result = await renderFrame(img, "macos-dark");
|
||||
const meta = await sharp(result).metadata();
|
||||
expect(meta.width).toBe(400);
|
||||
expect(meta.height!).toBeGreaterThan(300);
|
||||
});
|
||||
|
||||
it("renders Windows light frame", async () => {
|
||||
const img = await makeImage(400, 300);
|
||||
const result = await renderFrame(img, "windows-light");
|
||||
const meta = await sharp(result).metadata();
|
||||
expect(meta.width).toBe(400);
|
||||
expect(meta.height!).toBeGreaterThan(300);
|
||||
});
|
||||
|
||||
it("renders Windows dark frame", async () => {
|
||||
const img = await makeImage(400, 300);
|
||||
const result = await renderFrame(img, "windows-dark");
|
||||
const meta = await sharp(result).metadata();
|
||||
expect(meta.width).toBe(400);
|
||||
expect(meta.height!).toBeGreaterThan(300);
|
||||
});
|
||||
|
||||
it("renders browser light frame with URL bar", async () => {
|
||||
const img = await makeImage(400, 300);
|
||||
const result = await renderFrame(img, "browser-light", "example.com");
|
||||
const meta = await sharp(result).metadata();
|
||||
expect(meta.width).toBe(400);
|
||||
expect(meta.height!).toBeGreaterThan(300);
|
||||
});
|
||||
|
||||
it("renders browser dark frame", async () => {
|
||||
const img = await makeImage(400, 300);
|
||||
const result = await renderFrame(img, "browser-dark", "app.test.com");
|
||||
const meta = await sharp(result).metadata();
|
||||
expect(meta.width).toBe(400);
|
||||
expect(meta.height!).toBeGreaterThan(300);
|
||||
});
|
||||
|
||||
it("renders iPhone frame with image composited into screen area", async () => {
|
||||
const img = await makeImage(400, 800);
|
||||
const result = await renderFrame(img, "iphone");
|
||||
const meta = await sharp(result).metadata();
|
||||
expect(meta.width!).toBeGreaterThan(400);
|
||||
expect(meta.height!).toBeGreaterThan(800);
|
||||
});
|
||||
|
||||
it("renders MacBook frame", async () => {
|
||||
const img = await makeImage(800, 500);
|
||||
const result = await renderFrame(img, "macbook");
|
||||
const meta = await sharp(result).metadata();
|
||||
expect(meta.width!).toBeGreaterThan(800);
|
||||
});
|
||||
|
||||
it("renders iPad frame", async () => {
|
||||
const img = await makeImage(400, 600);
|
||||
const result = await renderFrame(img, "ipad");
|
||||
const meta = await sharp(result).metadata();
|
||||
expect(meta.width!).toBeGreaterThan(400);
|
||||
});
|
||||
|
||||
it("renders iPhone dark frame", async () => {
|
||||
const img = await makeImage(400, 800);
|
||||
const result = await renderFrame(img, "iphone-dark");
|
||||
const meta = await sharp(result).metadata();
|
||||
expect(meta.width!).toBeGreaterThan(400);
|
||||
});
|
||||
|
||||
it("handles wide images", async () => {
|
||||
const img = await makeImage(1920, 1080);
|
||||
const result = await renderFrame(img, "macos-light", "Wide App");
|
||||
const meta = await sharp(result).metadata();
|
||||
expect(meta.width).toBe(1920);
|
||||
expect(meta.height!).toBeGreaterThan(1080);
|
||||
});
|
||||
|
||||
it("handles narrow images", async () => {
|
||||
const img = await makeImage(100, 200);
|
||||
const result = await renderFrame(img, "browser-light", "narrow.app");
|
||||
const meta = await sharp(result).metadata();
|
||||
expect(meta.width).toBe(100);
|
||||
expect(meta.height!).toBeGreaterThan(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import sharp from "sharp";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyShadow } from "../../../apps/api/src/lib/beautify/shadow.js";
|
||||
|
||||
describe("applyShadow", () => {
|
||||
const makeImage = (w: number, h: number) =>
|
||||
sharp({
|
||||
create: {
|
||||
width: w,
|
||||
height: h,
|
||||
channels: 4,
|
||||
background: { r: 100, g: 100, b: 255, alpha: 1 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
it("returns larger buffer with shadow applied", async () => {
|
||||
const img = await makeImage(200, 150);
|
||||
const result = await applyShadow(img, {
|
||||
blur: 20,
|
||||
offsetX: 0,
|
||||
offsetY: 10,
|
||||
color: "#000000",
|
||||
opacity: 30,
|
||||
});
|
||||
const meta = await sharp(result.buffer).metadata();
|
||||
expect(meta.width).toBeGreaterThan(200);
|
||||
expect(meta.height).toBeGreaterThan(150);
|
||||
expect(result.padLeft).toBeGreaterThanOrEqual(0);
|
||||
expect(result.padTop).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("returns image position within shadow canvas", async () => {
|
||||
const img = await makeImage(200, 150);
|
||||
const result = await applyShadow(img, {
|
||||
blur: 20,
|
||||
offsetX: 5,
|
||||
offsetY: 10,
|
||||
color: "#000000",
|
||||
opacity: 50,
|
||||
});
|
||||
expect(result.imgX).toBeDefined();
|
||||
expect(result.imgY).toBeDefined();
|
||||
});
|
||||
|
||||
it("handles zero-blur shadow gracefully", async () => {
|
||||
const img = await makeImage(100, 100);
|
||||
const result = await applyShadow(img, {
|
||||
blur: 0,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
color: "#000000",
|
||||
opacity: 0,
|
||||
});
|
||||
const meta = await sharp(result.buffer).metadata();
|
||||
expect(meta.width).toBeGreaterThanOrEqual(100);
|
||||
});
|
||||
|
||||
it("handles negative offsets", async () => {
|
||||
const img = await makeImage(200, 150);
|
||||
const result = await applyShadow(img, {
|
||||
blur: 15,
|
||||
offsetX: -10,
|
||||
offsetY: -5,
|
||||
color: "#ff0000",
|
||||
opacity: 40,
|
||||
});
|
||||
const meta = await sharp(result.buffer).metadata();
|
||||
expect(meta.width).toBeGreaterThan(200);
|
||||
expect(meta.height).toBeGreaterThan(150);
|
||||
});
|
||||
|
||||
it("produces RGBA output with alpha channel", async () => {
|
||||
const img = await makeImage(100, 100);
|
||||
const result = await applyShadow(img, {
|
||||
blur: 10,
|
||||
offsetX: 0,
|
||||
offsetY: 5,
|
||||
color: "#000000",
|
||||
opacity: 30,
|
||||
});
|
||||
const meta = await sharp(result.buffer).metadata();
|
||||
expect(meta.hasAlpha).toBe(true);
|
||||
expect(meta.channels).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { join } from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
@@ -10,8 +9,6 @@ import {
|
||||
createGradientBackground,
|
||||
} from "../../../apps/api/src/lib/bg-effects.js";
|
||||
|
||||
const _FIXTURES = join(__dirname, "../../fixtures");
|
||||
|
||||
async function createTestImage(
|
||||
width: number,
|
||||
height: number,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { friendlyError } from "../../../apps/api/src/lib/errors.js";
|
||||
|
||||
const GENERIC = "Processing failed. The file may be in an unsupported or corrupted format.";
|
||||
|
||||
describe("friendlyError", () => {
|
||||
it("collapses raw ffmpeg stderr dumps to a safe sentence", () => {
|
||||
const dump =
|
||||
"ffmpeg exited 234: Input #0, gif ... Pixel format 'gbrap' is not widely supported. Conversion failed!";
|
||||
expect(friendlyError(dump)).toBe(GENERIC);
|
||||
});
|
||||
|
||||
it("collapses raw ffprobe stderr dumps", () => {
|
||||
expect(friendlyError("ffprobe exited 1: moov atom not found")).toBe(GENERIC);
|
||||
});
|
||||
|
||||
it("collapses python tracebacks", () => {
|
||||
expect(friendlyError("Traceback (most recent call last):\n File x\nValueError: boom")).toBe(
|
||||
GENERIC,
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses very long (>280 char) messages", () => {
|
||||
expect(friendlyError("x".repeat(400))).toBe(GENERIC);
|
||||
});
|
||||
|
||||
it("collapses multi-line dumps (>3 lines)", () => {
|
||||
expect(friendlyError("l1\nl2\nl3\nl4\nl5")).toBe(GENERIC);
|
||||
});
|
||||
|
||||
it("preserves intentional, user-facing validation messages", () => {
|
||||
for (const msg of [
|
||||
"This video has no audio track to normalize",
|
||||
"Reverse is limited to clips up to 5 minutes",
|
||||
"Crop rectangle 9999x9999+0+0 exceeds video size 640x360",
|
||||
"No subtitle track found in this video",
|
||||
]) {
|
||||
expect(friendlyError(msg)).toBe(msg);
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT collapse clean messages that merely contain tool-ish words (false-positive guard)", () => {
|
||||
// The old regex matched "conversion failed" / "pixel format" and would have
|
||||
// wrongly collapsed these legitimate messages.
|
||||
expect(friendlyError("SVG conversion failed")).toBe("SVG conversion failed");
|
||||
expect(friendlyError("PDF conversion failed")).toBe("PDF conversion failed");
|
||||
expect(friendlyError("Unsupported pixel format in source")).toBe(
|
||||
"Unsupported pixel format in source",
|
||||
);
|
||||
});
|
||||
|
||||
it("scrubs internal filesystem paths", () => {
|
||||
expect(friendlyError("decode failed at /data/ai/models/whisper")).toBe(
|
||||
"decode failed at [internal]",
|
||||
);
|
||||
expect(friendlyError("wrote /tmp/workspace/out.mp4")).toBe("wrote [internal]");
|
||||
});
|
||||
|
||||
it("is idempotent (safe to apply at every error surface)", () => {
|
||||
const dump = "ffmpeg exited 1: boom";
|
||||
expect(friendlyError(friendlyError(dump))).toBe(friendlyError(dump));
|
||||
const ok = "Region exceeds image bounds";
|
||||
expect(friendlyError(friendlyError(ok))).toBe(ok);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { EditMetadataSettings } from "../../../apps/api/src/lib/exiftool.js";
|
||||
@@ -8,8 +6,7 @@ import {
|
||||
inspectMetadata,
|
||||
writeMetadata,
|
||||
} from "../../../apps/api/src/lib/exiftool.js";
|
||||
|
||||
const FIXTURES = join(import.meta.dirname, "../../fixtures");
|
||||
import { fixtures, readFixture } from "../../fixtures/index.js";
|
||||
|
||||
describe("buildTagArgs", () => {
|
||||
it("returns empty array for empty settings", () => {
|
||||
@@ -289,33 +286,33 @@ describe("buildTagArgs", () => {
|
||||
|
||||
describe("inspectMetadata", () => {
|
||||
it("returns correct filename and fileSize for JPEG with EXIF", async () => {
|
||||
const buf = readFileSync(join(FIXTURES, "test-with-exif.jpg"));
|
||||
const buf = readFixture(fixtures.image.exifGps);
|
||||
const result = await inspectMetadata(buf, "test-with-exif.jpg");
|
||||
expect(result.filename).toBe("test-with-exif.jpg");
|
||||
expect(result.fileSize).toBe(buf.length);
|
||||
});
|
||||
|
||||
it("returns non-null exif object for JPEG with EXIF data", async () => {
|
||||
const buf = readFileSync(join(FIXTURES, "test-with-exif.jpg"));
|
||||
const buf = readFixture(fixtures.image.exifGps);
|
||||
const result = await inspectMetadata(buf, "test-with-exif.jpg");
|
||||
expect(result.exif).not.toBeNull();
|
||||
expect(typeof result.exif).toBe("object");
|
||||
});
|
||||
|
||||
it("returns keywords array (may be empty)", async () => {
|
||||
const buf = readFileSync(join(FIXTURES, "test-with-exif.jpg"));
|
||||
const buf = readFixture(fixtures.image.exifGps);
|
||||
const result = await inspectMetadata(buf, "test-with-exif.jpg");
|
||||
expect(Array.isArray(result.keywords)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns null for GPS when no GPS data present", async () => {
|
||||
const buf = readFileSync(join(FIXTURES, "test-with-exif.jpg"));
|
||||
const buf = readFixture(fixtures.image.exifGps);
|
||||
const result = await inspectMetadata(buf, "test-with-exif.jpg");
|
||||
expect(result.gps).toBeNull();
|
||||
});
|
||||
|
||||
it("works with PNG which has no EXIF -- returns null for exif, iptc, xmp, gps", async () => {
|
||||
const buf = readFileSync(join(FIXTURES, "test-1x1.png"));
|
||||
const buf = readFixture(fixtures.image.edge.px1);
|
||||
const result = await inspectMetadata(buf, "test-1x1.png");
|
||||
expect(result.filename).toBe("test-1x1.png");
|
||||
expect(result.fileSize).toBe(buf.length);
|
||||
@@ -328,13 +325,13 @@ describe("inspectMetadata", () => {
|
||||
|
||||
describe("writeMetadata", () => {
|
||||
it("empty tags array returns buffer unchanged", async () => {
|
||||
const buf = readFileSync(join(FIXTURES, "test-with-exif.jpg"));
|
||||
const buf = readFixture(fixtures.image.exifGps);
|
||||
const result = await writeMetadata(buf, "test-with-exif.jpg", []);
|
||||
expect(Buffer.compare(result, buf)).toBe(0);
|
||||
});
|
||||
|
||||
it("writing -Artist=TestArtist then inspecting shows the artist", async () => {
|
||||
const buf = readFileSync(join(FIXTURES, "test-with-exif.jpg"));
|
||||
const buf = readFixture(fixtures.image.exifGps);
|
||||
const written = await writeMetadata(buf, "test-with-exif.jpg", ["-Artist=TestArtist"]);
|
||||
const result = await inspectMetadata(written, "test-with-exif.jpg");
|
||||
expect(result.exif).not.toBeNull();
|
||||
@@ -342,7 +339,7 @@ describe("writeMetadata", () => {
|
||||
});
|
||||
|
||||
it("writing multiple tags works", async () => {
|
||||
const buf = readFileSync(join(FIXTURES, "test-with-exif.jpg"));
|
||||
const buf = readFixture(fixtures.image.exifGps);
|
||||
const written = await writeMetadata(buf, "test-with-exif.jpg", [
|
||||
"-Artist=MultiTest",
|
||||
"-Copyright=2024 Test Corp",
|
||||
@@ -353,7 +350,7 @@ describe("writeMetadata", () => {
|
||||
});
|
||||
|
||||
it("returns a valid image buffer that Sharp can read", async () => {
|
||||
const buf = readFileSync(join(FIXTURES, "test-with-exif.jpg"));
|
||||
const buf = readFixture(fixtures.image.exifGps);
|
||||
const written = await writeMetadata(buf, "test-with-exif.jpg", ["-Software=SnapOtter"]);
|
||||
const meta = await sharp(written).metadata();
|
||||
expect(meta.format).toBe("jpeg");
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decodeToSharpCompat, needsCliDecode } from "../../../apps/api/src/lib/format-decoders.js";
|
||||
import { encodeQoi } from "../../../apps/api/src/lib/format-encoders.js";
|
||||
|
||||
const FIXTURES = join(__dirname, "../../fixtures");
|
||||
import { fixtures, readFixture } from "../../fixtures/index.js";
|
||||
|
||||
function isImageMagickError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
@@ -176,7 +173,7 @@ describe("decodeToSharpCompat", () => {
|
||||
|
||||
it("decodes BMP to valid PNG", async () => {
|
||||
try {
|
||||
const input = await readFile(join(FIXTURES, "formats/sample.bmp"));
|
||||
const input = readFixture(fixtures.image.formats("bmp"));
|
||||
const result = await decodeToSharpCompat(input, "bmp");
|
||||
expect(isPng(result)).toBe(true);
|
||||
await assertValidImage(result);
|
||||
@@ -188,7 +185,7 @@ describe("decodeToSharpCompat", () => {
|
||||
|
||||
it("decodes ICO to valid PNG", async () => {
|
||||
try {
|
||||
const input = await readFile(join(FIXTURES, "formats/sample.ico"));
|
||||
const input = readFixture(fixtures.image.formats("ico"));
|
||||
const result = await decodeToSharpCompat(input, "ico");
|
||||
expect(isPng(result)).toBe(true);
|
||||
await assertValidImage(result);
|
||||
@@ -200,7 +197,7 @@ describe("decodeToSharpCompat", () => {
|
||||
|
||||
it("decodes TGA to valid PNG", async () => {
|
||||
try {
|
||||
const input = await readFile(join(FIXTURES, "formats/sample.tga"));
|
||||
const input = readFixture(fixtures.image.formats("tga"));
|
||||
const result = await decodeToSharpCompat(input, "tga");
|
||||
expect(isPng(result)).toBe(true);
|
||||
await assertValidImage(result);
|
||||
@@ -212,7 +209,7 @@ describe("decodeToSharpCompat", () => {
|
||||
|
||||
it("decodes PSD to valid PNG", async () => {
|
||||
try {
|
||||
const input = await readFile(join(FIXTURES, "formats/sample.psd"));
|
||||
const input = readFixture(fixtures.image.formats("psd"));
|
||||
const result = await decodeToSharpCompat(input, "psd");
|
||||
expect(isPng(result)).toBe(true);
|
||||
await assertValidImage(result);
|
||||
@@ -224,7 +221,7 @@ describe("decodeToSharpCompat", () => {
|
||||
|
||||
it("decodes EXR to valid PNG (requires EXR delegate)", async () => {
|
||||
try {
|
||||
const input = await readFile(join(FIXTURES, "formats/sample.exr"));
|
||||
const input = readFixture(fixtures.image.formats("exr"));
|
||||
const result = await decodeToSharpCompat(input, "exr");
|
||||
expect(isPng(result)).toBe(true);
|
||||
await assertValidImage(result);
|
||||
@@ -238,7 +235,7 @@ describe("decodeToSharpCompat", () => {
|
||||
|
||||
it("decodes HDR to valid PNG", async () => {
|
||||
try {
|
||||
const input = await readFile(join(FIXTURES, "formats/sample.hdr"));
|
||||
const input = readFixture(fixtures.image.formats("hdr"));
|
||||
const result = await decodeToSharpCompat(input, "hdr");
|
||||
expect(isPng(result)).toBe(true);
|
||||
await assertValidImage(result);
|
||||
@@ -250,7 +247,7 @@ describe("decodeToSharpCompat", () => {
|
||||
|
||||
it("decodes JXL to valid PNG", async () => {
|
||||
try {
|
||||
const input = await readFile(join(FIXTURES, "formats/sample.jxl"));
|
||||
const input = readFixture(fixtures.image.formats("jxl"));
|
||||
const result = await decodeToSharpCompat(input, "jxl");
|
||||
expect(isPng(result)).toBe(true);
|
||||
await assertValidImage(result);
|
||||
@@ -262,7 +259,7 @@ describe("decodeToSharpCompat", () => {
|
||||
|
||||
it("decodes JP2 to valid PNG", async () => {
|
||||
try {
|
||||
const input = await readFile(join(FIXTURES, "formats/sample.jp2"));
|
||||
const input = readFixture(fixtures.image.formats("jp2"));
|
||||
const result = await decodeToSharpCompat(input, "jp2");
|
||||
expect(isPng(result)).toBe(true);
|
||||
await assertValidImage(result);
|
||||
@@ -274,7 +271,7 @@ describe("decodeToSharpCompat", () => {
|
||||
|
||||
it("decodes DDS to valid PNG", async () => {
|
||||
try {
|
||||
const input = await readFile(join(FIXTURES, "formats/sample.dds"));
|
||||
const input = readFixture(fixtures.image.formats("dds"));
|
||||
const result = await decodeToSharpCompat(input, "dds");
|
||||
expect(isPng(result)).toBe(true);
|
||||
await assertValidImage(result);
|
||||
@@ -286,7 +283,7 @@ describe("decodeToSharpCompat", () => {
|
||||
|
||||
it("decodes CUR using ICO decoder to valid PNG", async () => {
|
||||
try {
|
||||
const input = await readFile(join(FIXTURES, "formats/sample.cur"));
|
||||
const input = readFixture(fixtures.image.formats("cur"));
|
||||
const result = await decodeToSharpCompat(input, "cur");
|
||||
expect(isPng(result)).toBe(true);
|
||||
await assertValidImage(result);
|
||||
@@ -298,7 +295,7 @@ describe("decodeToSharpCompat", () => {
|
||||
|
||||
it("decodes DPX to valid PNG", async () => {
|
||||
try {
|
||||
const input = await readFile(join(FIXTURES, "formats/sample.dpx"));
|
||||
const input = readFixture(fixtures.image.formats("dpx"));
|
||||
const result = await decodeToSharpCompat(input, "dpx");
|
||||
expect(isPng(result)).toBe(true);
|
||||
await assertValidImage(result);
|
||||
@@ -310,7 +307,7 @@ describe("decodeToSharpCompat", () => {
|
||||
|
||||
it("decodes FITS to valid PNG (requires FITS delegate)", async () => {
|
||||
try {
|
||||
const input = await readFile(join(FIXTURES, "formats/sample.fits"));
|
||||
const input = readFixture(fixtures.image.formats("fits"));
|
||||
const result = await decodeToSharpCompat(input, "fits");
|
||||
expect(isPng(result)).toBe(true);
|
||||
await assertValidImage(result);
|
||||
@@ -324,7 +321,7 @@ describe("decodeToSharpCompat", () => {
|
||||
|
||||
it("decodes PPM to valid PNG", async () => {
|
||||
try {
|
||||
const input = await readFile(join(FIXTURES, "formats/sample.ppm"));
|
||||
const input = readFixture(fixtures.image.formats("ppm"));
|
||||
const result = await decodeToSharpCompat(input, "ppm");
|
||||
expect(isPng(result)).toBe(true);
|
||||
await assertValidImage(result);
|
||||
@@ -336,7 +333,7 @@ describe("decodeToSharpCompat", () => {
|
||||
|
||||
it("decodes PGM to valid PNG", async () => {
|
||||
try {
|
||||
const input = await readFile(join(FIXTURES, "formats/sample.pgm"));
|
||||
const input = readFixture(fixtures.image.formats("pgm"));
|
||||
const result = await decodeToSharpCompat(input, "pgm");
|
||||
expect(isPng(result)).toBe(true);
|
||||
await assertValidImage(result);
|
||||
@@ -348,7 +345,7 @@ describe("decodeToSharpCompat", () => {
|
||||
|
||||
it("decodes PBM to valid PNG", async () => {
|
||||
try {
|
||||
const input = await readFile(join(FIXTURES, "formats/sample.pbm"));
|
||||
const input = readFixture(fixtures.image.formats("pbm"));
|
||||
const result = await decodeToSharpCompat(input, "pbm");
|
||||
expect(isPng(result)).toBe(true);
|
||||
await assertValidImage(result);
|
||||
@@ -365,7 +362,7 @@ describe("decodeToSharpCompat - individual decoder verification", () => {
|
||||
for (const fmt of formats) {
|
||||
it(`${fmt}: output has non-zero dimensions`, async () => {
|
||||
try {
|
||||
const input = await readFile(join(FIXTURES, `formats/sample.${fmt}`));
|
||||
const input = readFixture(fixtures.image.formats(fmt));
|
||||
const result = await decodeToSharpCompat(input, fmt);
|
||||
const { width, height } = await assertValidImage(result);
|
||||
expect(width).toBeGreaterThan(0);
|
||||
@@ -382,7 +379,7 @@ describe("decodeToSharpCompat - individual decoder verification", () => {
|
||||
for (const fmt of delegateFormats) {
|
||||
it(`${fmt}: output has non-zero dimensions (requires delegate)`, async () => {
|
||||
try {
|
||||
const input = await readFile(join(FIXTURES, `formats/sample.${fmt}`));
|
||||
const input = readFixture(fixtures.image.formats(fmt));
|
||||
const result = await decodeToSharpCompat(input, fmt);
|
||||
const { width, height } = await assertValidImage(result);
|
||||
expect(width).toBeGreaterThan(0);
|
||||
@@ -399,14 +396,14 @@ describe("decodeToSharpCompat - individual decoder verification", () => {
|
||||
|
||||
describe("decodeToSharpCompat - QOI decoder", () => {
|
||||
it("decodes QOI fixture to valid PNG", async () => {
|
||||
const input = await readFile(join(FIXTURES, "formats/sample.qoi"));
|
||||
const input = readFixture(fixtures.image.formats("qoi"));
|
||||
const result = await decodeToSharpCompat(input, "qoi");
|
||||
expect(isPng(result)).toBe(true);
|
||||
await assertValidImage(result);
|
||||
});
|
||||
|
||||
it("round-trips PNG -> QOI -> PNG", async () => {
|
||||
const png = await readFile(join(FIXTURES, "formats/sample.png"));
|
||||
const png = readFixture(fixtures.image.formats("png"));
|
||||
const qoi = await encodeQoi(png);
|
||||
const decoded = await decodeToSharpCompat(Buffer.from(qoi), "qoi");
|
||||
expect(isPng(decoded)).toBe(true);
|
||||
@@ -417,7 +414,7 @@ describe("decodeToSharpCompat - QOI decoder", () => {
|
||||
});
|
||||
|
||||
it("decoded QOI produces a buffer sharp can process further", async () => {
|
||||
const input = await readFile(join(FIXTURES, "formats/sample.qoi"));
|
||||
const input = readFixture(fixtures.image.formats("qoi"));
|
||||
const decoded = await decodeToSharpCompat(input, "qoi");
|
||||
const resized = await sharp(decoded).resize(10, 10).png().toBuffer();
|
||||
expect(resized.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
@@ -8,8 +7,7 @@ import {
|
||||
encodeJp2,
|
||||
encodeQoi,
|
||||
} from "../../../apps/api/src/lib/format-encoders.js";
|
||||
|
||||
const FIXTURES = join(__dirname, "../../fixtures");
|
||||
import { fixtures, readFixture } from "../../fixtures/index.js";
|
||||
|
||||
async function createTestPng(width = 50, height = 50): Promise<Buffer> {
|
||||
return sharp({
|
||||
@@ -52,7 +50,7 @@ describe("encodeQoi", () => {
|
||||
});
|
||||
|
||||
it("encodes fixture image to QOI", async () => {
|
||||
const input = await readFile(join(FIXTURES, "test-200x150.png"));
|
||||
const input = readFixture(fixtures.image.base.png200);
|
||||
const result = await encodeQoi(input);
|
||||
expect(result.length).toBeGreaterThan(100);
|
||||
});
|
||||
@@ -170,7 +168,7 @@ describe("encodeJp2", () => {
|
||||
});
|
||||
|
||||
it("produces valid output at different quality levels", async () => {
|
||||
const input = await readFile(join(FIXTURES, "test-200x150.png"));
|
||||
const input = readFixture(fixtures.image.base.png200);
|
||||
try {
|
||||
const low = await encodeJp2(input, 10);
|
||||
const high = await encodeJp2(input, 90);
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decodeHeic, ensureSharpCompat } from "../../../apps/api/src/lib/heic-converter.js";
|
||||
|
||||
const FIXTURES = join(__dirname, "../../fixtures");
|
||||
import { fixtures, readFixture } from "../../fixtures/index.js";
|
||||
|
||||
function isPng(buf: Buffer): boolean {
|
||||
return buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47;
|
||||
@@ -20,7 +18,7 @@ function makeHeicHeader(brand: string): Buffer {
|
||||
|
||||
describe("decodeHeic", () => {
|
||||
it("decodes sample.heic to a valid PNG buffer", async () => {
|
||||
const heicBuf = await readFile(join(FIXTURES, "formats/sample.heic"));
|
||||
const heicBuf = readFixture(fixtures.image.formats("heic"));
|
||||
const result = await decodeHeic(heicBuf);
|
||||
expect(isPng(result)).toBe(true);
|
||||
const meta = await sharp(result).metadata();
|
||||
@@ -29,7 +27,7 @@ describe("decodeHeic", () => {
|
||||
});
|
||||
|
||||
it("decodes sample.heif to a valid PNG buffer", { timeout: 60_000 }, async () => {
|
||||
const heifBuf = await readFile(join(FIXTURES, "formats/sample.heif"));
|
||||
const heifBuf = readFixture(fixtures.image.formats("heif"));
|
||||
const result = await decodeHeic(heifBuf);
|
||||
expect(isPng(result)).toBe(true);
|
||||
const meta = await sharp(result).metadata();
|
||||
@@ -38,7 +36,7 @@ describe("decodeHeic", () => {
|
||||
});
|
||||
|
||||
it("preserves image dimensions after decoding", async () => {
|
||||
const heicBuf = await readFile(join(FIXTURES, "formats/sample.heic"));
|
||||
const heicBuf = readFixture(fixtures.image.formats("heic"));
|
||||
const result = await decodeHeic(heicBuf);
|
||||
const meta = await sharp(result).metadata();
|
||||
expect(meta.width).toBeGreaterThan(0);
|
||||
@@ -56,7 +54,7 @@ describe("decodeHeic", () => {
|
||||
});
|
||||
|
||||
it("cleans up temp files after success", { timeout: 60_000 }, async () => {
|
||||
const heicBuf = await readFile(join(FIXTURES, "formats/sample.heic"));
|
||||
const heicBuf = readFixture(fixtures.image.formats("heic"));
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { readdirSync } = await import("node:fs");
|
||||
const beforeSet = new Set(
|
||||
@@ -75,25 +73,25 @@ describe("decodeHeic", () => {
|
||||
|
||||
describe("ensureSharpCompat", () => {
|
||||
it("decodes a HEIC buffer to PNG", async () => {
|
||||
const heicBuf = await readFile(join(FIXTURES, "formats/sample.heic"));
|
||||
const heicBuf = readFixture(fixtures.image.formats("heic"));
|
||||
const result = await ensureSharpCompat(heicBuf);
|
||||
expect(isPng(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("passes through a PNG buffer unchanged", async () => {
|
||||
const pngBuf = await readFile(join(FIXTURES, "formats/sample.png"));
|
||||
const pngBuf = readFixture(fixtures.image.formats("png"));
|
||||
const result = await ensureSharpCompat(pngBuf);
|
||||
expect(result).toBe(pngBuf);
|
||||
});
|
||||
|
||||
it("passes through a JPEG buffer unchanged", async () => {
|
||||
const jpgBuf = await readFile(join(FIXTURES, "formats/sample.jpg"));
|
||||
const jpgBuf = readFixture(fixtures.image.formats("jpg"));
|
||||
const result = await ensureSharpCompat(jpgBuf);
|
||||
expect(result).toBe(jpgBuf);
|
||||
});
|
||||
|
||||
it("passes through a WebP buffer unchanged", async () => {
|
||||
const webpBuf = await readFile(join(FIXTURES, "formats/sample.webp"));
|
||||
const webpBuf = readFixture(fixtures.image.formats("webp"));
|
||||
const result = await ensureSharpCompat(webpBuf);
|
||||
expect(result).toBe(webpBuf);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const TEMPLATES_DIR = join(__dirname, "../../../apps/api/static/meme-templates");
|
||||
const MANIFEST_PATH = join(TEMPLATES_DIR, "meme-templates.json");
|
||||
const VALID_CATEGORIES = ["reaction", "comparison", "opinion", "animals", "classic"];
|
||||
|
||||
interface TextBox {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
defaultText: string;
|
||||
}
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
aliases: string[];
|
||||
tags: string[];
|
||||
category: string;
|
||||
filename: string;
|
||||
width: number;
|
||||
height: number;
|
||||
popularity: number;
|
||||
textBoxes: TextBox[];
|
||||
}
|
||||
|
||||
interface Manifest {
|
||||
version: number;
|
||||
categories: string[];
|
||||
templates: Template[];
|
||||
}
|
||||
|
||||
function loadManifest(): Manifest {
|
||||
const raw = readFileSync(MANIFEST_PATH, "utf-8");
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
describe("meme template manifest validation", () => {
|
||||
it("manifest file exists and is valid JSON with version 1 and non-empty templates array", () => {
|
||||
expect(existsSync(MANIFEST_PATH)).toBe(true);
|
||||
|
||||
const raw = readFileSync(MANIFEST_PATH, "utf-8");
|
||||
let manifest: Manifest;
|
||||
expect(() => {
|
||||
manifest = JSON.parse(raw);
|
||||
}).not.toThrow();
|
||||
|
||||
manifest = JSON.parse(raw);
|
||||
expect(manifest.version).toBe(1);
|
||||
expect(Array.isArray(manifest.templates)).toBe(true);
|
||||
expect(manifest.templates.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("has no duplicate template IDs", () => {
|
||||
const manifest = loadManifest();
|
||||
const ids = manifest.templates.map((t) => t.id);
|
||||
const uniqueIds = new Set(ids);
|
||||
expect(uniqueIds.size).toBe(ids.length);
|
||||
|
||||
// Identify duplicates for a useful error message
|
||||
const seen = new Set<string>();
|
||||
const duplicates: string[] = [];
|
||||
for (const id of ids) {
|
||||
if (seen.has(id)) {
|
||||
duplicates.push(id);
|
||||
}
|
||||
seen.add(id);
|
||||
}
|
||||
expect(duplicates, `Duplicate template IDs: ${duplicates.join(", ")}`).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("every template has required fields with correct types", () => {
|
||||
const manifest = loadManifest();
|
||||
|
||||
for (const template of manifest.templates) {
|
||||
const label = `template "${template.id || template.name || "unknown"}"`;
|
||||
|
||||
// Required string fields
|
||||
expect(typeof template.id, `${label}: id must be a string`).toBe("string");
|
||||
expect(template.id.length, `${label}: id must not be empty`).toBeGreaterThan(0);
|
||||
expect(typeof template.name, `${label}: name must be a string`).toBe("string");
|
||||
expect(template.name.length, `${label}: name must not be empty`).toBeGreaterThan(0);
|
||||
|
||||
// aliases must be an array
|
||||
expect(Array.isArray(template.aliases), `${label}: aliases must be an array`).toBe(true);
|
||||
|
||||
// tags must be an array
|
||||
expect(Array.isArray(template.tags), `${label}: tags must be an array`).toBe(true);
|
||||
|
||||
// category must be valid
|
||||
expect(
|
||||
VALID_CATEGORIES.includes(template.category),
|
||||
`${label}: category "${template.category}" is not one of ${VALID_CATEGORIES.join(", ")}`,
|
||||
).toBe(true);
|
||||
|
||||
// filename
|
||||
expect(typeof template.filename, `${label}: filename must be a string`).toBe("string");
|
||||
expect(template.filename.length, `${label}: filename must not be empty`).toBeGreaterThan(0);
|
||||
|
||||
// width and height must be positive numbers
|
||||
expect(typeof template.width, `${label}: width must be a number`).toBe("number");
|
||||
expect(template.width, `${label}: width must be positive`).toBeGreaterThan(0);
|
||||
expect(typeof template.height, `${label}: height must be a number`).toBe("number");
|
||||
expect(template.height, `${label}: height must be positive`).toBeGreaterThan(0);
|
||||
|
||||
// popularity must be non-negative
|
||||
expect(typeof template.popularity, `${label}: popularity must be a number`).toBe("number");
|
||||
expect(
|
||||
template.popularity,
|
||||
`${label}: popularity must be non-negative`,
|
||||
).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// textBoxes must be a non-empty array
|
||||
expect(Array.isArray(template.textBoxes), `${label}: textBoxes must be an array`).toBe(true);
|
||||
expect(
|
||||
template.textBoxes.length,
|
||||
`${label}: must have at least one textBox`,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("text box coordinates are in valid percentage range (0-100)", () => {
|
||||
const manifest = loadManifest();
|
||||
|
||||
for (const template of manifest.templates) {
|
||||
for (const box of template.textBoxes) {
|
||||
const label = `template "${template.id}" textBox "${box.id}"`;
|
||||
|
||||
expect(box.x, `${label}: x must be >= 0`).toBeGreaterThanOrEqual(0);
|
||||
expect(box.x, `${label}: x must be <= 100`).toBeLessThanOrEqual(100);
|
||||
|
||||
expect(box.y, `${label}: y must be >= 0`).toBeGreaterThanOrEqual(0);
|
||||
expect(box.y, `${label}: y must be <= 100`).toBeLessThanOrEqual(100);
|
||||
|
||||
expect(box.width, `${label}: width must be >= 0`).toBeGreaterThanOrEqual(0);
|
||||
expect(box.width, `${label}: width must be <= 100`).toBeLessThanOrEqual(100);
|
||||
|
||||
expect(box.height, `${label}: height must be >= 0`).toBeGreaterThanOrEqual(0);
|
||||
expect(box.height, `${label}: height must be <= 100`).toBeLessThanOrEqual(100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("no duplicate text box IDs within a template", () => {
|
||||
const manifest = loadManifest();
|
||||
|
||||
for (const template of manifest.templates) {
|
||||
const boxIds = template.textBoxes.map((b) => b.id);
|
||||
const uniqueBoxIds = new Set(boxIds);
|
||||
expect(uniqueBoxIds.size, `template "${template.id}" has duplicate textBox IDs`).toBe(
|
||||
boxIds.length,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("every template has a corresponding full-size image in full/ directory", () => {
|
||||
const manifest = loadManifest();
|
||||
const fullDir = join(TEMPLATES_DIR, "full");
|
||||
|
||||
for (const template of manifest.templates) {
|
||||
const imagePath = join(fullDir, template.filename);
|
||||
expect(
|
||||
existsSync(imagePath),
|
||||
`template "${template.id}": missing full image at full/${template.filename}`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("every template has a corresponding thumbnail in thumbs/ directory", () => {
|
||||
const manifest = loadManifest();
|
||||
const thumbsDir = join(TEMPLATES_DIR, "thumbs");
|
||||
|
||||
for (const template of manifest.templates) {
|
||||
// Thumbnail uses the same base name but with .webp extension
|
||||
const baseName = template.filename.replace(/\.[^.]+$/, "");
|
||||
const thumbFilename = `${baseName}.webp`;
|
||||
const thumbPath = join(thumbsDir, thumbFilename);
|
||||
expect(
|
||||
existsSync(thumbPath),
|
||||
`template "${template.id}": missing thumbnail at thumbs/${thumbFilename}`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveOutputFormat } from "../../../apps/api/src/lib/output-format.js";
|
||||
import { fixtures, readFixture } from "../../fixtures/index.js";
|
||||
|
||||
const FIXTURES = join(__dirname, "..", "..", "fixtures");
|
||||
const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg"));
|
||||
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
|
||||
const WEBP = readFileSync(join(FIXTURES, "test-50x50.webp"));
|
||||
const JPG = readFixture(fixtures.image.base.jpg100);
|
||||
const PNG = readFixture(fixtures.image.base.png200);
|
||||
const WEBP = readFixture(fixtures.image.base.webp50);
|
||||
|
||||
describe("resolveOutputFormat", () => {
|
||||
it("detects JPEG input and returns jpeg config", async () => {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parsePageRange } from "../../../apps/api/src/routes/tools/pdf-to-image.js";
|
||||
|
||||
describe("parsePageRange", () => {
|
||||
it("returns all pages for 'all'", () => {
|
||||
expect(parsePageRange("all", 5)).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it("returns all pages for empty string", () => {
|
||||
expect(parsePageRange("", 3)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("parses a single page", () => {
|
||||
expect(parsePageRange("2", 5)).toEqual([2]);
|
||||
});
|
||||
|
||||
it("parses a range", () => {
|
||||
expect(parsePageRange("1-3", 5)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("parses mixed ranges and singles", () => {
|
||||
expect(parsePageRange("1-3, 5", 5)).toEqual([1, 2, 3, 5]);
|
||||
});
|
||||
|
||||
it("deduplicates overlapping ranges", () => {
|
||||
expect(parsePageRange("1-3, 2-4", 5)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("sorts output", () => {
|
||||
expect(parsePageRange("5, 1, 3", 5)).toEqual([1, 3, 5]);
|
||||
});
|
||||
|
||||
it("handles whitespace", () => {
|
||||
expect(parsePageRange(" 1 - 3 , 5 ", 5)).toEqual([1, 2, 3, 5]);
|
||||
});
|
||||
|
||||
it("throws on page 0", () => {
|
||||
expect(() => parsePageRange("0", 5)).toThrow();
|
||||
});
|
||||
|
||||
it("throws on negative page", () => {
|
||||
expect(() => parsePageRange("-1", 5)).toThrow();
|
||||
});
|
||||
|
||||
it("throws on page exceeding total", () => {
|
||||
expect(() => parsePageRange("6", 5)).toThrow(/out of range/);
|
||||
});
|
||||
|
||||
it("throws on range exceeding total", () => {
|
||||
expect(() => parsePageRange("3-7", 5)).toThrow(/out of range/);
|
||||
});
|
||||
|
||||
it("throws on reversed range", () => {
|
||||
expect(() => parsePageRange("5-2", 5)).toThrow();
|
||||
});
|
||||
|
||||
it("throws on non-numeric input", () => {
|
||||
expect(() => parsePageRange("abc", 5)).toThrow();
|
||||
});
|
||||
|
||||
it("throws on empty range segment", () => {
|
||||
expect(() => parsePageRange("1,,3", 5)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,7 @@ import { mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const FIXTURES = join(__dirname, "../../fixtures");
|
||||
import { fixtures, readFixture } from "../../fixtures/index.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. File validation
|
||||
@@ -32,7 +31,7 @@ describe("validateImageBuffer", () => {
|
||||
// -- Valid formats --------------------------------------------------------
|
||||
|
||||
it("accepts a valid PNG file", async () => {
|
||||
const buf = await readFile(join(FIXTURES, "test-200x150.png"));
|
||||
const buf = readFixture(fixtures.image.base.png200);
|
||||
const result = await validateImageBuffer(buf);
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
@@ -43,7 +42,7 @@ describe("validateImageBuffer", () => {
|
||||
});
|
||||
|
||||
it("accepts a valid JPEG file", async () => {
|
||||
const buf = await readFile(join(FIXTURES, "test-100x100.jpg"));
|
||||
const buf = readFixture(fixtures.image.base.jpg100);
|
||||
const result = await validateImageBuffer(buf);
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
@@ -54,7 +53,7 @@ describe("validateImageBuffer", () => {
|
||||
});
|
||||
|
||||
it("accepts a valid WebP file", async () => {
|
||||
const buf = await readFile(join(FIXTURES, "test-50x50.webp"));
|
||||
const buf = readFixture(fixtures.image.base.webp50);
|
||||
const result = await validateImageBuffer(buf);
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
@@ -65,7 +64,7 @@ describe("validateImageBuffer", () => {
|
||||
});
|
||||
|
||||
it("accepts a tiny 1x1 PNG file", async () => {
|
||||
const buf = await readFile(join(FIXTURES, "test-1x1.png"));
|
||||
const buf = readFixture(fixtures.image.edge.px1);
|
||||
const result = await validateImageBuffer(buf);
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
@@ -130,7 +129,7 @@ describe("validateImageBuffer", () => {
|
||||
});
|
||||
|
||||
it("accepts a HEIC file with correct magic bytes", async () => {
|
||||
const heicBuf = await readFile(join(FIXTURES, "test-200x150.heic"));
|
||||
const heicBuf = readFixture(fixtures.image.base.heic200);
|
||||
const result = await validateImageBuffer(heicBuf);
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
@@ -323,7 +322,7 @@ describe("validateImageBuffer", () => {
|
||||
origMod.env.MAX_MEGAPIXELS = 0.0001; // 100 pixels -- 200x150 = 30000px >> 100
|
||||
|
||||
try {
|
||||
const buf = await readFile(join(FIXTURES, "test-200x150.png"));
|
||||
const buf = readFixture(fixtures.image.base.png200);
|
||||
const result = await validateImageBuffer(buf);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
@@ -572,73 +571,72 @@ describe("validateImageBuffer", () => {
|
||||
|
||||
describe("validates exotic formats", () => {
|
||||
const { readFileSync } = require("node:fs");
|
||||
const FORMATS_DIR = join(FIXTURES, "formats");
|
||||
|
||||
it("accepts PBM file", async () => {
|
||||
const buf = readFileSync(join(FORMATS_DIR, "sample.pbm"));
|
||||
const buf = readFixture(fixtures.image.formats("pbm"));
|
||||
const result = await validateImageBuffer(buf, "sample.pbm");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) expect(result.format).toBe("pbm");
|
||||
});
|
||||
|
||||
it("accepts PGM file", async () => {
|
||||
const buf = readFileSync(join(FORMATS_DIR, "sample.pgm"));
|
||||
const buf = readFixture(fixtures.image.formats("pgm"));
|
||||
const result = await validateImageBuffer(buf, "sample.pgm");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) expect(result.format).toBe("pgm");
|
||||
});
|
||||
|
||||
it("accepts PPM file", async () => {
|
||||
const buf = readFileSync(join(FORMATS_DIR, "sample.ppm"));
|
||||
const buf = readFixture(fixtures.image.formats("ppm"));
|
||||
const result = await validateImageBuffer(buf, "sample.ppm");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) expect(result.format).toBe("ppm");
|
||||
});
|
||||
|
||||
it("accepts DDS file", async () => {
|
||||
const buf = readFileSync(join(FORMATS_DIR, "sample.dds"));
|
||||
const buf = readFixture(fixtures.image.formats("dds"));
|
||||
const result = await validateImageBuffer(buf, "sample.dds");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) expect(result.format).toBe("dds");
|
||||
});
|
||||
|
||||
it("accepts DPX file", async () => {
|
||||
const buf = readFileSync(join(FORMATS_DIR, "sample.dpx"));
|
||||
const buf = readFixture(fixtures.image.formats("dpx"));
|
||||
const result = await validateImageBuffer(buf, "sample.dpx");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) expect(result.format).toBe("dpx");
|
||||
});
|
||||
|
||||
it("accepts FITS file", async () => {
|
||||
const buf = readFileSync(join(FORMATS_DIR, "sample.fits"));
|
||||
const buf = readFixture(fixtures.image.formats("fits"));
|
||||
const result = await validateImageBuffer(buf, "sample.fits");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) expect(result.format).toBe("fits");
|
||||
});
|
||||
|
||||
it("accepts JP2 file", async () => {
|
||||
const buf = readFileSync(join(FORMATS_DIR, "sample.jp2"));
|
||||
const buf = readFixture(fixtures.image.formats("jp2"));
|
||||
const result = await validateImageBuffer(buf, "sample.jp2");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) expect(result.format).toBe("jp2");
|
||||
});
|
||||
|
||||
it("accepts QOI file", async () => {
|
||||
const buf = readFileSync(join(FORMATS_DIR, "sample.qoi"));
|
||||
const buf = readFixture(fixtures.image.formats("qoi"));
|
||||
const result = await validateImageBuffer(buf, "sample.qoi");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) expect(result.format).toBe("qoi");
|
||||
});
|
||||
|
||||
it("accepts SVGZ file (detected as svg)", async () => {
|
||||
const buf = readFileSync(join(FORMATS_DIR, "sample.svgz"));
|
||||
const buf = readFixture(fixtures.image.formats("svgz"));
|
||||
const result = await validateImageBuffer(buf, "sample.svgz");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) expect(result.format).toBe("svg");
|
||||
});
|
||||
|
||||
it("accepts EPS file", async () => {
|
||||
const buf = readFileSync(join(FORMATS_DIR, "sample.eps"));
|
||||
const buf = readFixture(fixtures.image.formats("eps"));
|
||||
const result = await validateImageBuffer(buf, "sample.eps");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) expect(result.format).toBe("eps");
|
||||
@@ -659,21 +657,21 @@ describe("validateImageBuffer", () => {
|
||||
});
|
||||
|
||||
it("accepts HEIF file", async () => {
|
||||
const buf = readFileSync(join(FORMATS_DIR, "sample.heif"));
|
||||
const buf = readFixture(fixtures.image.formats("heif"));
|
||||
const result = await validateImageBuffer(buf, "sample.heif");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) expect(result.format).toBe("heif");
|
||||
});
|
||||
|
||||
it("accepts APNG file (detected as png)", async () => {
|
||||
const buf = readFileSync(join(FORMATS_DIR, "sample.apng"));
|
||||
const buf = readFixture(fixtures.image.formats("apng"));
|
||||
const result = await validateImageBuffer(buf, "sample.apng");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) expect(result.format).toBe("png");
|
||||
});
|
||||
|
||||
it("accepts DNG file (detected as raw)", async () => {
|
||||
const buf = readFileSync(join(FORMATS_DIR, "sample.dng"));
|
||||
const buf = readFixture(fixtures.image.formats("dng"));
|
||||
const result = await validateImageBuffer(buf, "sample.dng");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Unit tests for the venv upgrade-stamp mechanism in docker/entrypoint.sh.
|
||||
*
|
||||
* The entrypoint compares /opt/venv/.venv-version (baked into the image at
|
||||
* build time) against /data/ai/venv/.venv-version (persisted on the volume).
|
||||
* When missing or mismatched, the venv is nuked and re-copied so upgraded
|
||||
* base packages take effect immediately.
|
||||
*
|
||||
* These tests exercise the bootstrap logic in isolation using temp dirs.
|
||||
*/
|
||||
|
||||
let root: string;
|
||||
let optVenv: string;
|
||||
let dataAi: string;
|
||||
let aiVenv: string;
|
||||
let aiVenvTmp: string;
|
||||
let installedJson: string;
|
||||
|
||||
// Self-contained shell script mirroring the entrypoint bootstrap block.
|
||||
// Uses env vars for paths so we can point at temp directories.
|
||||
const BOOTSTRAP_SCRIPT = `
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
AI_VENV="$TEST_AI_VENV"
|
||||
AI_VENV_TMP="$TEST_AI_VENV_TMP"
|
||||
OPT_VENV="$TEST_OPT_VENV"
|
||||
|
||||
if [ -d "$AI_VENV_TMP" ]; then
|
||||
echo "CLEANUP_INTERRUPTED"
|
||||
rm -rf "$AI_VENV_TMP"
|
||||
fi
|
||||
|
||||
if [ -d "$OPT_VENV" ]; then
|
||||
NEED_BOOTSTRAP=false
|
||||
|
||||
if [ ! -d "$AI_VENV" ]; then
|
||||
NEED_BOOTSTRAP=true
|
||||
echo "FIRST_RUN"
|
||||
elif [ -f "$OPT_VENV/.venv-version" ]; then
|
||||
IMAGE_STAMP=$(cat "$OPT_VENV/.venv-version")
|
||||
CURRENT_STAMP=""
|
||||
if [ -f "$AI_VENV/.venv-version" ]; then
|
||||
CURRENT_STAMP=$(cat "$AI_VENV/.venv-version")
|
||||
fi
|
||||
if [ "$CURRENT_STAMP" != "$IMAGE_STAMP" ]; then
|
||||
NEED_BOOTSTRAP=true
|
||||
echo "STAMP_MISMATCH"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$NEED_BOOTSTRAP" = true ]; then
|
||||
rm -rf "$AI_VENV"
|
||||
cp -r "$OPT_VENV" "$AI_VENV_TMP"
|
||||
mv "$AI_VENV_TMP" "$AI_VENV"
|
||||
if [ -f "$TEST_INSTALLED_JSON" ]; then
|
||||
echo '{"bundles":{}}' > "$TEST_INSTALLED_JSON"
|
||||
echo "BUNDLES_RESET"
|
||||
fi
|
||||
echo "VENV_READY"
|
||||
else
|
||||
echo "SKIP"
|
||||
fi
|
||||
else
|
||||
echo "NO_OPT_VENV"
|
||||
fi
|
||||
`;
|
||||
|
||||
function runBootstrap(): string {
|
||||
return execFileSync("/bin/sh", ["-c", BOOTSTRAP_SCRIPT], {
|
||||
env: {
|
||||
TEST_AI_VENV: aiVenv,
|
||||
TEST_AI_VENV_TMP: aiVenvTmp,
|
||||
TEST_OPT_VENV: optVenv,
|
||||
TEST_INSTALLED_JSON: installedJson,
|
||||
PATH: process.env.PATH,
|
||||
},
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "venv-stamp-"));
|
||||
optVenv = join(root, "opt-venv");
|
||||
dataAi = join(root, "data-ai");
|
||||
aiVenv = join(dataAi, "venv");
|
||||
aiVenvTmp = join(dataAi, "venv.bootstrapping");
|
||||
installedJson = join(dataAi, "installed.json");
|
||||
|
||||
// Simulate /opt/venv with a stamp and a marker file
|
||||
mkdirSync(optVenv, { recursive: true });
|
||||
writeFileSync(join(optVenv, ".venv-version"), "abc123\n");
|
||||
writeFileSync(join(optVenv, "marker.txt"), "base-package-content");
|
||||
|
||||
mkdirSync(dataAi, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("venv upgrade stamp", () => {
|
||||
it("bootstraps on first run when no venv exists", () => {
|
||||
const output = runBootstrap();
|
||||
expect(output).toContain("FIRST_RUN");
|
||||
expect(output).toContain("VENV_READY");
|
||||
expect(existsSync(join(aiVenv, ".venv-version"))).toBe(true);
|
||||
expect(readFileSync(join(aiVenv, "marker.txt"), "utf-8")).toBe("base-package-content");
|
||||
});
|
||||
|
||||
it("skips when stamps match", () => {
|
||||
// Simulate an existing venv with matching stamp
|
||||
mkdirSync(aiVenv, { recursive: true });
|
||||
writeFileSync(join(aiVenv, ".venv-version"), "abc123\n");
|
||||
|
||||
const output = runBootstrap();
|
||||
expect(output).toBe("SKIP");
|
||||
});
|
||||
|
||||
it("refreshes when stamps differ (upgrade)", () => {
|
||||
// Simulate stale venv with old stamp
|
||||
mkdirSync(aiVenv, { recursive: true });
|
||||
writeFileSync(join(aiVenv, ".venv-version"), "old-hash\n");
|
||||
writeFileSync(join(aiVenv, "stale-file.txt"), "should-be-removed");
|
||||
|
||||
const output = runBootstrap();
|
||||
expect(output).toContain("STAMP_MISMATCH");
|
||||
expect(output).toContain("VENV_READY");
|
||||
// Old content replaced with fresh copy
|
||||
expect(existsSync(join(aiVenv, "stale-file.txt"))).toBe(false);
|
||||
expect(readFileSync(join(aiVenv, "marker.txt"), "utf-8")).toBe("base-package-content");
|
||||
expect(readFileSync(join(aiVenv, ".venv-version"), "utf-8")).toBe("abc123\n");
|
||||
});
|
||||
|
||||
it("refreshes when venv has no stamp (pre-stamp image upgrade)", () => {
|
||||
// Simulate old venv without any stamp file
|
||||
mkdirSync(aiVenv, { recursive: true });
|
||||
writeFileSync(join(aiVenv, "old-pkg.txt"), "legacy");
|
||||
|
||||
const output = runBootstrap();
|
||||
expect(output).toContain("STAMP_MISMATCH");
|
||||
expect(output).toContain("VENV_READY");
|
||||
expect(existsSync(join(aiVenv, "old-pkg.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
it("resets installed.json when refreshing", () => {
|
||||
// Simulate existing venv + installed bundles
|
||||
mkdirSync(aiVenv, { recursive: true });
|
||||
writeFileSync(join(aiVenv, ".venv-version"), "old-hash\n");
|
||||
writeFileSync(
|
||||
installedJson,
|
||||
JSON.stringify({
|
||||
bundles: {
|
||||
"background-removal": {
|
||||
version: "1.0.0",
|
||||
installedAt: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const output = runBootstrap();
|
||||
expect(output).toContain("BUNDLES_RESET");
|
||||
const data = JSON.parse(readFileSync(installedJson, "utf-8"));
|
||||
expect(data).toEqual({ bundles: {} });
|
||||
});
|
||||
|
||||
it("does not reset installed.json when no bundles were installed", () => {
|
||||
// No installed.json exists
|
||||
mkdirSync(aiVenv, { recursive: true });
|
||||
writeFileSync(join(aiVenv, ".venv-version"), "old-hash\n");
|
||||
|
||||
const output = runBootstrap();
|
||||
expect(output).toContain("VENV_READY");
|
||||
expect(output).not.toContain("BUNDLES_RESET");
|
||||
});
|
||||
|
||||
it("does nothing when /opt/venv has no stamp (old image)", () => {
|
||||
// Remove the stamp from the image venv
|
||||
rmSync(join(optVenv, ".venv-version"));
|
||||
mkdirSync(aiVenv, { recursive: true });
|
||||
writeFileSync(join(aiVenv, "existing.txt"), "keep");
|
||||
|
||||
const output = runBootstrap();
|
||||
expect(output).toBe("SKIP");
|
||||
// Existing venv untouched
|
||||
expect(existsSync(join(aiVenv, "existing.txt"))).toBe(true);
|
||||
});
|
||||
|
||||
it("cleans up interrupted bootstrap from previous start", () => {
|
||||
mkdirSync(aiVenvTmp, { recursive: true });
|
||||
writeFileSync(join(aiVenvTmp, "partial.txt"), "incomplete");
|
||||
|
||||
const output = runBootstrap();
|
||||
expect(output).toContain("CLEANUP_INTERRUPTED");
|
||||
expect(existsSync(aiVenvTmp)).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing when /opt/venv does not exist", () => {
|
||||
rmSync(optVenv, { recursive: true });
|
||||
const output = runBootstrap();
|
||||
expect(output).toBe("NO_OPT_VENV");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user