test: add e2e test, add manual test checklist

This commit is contained in:
2026-09-08 11:03:05 +05:00
parent 9a7b7c178b
commit 3f21f1c024
18 changed files with 937 additions and 11 deletions
+2 -1
View File
@@ -9,7 +9,8 @@
обёртки в корне: `pnpm build`, `pnpm dev` (это `pnpm --dir web ...`). обёртки в корне: `pnpm build`, `pnpm dev` (это `pnpm --dir web ...`).
Не использовать npm. Не использовать npm.
- Сборка: `pnpm --dir web build`, проверка типов: `pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json`, - Сборка: `pnpm --dir web build`, проверка типов: `pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json`,
тесты: `pnpm --dir web test`. тесты: `pnpm --dir web test` (Vitest), e2e: `pnpm --dir web test:e2e` (Playwright,
`web/e2e/`, свой webServer на `pnpm build` + `scripts/serve-static.mjs`, порт 4173).
- Форматирование: `pnpm --dir web format` (Prettier + `prettier-plugin-svelte`, - Форматирование: `pnpm --dir web format` (Prettier + `prettier-plugin-svelte`,
конфиг `web/.prettierrc`, игнор `web/.prettierignore`). Проверка без записи: конфиг `web/.prettierrc`, игнор `web/.prettierignore`). Проверка без записи:
`pnpm --dir web exec prettier --check .`. `pnpm --dir web exec prettier --check .`.
@@ -1,8 +1,11 @@
# План: тестирование preview перед промоушеном в главную # План: тестирование preview перед промоушеном в главную
> Статус: план тестирования ветки preview (`web/src/routes/preview/**`, > Статус: **реализация завершена**. Playwright-набор в `web/e2e/`
> регистр `lib/registry-new/`, 121 инструмент) перед шагом C17 из > (108 тестов: 104 прошли, 4 `test.fixme` — известные баги №1–4 ниже),
> `docs/plan-redesign.md` (переезд preview → реальные маршруты). > ручной чек-лист — `docs/checklist-manual-testing.md`. План ветки preview
> (`web/src/routes/preview/**`, регистр `lib/registry-new/`, 121 инструмент)
> перед шагом C17 из `docs/plan-redesign.md` (переезд preview → реальные
> маршруты).
> >
> Охват: **документация плана** — реализация тестов (Playwright-раннер, > Охват: **документация плана** — реализация тестов (Playwright-раннер,
> сами тесты) делается отдельными задачами после ревью этого документа. > сами тесты) делается отдельными задачами после ревью этого документа.
@@ -13,6 +16,7 @@
> Команды для проверки в процессе: > Команды для проверки в процессе:
> >
> - `pnpm --dir web test` — существующий Vitest (42 файла, 121 инструмент частично покрыт юнит-тестами). > - `pnpm --dir web test` — существующий Vitest (42 файла, 121 инструмент частично покрыт юнит-тестами).
> - `pnpm --dir web test:e2e` — Playwright (`web/e2e/`), сборка + `scripts/serve-static.mjs` на 4173.
> - `pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json` — проверка типов. > - `pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json` — проверка типов.
> - `pnpm --dir web lint:all` — ESLint + stylelint + токен-аудит. > - `pnpm --dir web lint:all` — ESLint + stylelint + токен-аудит.
> - `pnpm --dir web build` — статическая сборка (adapter-static). > - `pnpm --dir web build` — статическая сборка (adapter-static).
@@ -158,10 +162,30 @@ GEOMETRY / FILTERS / TEXT / ANALYZE / GENERATE. Режимы инструмен
## 6. Шаги реализации (после ревью) ## 6. Шаги реализации (после ревью)
1. Playwright-раннер: `web/playwright.config.ts` (webServer на `pnpm build + 1. Playwright-раннер: `web/playwright.config.ts` (webServer на `pnpm build +
preview`, baseURL), папка `web/e2e/`, npm-скрипт `test:e2e`. `scripts/serve-static.mjs` `--port 4173`), папка `web/e2e/`, npm-скрипт
2. Фикстуры PNG: с альфой, без альфы, 1×1, большой, повреждённый. `test:e2e`.
3. Написать e2e по блокам A–D и секции 3 (авто-часть). 2. ✅ Фикстуры PNG (генерируются в рантайме, `web/e2e/helpers/fixtures.ts`):
4. Ручной чек-лист из секции 3 (ручная часть) — отдельный прогон. с альфой, без альфы, 1×1, большой, повреждённый, ландшафт.
5. Найденные баги — отдельными атомарными задачами; исправления по конвенции 3. ✅ E2E по блокам A–D и секции 3 (авто-часть): `navigation.spec.ts`,
AGENTS.md (lint:all после правок). `catalog.spec.ts`, `pipeline.spec.ts`, `text-and-verdicts.spec.ts`,
`tools-smoke.spec.ts` (матрица ~71 инструмента), `generators.spec.ts`,
`known-issues.spec.ts` (fixme).
4. ✅ Ручной чек-лист из секции 3 (ручная часть) —
`docs/checklist-manual-testing.md` (разделы A–G, включая известные баги).
5. ✅ Найденные баги (зафиксированы как `test.fixme` + чек-лист §G):
1. генераторы (21/121) не имеют кнопки «Generate» и полей схемы —
результат через UI недостижим;
2. `resize-png` с дефолтом 0×0 → `errors.resizeSize` при любом входе;
3. `crop-png` с дефолтом 0×0 → `errors.cropSize` при любом входе;
4. ключи ошибок показываются сырыми (`errors.*`) вместо переведённого
текста из `en.ts`/`ru.ts`.
Исправления — отдельными атомарными задачами по конвенции AGENTS.md
(после правок: `lint:all`, `test:e2e`).
## 7. Итоговое состояние на момент сдачи
- `pnpm --dir web test:e2e`**104 passed, 4 skipped (fixme), 0 failed**.
- `pnpm --dir web lint:all` и `svelte-check` — зелёные.
- Визуальный слой, краевые PNG/параметры, кросс-браузеры — остаются на ручной
прогон по `docs/checklist-manual-testing.md`.
+27
View File
@@ -1,5 +1,32 @@
# Backlog # Backlog
## Баги preview
Зарегистрированы при написании e2e (`web/e2e/`, `pnpm --dir web test:e2e`) и
ручного чек-листа — см. `docs/checklist-manual-testing.md` (раздел G). Каждый
зафиксирован как `test.fixme`. После фикса бага: убрать соответствующий
`test.fixme` и отметить пункт G в чек-листе как пройденный.
1. **Генераторы без кнопки «Generate»** — все 21 инструмент категории GENERATE
открываются, но в UI нет кнопки Generate и полей схемы (только RU/EN/Reset);
результат через UI недостижим. В `SchemaToolView.svelte` `isGenerator`
определяется как `tool.generate && !tool.run`, кнопка рисуется только если
`ongenerate` прокинут, а для генераторов с дефолтным `input: "file"` — нет.
Ожидание: поля схемы + кнопка «Generate», результат-картинка по дефолтам.
Fixme: `web/e2e/generators.spec.ts`.
2. **resize-png с дефолтами падает** — схема по умолчанию `size: 0×0` +
`keepAspect: true` → при загрузке ЛЮБОГО PNG сразу `errors.resizeSize` (alert),
результата нет. `registry-new/geometry.ts`. Ожидание: осмысленный дефолт-размер
(например, исходный) либо кнопка, а не авто-ошибка.
3. **crop-png с дефолтами падает** — аналогично: `width: 0, height: 0` → сразу
`errors.cropSize`. `registry-new/geometry.ts`. Ожидание: базовый кроп по
умолчанию либо внятное предзаполнение.
4. **Сырые ключи ошибок в UI** — alert на tool-странице показывает сырой ключ
i18n (`errors.resizeSize`), а не сообщение из `en.ts` / `ru.ts` (там
`resizeSize: "Width and/or height must be positive"`). Проверяется на
resize-png / crop-png. Ожидание: человекочитаемое сообщение на текущем языке.
Fixme: `web/e2e/known-issues.spec.ts` (тест «error message localized»).
## Идеи ## Идеи
1. **Менять первый инструмент цепочки** — оценка M. 1. **Менять первый инструмент цепочки** — оценка M.
+76
View File
@@ -0,0 +1,76 @@
# Чек-лист ручного тестирования preview
> Вспомогательный документ к `docs/archive/plan-testing-preview.md` и e2e-тестам
> (`web/e2e/`, `pnpm --dir web test:e2e`). Автоматизированная часть закрывает
> «нет падений/ошибок»; здесь — то, что руками, и что Playwright не покрывает.
>
> Перед прогоном: `pnpm --dir web build`, поднять локально `web/` (dev или
> build → `node scripts/serve-static.mjs`).
>
> Критерий приёмки большинства пунктов: **нет зависит, нет «залипания»,
> сообщения об ошибках человекочитаемые**.
## A. Краевые PNG-файлы
- [ ] Огромное изображение (мегапиксели) — загрузка не висит, прогресс есть.
- [ ] PNG 1×1 — инструменты не падают (проверено авто: flip-png, см. suite).
- [ ] PNG с полупрозрачностью/чётким альфа-краёв (чёрно-белая шахматка) —
remove-background, feather-edges, clean-edges дают корректные края.
- [ ] Палитровый PNG (индексированные цвета) — конвертируется/анализируется.
- [ ] Анимированный/APNG, если попадётся — не ломает конвейер.
- [ ] Повреждённый/не-PNG файл — понятная ошибка, нет краша (авто-чека в
pipeline.spec.ts).
- [ ] PNG с битой CRCh / 16-bit — как ведут себя анализаторы (ориентация,
размер, прозрачность).
## B. Края параметров
- [ ] Экстремальные значения слайдеров (0 и max) в blur/sharpen/pixelate —
не «залипает» running.
- [ ] Пустые/NaN поля (напр. width/height) — санитизация (silent repair), нет
красного alert-текста в виде `errors.*`.
- [ ] Невалидные цвета в плашках/градиентах (мисс-спелл `#xyz`, короткие hex,
именные цвета) — silent repair или понятная ошибка.
- [ ] Пустой text-source на text-инструменте — run пропускается, ошибок нет
(кейс из плана п.14).
- [ ] Смена параметра после Reset — авто-перезапуск работает (Debounce 200 мс).
## C. Буфер обмена и drag-n-drop
- [ ] В реальном браузере кнопка copy на текстовом результате (png-to-base64
и т.п.) кладёт текст в буфер (в Playwright не тестируем из-за permissions).
- [ ] Drag-n-drop файла на dropzone страницы инструмента (не только
файл-диалог) — UX-ощущения и корректность.
## D. Производительность и отзывчивость
- [ ] Долгие авто-рераны на больших картинках (jpeg-artifacts, dithering,
quantize на 4К) — UI не фризит, канселяция/дебаунс срабатывает.
- [ ] Скролл страницы при длинных настройках (например, text-инструменты) —
нет залипаний, панели не прыгают.
- [ ] Вкладка не «съедает» память при 10+ повторах blur/sharpen на мегапикселе
(утечек blob-URL/bitmap быть не должно).
## E. Визуальный слой (доделываем по ходу)
- [ ] Сетки/выравнивание на брейкпоинтах 640 / 800 / 1100 (mobile/tablet/
desktop) на всех 4 маршрутах.
- [ ] Тема light/dark: контраст вердиктов (Yes/No), статус «LIVE PREVIEW»,
мета-инфо бликов не даёт.
- [ ] Иконки в каталоге на всех категориях — не «бьются» (missing icon).
- [ ] Язык RU/EN на превью-страницах — переключение без регресса рендера.
## F. Субъективная корректность
- [ ] dithering / two-colors / quantize — результат «по ощущениям» соответствует
описанию.
- [ ] watermark-tile / add-text / date-stamp на кириллице — рендер текста
корректный (не «кракозябры»).
- [ ] remove-background на сложной полупрозрачности — края не «звенят».
## Как долго
- Полный прогон: ~40 минут (все разделы, включая мегапиксели и 4К) в двух
браузерах.
- Быстрый смоук (< 15 мин): A (кроме мегапикселей), B (выборочно), C, F
выборочно, G обязательно.
+5
View File
@@ -21,3 +21,8 @@ Thumbs.db
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# Playwright
/test-results
/playwright-report
/e2e-results
+55
View File
@@ -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");
});
+24
View File
@@ -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);
});
});
+120
View File
@@ -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(" ");
}
+52
View File
@@ -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([]);
}
+40
View File
@@ -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);
});
});
+53
View File
@@ -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();
});
+114
View File
@@ -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);
});
}
+102
View File
@@ -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");
});
+124
View File
@@ -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);
});
}
});
+11
View File
@@ -50,7 +50,18 @@ export default tseslint.config(
"vitest.config.ts", "vitest.config.ts",
"eslint.config.js", "eslint.config.js",
"svelte.config.js", "svelte.config.js",
"playwright.config.ts",
"e2e/helpers/fixtures.ts",
"e2e/helpers/page.ts",
"e2e/navigation.spec.ts",
"e2e/catalog.spec.ts",
"e2e/pipeline.spec.ts",
"e2e/text-and-verdicts.spec.ts",
"e2e/tools-smoke.spec.ts",
"e2e/generators.spec.ts",
"e2e/known-issues.spec.ts",
], ],
maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 12,
}, },
extraFileExtensions: [".svelte"], extraFileExtensions: [".svelte"],
}, },
+1
View File
@@ -11,6 +11,7 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest run", "test": "vitest run",
"test:e2e": "playwright test",
"format": "prettier --write . --log-level warn", "format": "prettier --write . --log-level warn",
"lint": "eslint .", "lint": "eslint .",
"lint:css": "stylelint \"./src/**/*.css\"", "lint:css": "stylelint \"./src/**/*.css\"",
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig } from "playwright/test";
export default defineConfig({
testDir: "./e2e",
outputDir: "./e2e-results",
fullyParallel: true,
timeout: 60_000,
retries: 0,
reporter: [["list"], ["html", { open: "never" }]],
use: {
baseURL: "http://127.0.0.1:4173",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: {
browserName: "chromium",
viewport: { width: 1440, height: 900 },
},
},
],
webServer: {
command: "pnpm build && node scripts/serve-static.mjs --port 4173",
url: "http://127.0.0.1:4173/preview",
reuseExistingServer: !process.env.CI,
timeout: 180_000,
},
});
+68
View File
@@ -0,0 +1,68 @@
// Статический сервер для e2e (Playwright webServer). Раздаёт web/build
// (adapter-static): /foo -> foo.html | foo/index.html, MIME по расширению.
// Запуск: pnpm build && node scripts/serve-static.mjs --port 4173
import { createServer } from "node:http";
import { readFileSync, existsSync, statSync } from "node:fs";
import { extname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const portIndex = process.argv.indexOf("--port");
const PORT =
portIndex >= 0 ? Number(process.argv[portIndex + 1]) || 4173 : 4173;
const ROOT = fileURLToPath(new URL("../build", import.meta.url));
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript",
".mjs": "text/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff2": "font/woff2",
".woff": "font/woff",
".ttf": "font/ttf",
".txt": "text/plain; charset=utf-8",
".xml": "application/xml",
".map": "application/json",
};
function candidates(pathname) {
const rel = pathname.replace(/\\/g, "/").replace(/^\/+/, "");
const base = rel ? join(ROOT, ...rel.split("/")) : ROOT;
const out = [base];
if (!extname(base)) {
out.push(`${base}.html`);
out.push(join(base, "index.html"));
}
return out;
}
function serve(req, res) {
const url = new URL(req.url, `http://${req.headers.host}`);
const path = decodeURIComponent(url.pathname);
for (const file of candidates(path)) {
try {
if (!statSync(file).isFile()) continue;
const body = readFileSync(file);
res.writeHead(200, {
"Content-Type": MIME[extname(file)] ?? "application/octet-stream",
"Cache-Control": "no-store",
});
res.end(body);
return;
} catch {
/* not found — try next candidate */
}
}
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Not found: " + path);
}
createServer(serve).listen(PORT, "127.0.0.1", () => {
console.log(`[serve-static] serving ${ROOT} on http://127.0.0.1:${PORT}`);
});