Files
image-tools/tests/browser/app.spec.ts
T
zemion 79d223c347
Verify / verify (push) Canceled after 0s
Release Image Tools 0.2.0
2026-09-02 07:56:38 +02:00

209 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test, type Page } from "@playwright/test";
import { Buffer } from "node:buffer";
import { VALID_PNG_BASE64, pngFixture } from "../fixtures/images";
const ORIGIN = "http://127.0.0.1:4173";
async function localOnly(page: Page) {
const external: string[] = [];
await page.route("**/*", async (route) => {
const url = new URL(route.request().url());
if (url.origin !== ORIGIN) {
external.push(url.href);
await route.abort();
} else await route.continue();
});
return external;
}
test("runs from a nested path without external requests", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
const external = await localOnly(page);
await page.goto("/deep/nested/image/");
await expect(
page.getByRole("heading", { name: "Image Tools" }),
).toBeVisible();
expect(external).toEqual([]);
expect(errors).toEqual([]);
});
test("serves the release identity and hardened headers", async ({
request,
}) => {
const index = await request.get("/deep/nested/image/");
expect(index.ok()).toBe(true);
expect(index.headers()["content-security-policy"]).toContain(
"default-src 'self'",
);
expect(await index.text()).not.toMatch(/\b(?:src|href)=["']\//u);
const manifest = await request.get("/deep/nested/image/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.image-tools",
version: "0.2.0",
entry: "./",
});
});
test("inspects, previews, resizes and exports a static PNG locally", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
const external = await localOnly(page);
await page.goto("/deep/nested/image/");
await page.locator('.drop-zone input[type="file"]').setInputFiles({
name: "tiny.png",
mimeType: "image/png",
buffer: Buffer.from(VALID_PNG_BASE64, "base64"),
});
await expect(page.getByRole("heading", { name: "tiny.png" })).toBeVisible();
await expect(
page.getByRole("img", { name: "tiny.png, edited preview" }),
).toBeVisible();
await page.getByLabel("Mode").selectOption("exact");
await page.getByLabel("Width px").fill("4");
await page.getByLabel("Height px").fill("3");
await expect(page.getByText("Export 4 × 3")).toBeVisible();
await expect(
page.getByRole("img", { name: "tiny.png, edited preview" }),
).toBeVisible();
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Export current" }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe("tiny-edited.png");
await expect(page.getByText("Last export report")).toBeVisible();
expect(external).toEqual([]);
expect(errors).toEqual([]);
});
test("rejects an animated PNG without decoding a frame", async ({ page }) => {
await page.goto("/deep/nested/image/");
await page.locator('.drop-zone input[type="file"]').setInputFiles({
name: "animated.png",
mimeType: "image/png",
buffer: Buffer.from(pngFixture(8, 8, { animated: true })),
});
await expect(
page.getByText(/Animated and multi-picture images are not processed/u),
).toBeVisible();
await expect(page.getByRole("img")).toHaveCount(0);
});
test("uses exact native JPEG and WebP codec capabilities", async ({ page }) => {
const external = await localOnly(page);
await page.goto("/deep/nested/image/");
const jpeg = await browserRaster(page, "image/jpeg");
const webp = await browserRaster(page, "image/webp");
expect(jpeg.mimeType).toBe("image/jpeg");
expect(webp.mimeType).toBe("image/webp");
await page.locator('.drop-zone input[type="file"]').setInputFiles([
{
name: "sample.jpg",
mimeType: jpeg.mimeType,
buffer: Buffer.from(jpeg.bytes),
},
{
name: "sample.webp",
mimeType: webp.mimeType,
buffer: Buffer.from(webp.bytes),
},
]);
await expect(page.getByRole("heading", { name: "sample.jpg" })).toBeVisible();
await page.getByLabel("Format").selectOption("jpeg");
let downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Export current" }).click();
expect((await downloadPromise).suggestedFilename()).toBe("sample-edited.jpg");
await page
.locator(".queue-select")
.filter({ hasText: "sample.webp" })
.click();
await expect(
page.getByRole("heading", { name: "sample.webp" }),
).toBeVisible();
await page.getByLabel("Format").selectOption("webp");
downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Export current" }).click();
expect((await downloadPromise).suggestedFilename()).toBe(
"sample-edited.webp",
);
expect(external).toEqual([]);
});
test("zooms, exposes the interactive crop and exports a reusable recipe", async ({
page,
}) => {
await page.goto("/deep/nested/image/");
await page.locator('.drop-zone input[type="file"]').setInputFiles({
name: "crop.png",
mimeType: "image/png",
buffer: Buffer.from(VALID_PNG_BASE64, "base64"),
});
await expect(
page.getByRole("img", { name: "crop.png, edited preview" }),
).toBeVisible();
await page.getByRole("slider", { name: "Zoom" }).fill("175");
await expect(page.getByText("Zoom 175%")).toBeVisible();
await page.getByLabel("Width %").fill("60");
await expect(
page.getByRole("application", { name: /Interactive crop rectangle/iu }),
).toBeVisible();
const recipeDownload = page.waitForEvent("download");
await page.getByRole("button", { name: "Save recipe" }).click();
expect((await recipeDownload).suggestedFilename()).toBe(
"image-tools-recipe.json",
);
await page.getByText("Native codec capabilities").click();
await expect(
page.getByText(/PNG decode available · encode available/iu),
).toBeVisible();
});
test("packages completed queue outputs into one batch ZIP", async ({
page,
}) => {
await page.goto("/deep/nested/image/");
const buffer = Buffer.from(VALID_PNG_BASE64, "base64");
await page.locator('.drop-zone input[type="file"]').setInputFiles([
{ name: "one.png", mimeType: "image/png", buffer },
{ name: "two.png", mimeType: "image/png", buffer },
]);
await expect(page.getByRole("heading", { name: "one.png" })).toBeVisible();
await page.getByRole("button", { name: "Process queue" }).click();
await expect(page.getByText(/Finished 2 of 2/iu)).toBeVisible();
const batch = page.waitForEvent("download");
await page.getByRole("button", { name: "Batch ZIP (2)" }).click();
expect((await batch).suggestedFilename()).toBe("image-tools-batch.zip");
});
async function browserRaster(page: Page, type: "image/jpeg" | "image/webp") {
return page.evaluate(async (mimeType) => {
const canvas = document.createElement("canvas");
canvas.width = 4;
canvas.height = 3;
const context = canvas.getContext("2d");
if (!context) throw new Error("No canvas context");
context.fillStyle = "#7c4dff";
context.fillRect(0, 0, 2, 3);
context.fillStyle = "#19a974";
context.fillRect(2, 0, 2, 3);
const blob = await new Promise<Blob>((resolve, reject) =>
canvas.toBlob(
(value) => (value ? resolve(value) : reject(new Error("No encoder"))),
mimeType,
0.9,
),
);
return {
mimeType: blob.type,
bytes: Array.from(new Uint8Array(await blob.arrayBuffer())),
};
}, type);
}