test: add e2e test, add manual test checklist
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { trackErrors, expectNoErrors } from "./helpers/page";
|
||||
|
||||
const TOTAL = 121;
|
||||
const GROUPS = 8;
|
||||
|
||||
test("catalog shows total and all groups", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await page.goto("/preview/list-tools");
|
||||
await expect(page.locator(".catalog-total b")).toHaveText(String(TOTAL));
|
||||
await expect(page.locator(".catalog-group")).toHaveCount(GROUPS);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test("catalog search narrows results", async ({ page }) => {
|
||||
await page.goto("/preview/list-tools");
|
||||
await expect(async () => {
|
||||
await page.getByRole("textbox", { name: "Search tools" }).fill("resize");
|
||||
await expect(page.locator(".tool-card")).toHaveCount(1);
|
||||
}).toPass();
|
||||
await expect(page.locator(".tool-card").first()).toContainText("Resize PNG");
|
||||
});
|
||||
|
||||
test("category filter shows only matching group and resets on ALL", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/preview/list-tools");
|
||||
const allButtons = page.locator(".tool-card");
|
||||
const allCount = await allButtons.count();
|
||||
expect(allCount).toBe(TOTAL);
|
||||
|
||||
await expect(async () => {
|
||||
await page.getByRole("button", { name: "CONVERT" }).click();
|
||||
await expect(page.locator(".catalog-group")).toHaveCount(1);
|
||||
}).toPass();
|
||||
await expect(page.locator(".catalog-group")).toContainText("CONVERT");
|
||||
const convertCards = await page.locator(".tool-card").count();
|
||||
expect(convertCards).toBeGreaterThan(0);
|
||||
expect(convertCards).toBeLessThan(allCount);
|
||||
|
||||
await expect(async () => {
|
||||
await page.getByRole("button", { name: "ALL" }).click();
|
||||
await expect(page.locator(".catalog-group")).toHaveCount(GROUPS);
|
||||
}).toPass();
|
||||
await expect(page.locator(".tool-card")).toHaveCount(allCount);
|
||||
});
|
||||
|
||||
test("workspace search matches a tool by title", async ({ page }) => {
|
||||
await page.goto("/preview");
|
||||
await expect(async () => {
|
||||
await page.getByRole("textbox", { name: "Search tools" }).fill("flip");
|
||||
await expect(page.locator(".tool-card")).toHaveCount(1);
|
||||
}).toPass();
|
||||
await expect(page.locator(".tool-card").first()).toContainText("Flip PNG");
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { trackErrors, openTool, expectNoErrors } from "./helpers/page";
|
||||
|
||||
test.describe("generators — known UI gap (#checklist, п.1)", () => {
|
||||
// Генераторы (21/121) открываются, но в UI нет ни полей схемы, ни кнопки
|
||||
// «Generate» — только RU/EN/Reset. Пока баг открыт — fixme с ожидаемым
|
||||
// сценарием; после фикса убрать fixme.
|
||||
test.fixme("single-color-png renders Generate controls and produces a result", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openTool(page, "single-color-png");
|
||||
const generate = page.getByRole("button", { name: "Generate" });
|
||||
await expect(generate).toBeVisible();
|
||||
await generate.click();
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("create-empty-png page opens without errors", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "create-empty-png");
|
||||
await expect(page.locator("h1")).toContainText("Create");
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { deflateSync } from "node:zlib";
|
||||
|
||||
export interface SourceFile {
|
||||
name: string;
|
||||
mimeType: string;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(data: Uint8Array): number {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < data.length; i++)
|
||||
c = CRC_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type: string, data: Buffer): Buffer {
|
||||
const out = Buffer.alloc(8 + data.length + 4);
|
||||
out.writeUInt32BE(data.length, 0);
|
||||
out.write(type, 4, "ascii");
|
||||
data.copy(out, 8);
|
||||
out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Строит валидный RGBA PNG в рантайме (8-bit, фильтр type 0, deflate/zlib). */
|
||||
export function makePng(
|
||||
width: number,
|
||||
height: number,
|
||||
pixelAt: (x: number, y: number) => [number, number, number, number],
|
||||
): Buffer {
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 6;
|
||||
const stride = width * 4 + 1;
|
||||
const raw = Buffer.alloc(height * stride);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const [r, g, b, a] = pixelAt(x, y);
|
||||
const p = y * stride + 1 + x * 4;
|
||||
raw[p] = r;
|
||||
raw[p + 1] = g;
|
||||
raw[p + 2] = b;
|
||||
raw[p + 3] = a;
|
||||
}
|
||||
}
|
||||
return Buffer.concat([
|
||||
sig,
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", deflateSync(raw)),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
const checker = (x: number, y: number): [number, number, number, number] =>
|
||||
x % 2 === y % 2 ? [255, 0, 0, 255] : [255, 255, 255, 255];
|
||||
|
||||
const alphaGrid = (x: number, y: number): [number, number, number, number] =>
|
||||
(x + y) % 3 === 0 ? [0, 0, 0, 0] : [0, 128, 255, 255];
|
||||
|
||||
const solidRed = (): [number, number, number, number] => [255, 0, 0, 255];
|
||||
|
||||
export function asSourceFile(buffer: Buffer, name: string): SourceFile {
|
||||
return { name, mimeType: "image/png", buffer };
|
||||
}
|
||||
|
||||
export const opaquePng: SourceFile = asSourceFile(
|
||||
makePng(64, 48, checker),
|
||||
"opaque.png",
|
||||
);
|
||||
export const transparentPng: SourceFile = asSourceFile(
|
||||
makePng(64, 64, alphaGrid),
|
||||
"transparent.png",
|
||||
);
|
||||
export const onePixelPng: SourceFile = asSourceFile(
|
||||
makePng(1, 1, solidRed),
|
||||
"1px.png",
|
||||
);
|
||||
export const landscapePng: SourceFile = asSourceFile(
|
||||
makePng(64, 16, checker),
|
||||
"landscape.png",
|
||||
);
|
||||
export const largePng: SourceFile = asSourceFile(
|
||||
makePng(256, 256, checker),
|
||||
"large.png",
|
||||
);
|
||||
export const corruptPng: SourceFile = {
|
||||
name: "corrupt.png",
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from("this is definitely not a png file", "utf8"),
|
||||
};
|
||||
|
||||
export const tinyBase64 = makePng(8, 8, solidRed).toString("base64");
|
||||
|
||||
export const svgMarkup =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="6">' +
|
||||
'<rect width="10" height="6" fill="#ff0000"/></svg>';
|
||||
|
||||
const PIXEL_BYTES = (r: number, g: number, b: number, a: number): string =>
|
||||
`${r} ${g} ${b} ${a}`;
|
||||
|
||||
/** Строка для bytes-to-png / rgb-values-to-png, ровно 32 пикселя в ширину по умолчанию. */
|
||||
export function pixelRow(
|
||||
count: number,
|
||||
rgba: [number, number, number, number],
|
||||
): string {
|
||||
return Array.from({ length: count }, () => PIXEL_BYTES(...rgba)).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { expect, type Page } from "playwright/test";
|
||||
import type { SourceFile } from "./fixtures";
|
||||
|
||||
export type ErrorSink = { errors: string[] };
|
||||
|
||||
export function trackErrors(page: Page): ErrorSink {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(`pageerror: ${e.message}`));
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error") errors.push(`console: ${m.text()}`);
|
||||
});
|
||||
return { errors };
|
||||
}
|
||||
|
||||
export async function openTool(page: Page, id: string): Promise<void> {
|
||||
await page.goto(`/preview/tools/${id}`);
|
||||
await expect(page.locator(".schema-tool h1")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function uploadImage(page: Page, file: SourceFile): Promise<void> {
|
||||
await page.locator('.actions input[type="file"]').setInputFiles({
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
buffer: file.buffer,
|
||||
});
|
||||
}
|
||||
|
||||
export async function metaValue(page: Page, caption: string): Promise<string> {
|
||||
const row = page.locator(".meta-row", { hasText: caption });
|
||||
await expect(row).toBeVisible();
|
||||
const value = await row.locator(".meta-value").textContent();
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
export async function suggestedDownloadName(
|
||||
page: Page,
|
||||
selector: string,
|
||||
): Promise<string> {
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.locator(selector).click(),
|
||||
]);
|
||||
return download.suggestedFilename();
|
||||
}
|
||||
|
||||
export async function expectNoErrorAlert(page: Page): Promise<void> {
|
||||
await expect(page.locator('[role="alert"]')).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectNoErrors(sink: ErrorSink): Promise<void> {
|
||||
expect(sink.errors, "нет console/page errors").toEqual([]);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { opaquePng } from "./helpers/fixtures";
|
||||
import { openTool, uploadImage } from "./helpers/page";
|
||||
|
||||
// Весь файл — зарегистрированные баги preview (см. docs/checklist-manual-testing.md
|
||||
// → «Известные баги preview»). Тела assert'ят ОЖИДАЕМОЕ поведение. Статус fixme
|
||||
// означает «мы знаем, что сейчас падает»; когда баг починят — убрать fixme и
|
||||
// тест станет зелёным «сам по себе».
|
||||
|
||||
test.describe("known bugs — documented as fixme", () => {
|
||||
test.fixme("resize-png: upload produces a resized result (no alert)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openTool(page, "resize-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator("[role='alert']")).toHaveCount(0);
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test.fixme("crop-png: upload produces a cropped result (no alert)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openTool(page, "crop-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator("[role='alert']")).toHaveCount(0);
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test.fixme("error message is localized, not a raw i18n key", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openTool(page, "resize-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
const alert = page.locator("[role='alert']");
|
||||
await expect(alert).toBeVisible();
|
||||
const text = (await alert.textContent()) ?? "";
|
||||
expect(text).not.toMatch(/^errors\./);
|
||||
expect(text.length).toBeGreaterThan(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { trackErrors, expectNoErrors } from "./helpers/page";
|
||||
|
||||
const ROUTES = [
|
||||
"/preview",
|
||||
"/preview/list-tools",
|
||||
"/preview/kit",
|
||||
"/preview/tools/flip-png",
|
||||
];
|
||||
|
||||
for (const route of ROUTES) {
|
||||
test(`page ${route} loads without errors`, async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
const resp = await page.goto(route);
|
||||
expect(resp?.status()).toBe(200);
|
||||
await page.waitForLoadState("networkidle");
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
}
|
||||
|
||||
test("unknown tool route responds 404 on static build", async ({ page }) => {
|
||||
const resp = await page.goto("/preview/tools/definitely-not-a-tool");
|
||||
expect(resp?.status()).toBe(404);
|
||||
});
|
||||
|
||||
test("theme toggle flips preview theme and persists to localStorage", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/preview");
|
||||
const root = page.locator("main.preview-root");
|
||||
const before = await root.getAttribute("data-theme");
|
||||
await expect(async () => {
|
||||
await page.getByRole("button", { name: "Toggle theme" }).click();
|
||||
const after = await root.getAttribute("data-theme");
|
||||
expect(after).not.toBe(before);
|
||||
}).toPass();
|
||||
const after = await root.getAttribute("data-theme");
|
||||
const stored = await page.evaluate(() =>
|
||||
localStorage.getItem("easy-png-tools:theme"),
|
||||
);
|
||||
expect(["light", "dark"]).toContain(before);
|
||||
expect(["light", "dark"]).toContain(after);
|
||||
expect(stored).toBe(after);
|
||||
});
|
||||
|
||||
test("language toggle marks the active button", async ({ page }) => {
|
||||
await page.goto("/preview");
|
||||
const group = page.getByRole("group", { name: "Language" });
|
||||
await expect(async () => {
|
||||
await group.getByRole("button", { name: "EN" }).click();
|
||||
await expect(page.locator(".lang-btn.active")).toHaveText("EN");
|
||||
}).toPass();
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import {
|
||||
opaquePng,
|
||||
landscapePng,
|
||||
onePixelPng,
|
||||
largePng,
|
||||
corruptPng,
|
||||
} from "./helpers/fixtures";
|
||||
import {
|
||||
trackErrors,
|
||||
openTool,
|
||||
uploadImage,
|
||||
metaValue,
|
||||
suggestedDownloadName,
|
||||
expectNoErrorAlert,
|
||||
expectNoErrors,
|
||||
} from "./helpers/page";
|
||||
|
||||
test("flip-png: full flow — upload, result, meta, download", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, landscapePng);
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
await expect(page.locator('img[alt="result"]')).toHaveAttribute(
|
||||
"src",
|
||||
/^(blob:|data:image\/png;base64,)/,
|
||||
);
|
||||
const resultMeta = await metaValue(page, "RESULT");
|
||||
expect(resultMeta).toBe("64 × 16 px");
|
||||
const downloadName = await suggestedDownloadName(
|
||||
page,
|
||||
'button[aria-label="Download result"]',
|
||||
);
|
||||
expect(downloadName).toBe("flip-png.png");
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test("convert-png-to-jpg: produces a downloadable jpg", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "convert-png-to-jpg");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
const downloadName = await suggestedDownloadName(
|
||||
page,
|
||||
'button[aria-label="Download result"]',
|
||||
);
|
||||
expect(downloadName).toBe("convert-png-to-jpg.jpg");
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test("reset clears result but keeps source", async ({ page }) => {
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
await page.getByRole("button", { name: "Reset" }).click();
|
||||
await expect(page.locator('img[alt="result"]')).toHaveCount(0);
|
||||
await expect(page.locator(".empty")).toContainText("no result yet");
|
||||
await expect(page.locator('img[alt="source"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("blur: changing slider updates result image", async ({ page }) => {
|
||||
await openTool(page, "blur-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
const src1 = await page.locator('img[alt="result"]').getAttribute("src");
|
||||
await expect(async () => {
|
||||
await page.locator('input[type="range"]').fill("20");
|
||||
await expect(page.locator('img[alt="result"]')).not.toHaveAttribute(
|
||||
"src",
|
||||
src1 ?? "",
|
||||
);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test("runs without Web Worker (fallback)", async ({ browser }) => {
|
||||
const context = await browser.newContext();
|
||||
await context.addInitScript(() => {
|
||||
Object.defineProperty(window, "Worker", {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, landscapePng);
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test("invalid file upload shows error, no crash", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, corruptPng);
|
||||
await expect(page.locator('[role="alert"]')).toBeVisible();
|
||||
await expect(page.locator('img[alt="result"]')).toHaveCount(0);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
for (const fixture of [onePixelPng, largePng]) {
|
||||
test(`handles ${fixture.name} correctly`, async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, fixture);
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import {
|
||||
opaquePng,
|
||||
transparentPng,
|
||||
tinyBase64,
|
||||
svgMarkup,
|
||||
pixelRow,
|
||||
} from "./helpers/fixtures";
|
||||
import {
|
||||
trackErrors,
|
||||
openTool,
|
||||
uploadImage,
|
||||
expectNoErrorAlert,
|
||||
expectNoErrors,
|
||||
} from "./helpers/page";
|
||||
|
||||
for (const [id, input] of [
|
||||
["base64-to-png", tinyBase64],
|
||||
["hex-to-png", "ff0000ff"],
|
||||
["bytes-to-png", pixelRow(32, [255, 0, 0, 255])],
|
||||
[
|
||||
"rgb-values-to-png",
|
||||
Array.from({ length: 32 }, () => "rgba(255,0,0,255)").join(" "),
|
||||
],
|
||||
["svg-to-png", svgMarkup],
|
||||
] as const) {
|
||||
test(`text input → ${id} produces result image`, async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await expect(async () => {
|
||||
await openTool(page, id);
|
||||
await page.locator(".text-source textarea").fill(input);
|
||||
await page.getByRole("button", { name: "Render text" }).click();
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
}).toPass({ timeout: 25_000 });
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
}
|
||||
|
||||
test("png-to-base64 shows decoded text result", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "png-to-base64");
|
||||
await uploadImage(page, opaquePng);
|
||||
const code = page.locator(".result-pre code");
|
||||
await expect(code).toBeVisible();
|
||||
const text = (await code.textContent()) ?? "";
|
||||
expect(text.length).toBeGreaterThan(20);
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
for (const [input, expected] of [
|
||||
[tinyBase64, "Yes — valid PNG signature."],
|
||||
["aGVsbG8=", "No — the content is not a PNG."],
|
||||
] as const) {
|
||||
test(`verify-is-png verdict: ${expected}`, async ({ page }) => {
|
||||
await expect(async () => {
|
||||
await openTool(page, "verify-is-png");
|
||||
await page.locator(".text-source textarea").fill(input);
|
||||
await page.getByRole("button", { name: "Render text" }).click();
|
||||
await expect(page.locator(".verdict-text")).toContainText(expected);
|
||||
}).toPass({ timeout: 25_000 });
|
||||
});
|
||||
}
|
||||
|
||||
test("png-is-transparent: opaque image → 'No'", async ({ page }) => {
|
||||
await openTool(page, "png-is-transparent");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator(".verdict-text")).toContainText(
|
||||
"No — fully opaque.",
|
||||
);
|
||||
});
|
||||
|
||||
test("png-is-transparent: transparent image → 'Yes'", async ({ page }) => {
|
||||
await openTool(page, "png-is-transparent");
|
||||
await uploadImage(page, transparentPng);
|
||||
await expect(page.locator(".verdict-text")).toContainText(
|
||||
"Yes — has transparency.",
|
||||
);
|
||||
});
|
||||
|
||||
test("png-is-grayscale: colored image → 'No'", async ({ page }) => {
|
||||
await openTool(page, "png-is-grayscale");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator(".verdict-text")).toContainText(
|
||||
"No — contains colors.",
|
||||
);
|
||||
});
|
||||
|
||||
test("png-orientation: landscape image → Landscape", async ({ page }) => {
|
||||
await openTool(page, "png-orientation");
|
||||
await uploadImage(page, opaquePng); // 64×48 → landscape
|
||||
await expect(page.locator(".verdict-text")).toContainText("Landscape");
|
||||
});
|
||||
|
||||
test("png-file-size: returns a size in KB", async ({ page }) => {
|
||||
await openTool(page, "png-file-size");
|
||||
await uploadImage(page, opaquePng);
|
||||
const verdict = page.locator(".verdict-text");
|
||||
await expect(verdict).toBeVisible();
|
||||
await expect(verdict).toContainText("KB");
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { opaquePng, transparentPng } from "./helpers/fixtures";
|
||||
import type { SourceFile } from "./helpers/fixtures";
|
||||
import {
|
||||
trackErrors,
|
||||
openTool,
|
||||
uploadImage,
|
||||
expectNoErrorAlert,
|
||||
expectNoErrors,
|
||||
} from "./helpers/page";
|
||||
|
||||
type Kind = "image" | "text-out" | "verdict";
|
||||
|
||||
const CASES: { id: string; kind: Kind; file?: SourceFile }[] = [
|
||||
// ── convert ────────────────────────────────────────────────
|
||||
{ id: "convert-png-to-jpg", kind: "image" },
|
||||
{ id: "convert-png-to-webp", kind: "image" },
|
||||
{ id: "png-to-bmp", kind: "image" },
|
||||
{ id: "png-to-base64", kind: "text-out" },
|
||||
// ── alpha ──────────────────────────────────────────────────
|
||||
{ id: "remove-background-png", kind: "image", file: transparentPng },
|
||||
{ id: "remove-color-from-png", kind: "image", file: transparentPng },
|
||||
{ id: "round-corners-png", kind: "image", file: transparentPng },
|
||||
{ id: "add-stroke-png", kind: "image", file: transparentPng },
|
||||
{ id: "circle-mask-png", kind: "image", file: transparentPng },
|
||||
{ id: "wavy-mask-png", kind: "image", file: transparentPng },
|
||||
{ id: "find-contour-png", kind: "image", file: transparentPng },
|
||||
{ id: "feather-edges-png", kind: "image", file: transparentPng },
|
||||
{ id: "clean-edges-png", kind: "image", file: transparentPng },
|
||||
{ id: "make-thicker-png", kind: "image", file: transparentPng },
|
||||
{ id: "make-thinner-png", kind: "image", file: transparentPng },
|
||||
{ id: "despeckle-alpha-png", kind: "image", file: transparentPng },
|
||||
{ id: "close-holes-png", kind: "image", file: transparentPng },
|
||||
{ id: "harden-alpha-png", kind: "image", file: transparentPng },
|
||||
{ id: "invert-alpha-png", kind: "image", file: transparentPng },
|
||||
{ id: "set-alpha-channel-png", kind: "image", file: transparentPng },
|
||||
{ id: "extract-alpha-mask-png", kind: "image", file: transparentPng },
|
||||
{ id: "remove-alpha-channel-png", kind: "image", file: transparentPng },
|
||||
{ id: "square-mask-png", kind: "image", file: transparentPng },
|
||||
{ id: "star-mask-png", kind: "image", file: transparentPng },
|
||||
// ── color ──────────────────────────────────────────────────
|
||||
{ id: "grayscale-png", kind: "image" },
|
||||
{ id: "invert-colors-png", kind: "image" },
|
||||
{ id: "sepia-png", kind: "image" },
|
||||
{ id: "quantize-png", kind: "image" },
|
||||
{ id: "dithering-png", kind: "image" },
|
||||
{ id: "decrease-color-count-png", kind: "image" },
|
||||
{ id: "two-colors-png", kind: "image" },
|
||||
{ id: "tint-png", kind: "image" },
|
||||
{ id: "auto-contrast-png", kind: "image" },
|
||||
{ id: "png-to-hsl", kind: "image" },
|
||||
{ id: "png-to-cmyk", kind: "image" },
|
||||
// ── geometry ───────────────────────────────────────────────
|
||||
{ id: "flip-png", kind: "image" },
|
||||
{ id: "rotate-png", kind: "image" },
|
||||
{ id: "add-border-png", kind: "image" },
|
||||
{ id: "tile-png", kind: "image" },
|
||||
{ id: "change-canvas-size-png", kind: "image" },
|
||||
{ id: "trim-empty-space-png", kind: "image" },
|
||||
{ id: "swap-orientation-png", kind: "image" },
|
||||
{ id: "skew-png", kind: "image" },
|
||||
{ id: "zoom-png", kind: "image" },
|
||||
{ id: "center-by-alpha-png", kind: "image" },
|
||||
{ id: "change-aspect-ratio-png", kind: "image" },
|
||||
{ id: "symmetric-copy-png", kind: "image" },
|
||||
{ id: "shift-png", kind: "image" },
|
||||
// ── filters ────────────────────────────────────────────────
|
||||
{ id: "blur-png", kind: "image" },
|
||||
{ id: "sharpen-png", kind: "image" },
|
||||
{ id: "pixelate-png", kind: "image" },
|
||||
{ id: "add-noise-png", kind: "image" },
|
||||
{ id: "vignette-png", kind: "image" },
|
||||
{ id: "silhouette-png", kind: "image" },
|
||||
{ id: "randomize-pixels-png", kind: "image" },
|
||||
{ id: "jpeg-artifacts-png", kind: "image" },
|
||||
// ── text ───────────────────────────────────────────────────
|
||||
{ id: "add-text-png", kind: "image" },
|
||||
{ id: "date-stamp-png", kind: "image" },
|
||||
{ id: "watermark-tile-png", kind: "image" },
|
||||
// ── analyze (image output) ─────────────────────────────────
|
||||
{ id: "extract-color-from-png", kind: "image", file: transparentPng },
|
||||
{ id: "show-transparent-png", kind: "image", file: transparentPng },
|
||||
{ id: "light-pixel-mask-png", kind: "image", file: transparentPng },
|
||||
{ id: "extract-channel-png", kind: "image", file: transparentPng },
|
||||
{ id: "unique-color-mask-png", kind: "image", file: transparentPng },
|
||||
{ id: "show-grayscale-pixels-png", kind: "image", file: transparentPng },
|
||||
{ id: "show-color-pixels-png", kind: "image", file: transparentPng },
|
||||
{ id: "dark-pixel-mask-png", kind: "image", file: transparentPng },
|
||||
// ── analyze (verdict output) ───────────────────────────────
|
||||
{ id: "png-is-transparent", kind: "verdict" },
|
||||
{ id: "png-is-grayscale", kind: "verdict" },
|
||||
{ id: "png-orientation", kind: "verdict" },
|
||||
{ id: "png-file-size", kind: "verdict" },
|
||||
];
|
||||
|
||||
test.describe("smoke: tools produce output without errors", () => {
|
||||
for (const { id, kind, file: fixture } of CASES) {
|
||||
test(`tool ${id} (${kind})`, async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
|
||||
const outputLocator = (): ReturnType<typeof page.locator> => {
|
||||
switch (kind) {
|
||||
case "image":
|
||||
return page.locator('img[alt="result"]');
|
||||
case "text-out":
|
||||
return page.locator(".result-pre code");
|
||||
case "verdict":
|
||||
return page.locator(".verdict-text");
|
||||
}
|
||||
};
|
||||
|
||||
// toPass: устойчивость к hydration-рейсу (ввод до гидрации SvelteKit
|
||||
// может не обработаться с первого раза).
|
||||
await expect(async () => {
|
||||
await openTool(page, id);
|
||||
await uploadImage(page, fixture ?? opaquePng);
|
||||
await expect(outputLocator()).toBeVisible({ timeout: 8_000 });
|
||||
}).toPass({ timeout: 25_000 });
|
||||
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user