diff --git a/AGENTS.md b/AGENTS.md
index 532c51a..5be3433 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -9,7 +9,8 @@
обёртки в корне: `pnpm build`, `pnpm dev` (это `pnpm --dir web ...`).
Не использовать npm.
- Сборка: `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`,
конфиг `web/.prettierrc`, игнор `web/.prettierignore`). Проверка без записи:
`pnpm --dir web exec prettier --check .`.
diff --git a/docs/plan-testing-preview.md b/docs/archive/plan-testing-preview.md
similarity index 80%
rename from docs/plan-testing-preview.md
rename to docs/archive/plan-testing-preview.md
index a8ded0d..3bc0165 100644
--- a/docs/plan-testing-preview.md
+++ b/docs/archive/plan-testing-preview.md
@@ -1,8 +1,11 @@
# План: тестирование preview перед промоушеном в главную
-> Статус: план тестирования ветки preview (`web/src/routes/preview/**`,
-> регистр `lib/registry-new/`, 121 инструмент) перед шагом C17 из
-> `docs/plan-redesign.md` (переезд preview → реальные маршруты).
+> Статус: **реализация завершена**. Playwright-набор в `web/e2e/`
+> (108 тестов: 104 прошли, 4 `test.fixme` — известные баги №1–4 ниже),
+> ручной чек-лист — `docs/checklist-manual-testing.md`. План ветки preview
+> (`web/src/routes/preview/**`, регистр `lib/registry-new/`, 121 инструмент)
+> перед шагом C17 из `docs/plan-redesign.md` (переезд preview → реальные
+> маршруты).
>
> Охват: **документация плана** — реализация тестов (Playwright-раннер,
> сами тесты) делается отдельными задачами после ревью этого документа.
@@ -13,6 +16,7 @@
> Команды для проверки в процессе:
>
> - `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 lint:all` — ESLint + stylelint + токен-аудит.
> - `pnpm --dir web build` — статическая сборка (adapter-static).
@@ -158,10 +162,30 @@ GEOMETRY / FILTERS / TEXT / ANALYZE / GENERATE. Режимы инструмен
## 6. Шаги реализации (после ревью)
-1. Playwright-раннер: `web/playwright.config.ts` (webServer на `pnpm build +
-preview`, baseURL), папка `web/e2e/`, npm-скрипт `test:e2e`.
-2. Фикстуры PNG: с альфой, без альфы, 1×1, большой, повреждённый.
-3. Написать e2e по блокам A–D и секции 3 (авто-часть).
-4. Ручной чек-лист из секции 3 (ручная часть) — отдельный прогон.
-5. Найденные баги — отдельными атомарными задачами; исправления по конвенции
- AGENTS.md (lint:all после правок).
+1. ✅ Playwright-раннер: `web/playwright.config.ts` (webServer на `pnpm build +
+ `scripts/serve-static.mjs` `--port 4173`), папка `web/e2e/`, npm-скрипт
+ `test:e2e`.
+2. ✅ Фикстуры PNG (генерируются в рантайме, `web/e2e/helpers/fixtures.ts`):
+ с альфой, без альфы, 1×1, большой, повреждённый, ландшафт.
+3. ✅ E2E по блокам A–D и секции 3 (авто-часть): `navigation.spec.ts`,
+ `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`.
diff --git a/docs/backlog.md b/docs/backlog.md
index bb2447b..b33315e 100644
--- a/docs/backlog.md
+++ b/docs/backlog.md
@@ -1,5 +1,32 @@
# 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.
diff --git a/docs/checklist-manual-testing.md b/docs/checklist-manual-testing.md
new file mode 100644
index 0000000..ddbf7f0
--- /dev/null
+++ b/docs/checklist-manual-testing.md
@@ -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 обязательно.
diff --git a/web/.gitignore b/web/.gitignore
index 3b462cb..76dcdd0 100644
--- a/web/.gitignore
+++ b/web/.gitignore
@@ -21,3 +21,8 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+
+# Playwright
+/test-results
+/playwright-report
+/e2e-results
diff --git a/web/e2e/catalog.spec.ts b/web/e2e/catalog.spec.ts
new file mode 100644
index 0000000..792482d
--- /dev/null
+++ b/web/e2e/catalog.spec.ts
@@ -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");
+});
diff --git a/web/e2e/generators.spec.ts b/web/e2e/generators.spec.ts
new file mode 100644
index 0000000..581cd4f
--- /dev/null
+++ b/web/e2e/generators.spec.ts
@@ -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);
+ });
+});
diff --git a/web/e2e/helpers/fixtures.ts b/web/e2e/helpers/fixtures.ts
new file mode 100644
index 0000000..3e55e0d
--- /dev/null
+++ b/web/e2e/helpers/fixtures.ts
@@ -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 =
+ '';
+
+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(" ");
+}
diff --git a/web/e2e/helpers/page.ts b/web/e2e/helpers/page.ts
new file mode 100644
index 0000000..22e4d32
--- /dev/null
+++ b/web/e2e/helpers/page.ts
@@ -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 {
+ await page.goto(`/preview/tools/${id}`);
+ await expect(page.locator(".schema-tool h1")).toBeVisible();
+}
+
+export async function uploadImage(page: Page, file: SourceFile): Promise {
+ 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 {
+ 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 {
+ const [download] = await Promise.all([
+ page.waitForEvent("download"),
+ page.locator(selector).click(),
+ ]);
+ return download.suggestedFilename();
+}
+
+export async function expectNoErrorAlert(page: Page): Promise {
+ await expect(page.locator('[role="alert"]')).toHaveCount(0);
+}
+
+export async function expectNoErrors(sink: ErrorSink): Promise {
+ expect(sink.errors, "нет console/page errors").toEqual([]);
+}
diff --git a/web/e2e/known-issues.spec.ts b/web/e2e/known-issues.spec.ts
new file mode 100644
index 0000000..6be7138
--- /dev/null
+++ b/web/e2e/known-issues.spec.ts
@@ -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);
+ });
+});
diff --git a/web/e2e/navigation.spec.ts b/web/e2e/navigation.spec.ts
new file mode 100644
index 0000000..61aa80b
--- /dev/null
+++ b/web/e2e/navigation.spec.ts
@@ -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();
+});
diff --git a/web/e2e/pipeline.spec.ts b/web/e2e/pipeline.spec.ts
new file mode 100644
index 0000000..e55c9c5
--- /dev/null
+++ b/web/e2e/pipeline.spec.ts
@@ -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);
+ });
+}
diff --git a/web/e2e/text-and-verdicts.spec.ts b/web/e2e/text-and-verdicts.spec.ts
new file mode 100644
index 0000000..bcd5c1c
--- /dev/null
+++ b/web/e2e/text-and-verdicts.spec.ts
@@ -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");
+});
diff --git a/web/e2e/tools-smoke.spec.ts b/web/e2e/tools-smoke.spec.ts
new file mode 100644
index 0000000..52562d3
--- /dev/null
+++ b/web/e2e/tools-smoke.spec.ts
@@ -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 => {
+ 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);
+ });
+ }
+});
diff --git a/web/eslint.config.js b/web/eslint.config.js
index 4e64c75..c01abb3 100644
--- a/web/eslint.config.js
+++ b/web/eslint.config.js
@@ -50,7 +50,18 @@ export default tseslint.config(
"vitest.config.ts",
"eslint.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"],
},
diff --git a/web/package.json b/web/package.json
index 64e4291..a6ce3fa 100644
--- a/web/package.json
+++ b/web/package.json
@@ -11,6 +11,7 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest run",
+ "test:e2e": "playwright test",
"format": "prettier --write . --log-level warn",
"lint": "eslint .",
"lint:css": "stylelint \"./src/**/*.css\"",
diff --git a/web/playwright.config.ts b/web/playwright.config.ts
new file mode 100644
index 0000000..af24e8a
--- /dev/null
+++ b/web/playwright.config.ts
@@ -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,
+ },
+});
diff --git a/web/scripts/serve-static.mjs b/web/scripts/serve-static.mjs
new file mode 100644
index 0000000..d1c5a62
--- /dev/null
+++ b/web/scripts/serve-static.mjs
@@ -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}`);
+});