From 5313567e701e7aea122c5b870f8294005ef6c95e Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Fri, 25 Sep 2026 17:40:58 +0500 Subject: [PATCH] test: less fragile - use semantic locators\helpers --- AGENTS.md | 8 ++ docs/README.md | 11 ++- docs/agent-workflow.md | 5 +- docs/backlog.md | 5 +- docs/checklist-manual-testing.md | 17 ++-- docs/plan-codebase-quality.md | 56 +++++------ docs/quality-gates.md | 3 +- docs/testing-strategy.md | 87 +++++++++++++++++ web/e2e/catalog.spec.ts | 65 ++++++------- web/e2e/generators.spec.ts | 17 ++-- web/e2e/helpers/page.ts | 72 ++++++++++---- web/e2e/i18n.spec.ts | 29 +++--- web/e2e/known-issues.spec.ts | 20 ++-- web/e2e/navigation.spec.ts | 55 +++++------ web/e2e/pipeline.spec.ts | 94 ++++++++----------- web/e2e/text-and-verdicts.spec.ts | 89 ++++++++++-------- web/e2e/tools-smoke.spec.ts | 11 ++- web/src/lib/components/CatalogGroup.svelte | 5 +- web/src/lib/components/CatalogHeader.svelte | 7 +- web/src/lib/components/CatalogToolbar.svelte | 1 + .../lib/components/SchemaResultTile.svelte | 10 +- .../lib/components/SchemaSourceTile.svelte | 2 +- .../lib/components/SchemaTextResult.svelte | 10 +- .../lib/components/SchemaTextSource.svelte | 1 + .../fields/schema/RangeControl.svelte | 1 + web/src/lib/i18n/schema-tool-strings.test.ts | 29 ++++++ 26 files changed, 436 insertions(+), 274 deletions(-) create mode 100644 docs/testing-strategy.md diff --git a/AGENTS.md b/AGENTS.md index 64a7627..c168c52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,14 @@ Типизация пропсов через локальный `interface Props` закреплена правилом `conventions/interface-props`; руками его нигде дублировать не надо. +### E2E + +- Сначала используй `role`, `aria-*`, label и стабильный `href`; `data-testid` + разрешён только для source/result/verdict/empty-state. +- `test.fixme` допускается только для зарегистрированной задачи в backlog или + checklist; после исправления тест и ссылку обновляют вместе. +- Полные переводы проверяются в unit-тестах; e2e проверяет поведение. + ### Сначала искать Новая константа или тип не заводится, пока не произведён поиск существующего diff --git a/docs/README.md b/docs/README.md index 368b160..67c5700 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,13 +20,14 @@ | `AGENTS.md` | Краткий обязательный контракт для агента | active | | `docs/plan-codebase-quality.md` | План quality gates, тестов и lint-правил | draft | | `docs/agent-workflow.md` | Подробный порядок исследования, изменения и проверки | active | +| `docs/testing-strategy.md` | Locator, fixture, i18n и fixme-policy для e2e | active | | `docs/quality-gates.md` | Команды проверки, warnings и CI | active | | `docs/backlog.md` | Продуктовые, UX- и инфраструктурные задачи | active | | `docs/roadmap.md` | Общие направления развития | active | | `docs/plan-redesign.md` | Новый дизайн production UI | active | | `docs/plan-seo.md` | SEO/GEO-план | active | | `docs/plan-platform.md` | PWA/API/WASM-план | active | -| `docs/checklist-manual-testing.md` | Ручная приёмка preview | active | +| `docs/checklist-manual-testing.md` | Ручная приёмка production UI | active | | `docs/tools-map.md` | Живая карта инструментов | active | | `web/eslint-plugins/README.md` | Детали custom lint rules и token policy | active | | `docs/archive/README.md` | Правила архивирования и удаления старых документов | active | @@ -50,17 +51,17 @@ только если темы нет в существующем документе; иначе обновляется источник истины. -## Текущий baseline Q3 +## Текущий baseline Q4 -На момент фиксации Q3: +На момент фиксации Q4: - production registry содержит 122 инструмента; - `pnpm --dir web check` проходит без ошибок и предупреждений; -- `pnpm --dir web test` — 39 test files / 422 tests; +- `pnpm --dir web test` — 39 test files / 425 tests; - `pnpm --dir web test:rules` — 4 test files / 74 tests; - `pnpm --dir web lint:all` — 0 errors и 0 i18n warnings; остаются 4 unused design tokens от token audit; -- `pnpm verify` проходит; production E2E — 105 passed, 5 skipped. +- `pnpm verify` проходит; production E2E — 105 passed, 4 skipped. Baseline и следующий шаг поддерживаются в `docs/plan-codebase-quality.md`. diff --git a/docs/agent-workflow.md b/docs/agent-workflow.md index 1af9921..4fbb97a 100644 --- a/docs/agent-workflow.md +++ b/docs/agent-workflow.md @@ -54,6 +54,9 @@ E2E запускается отдельно от быстрых проверок - Поведенческий тест проверяет пользовательский контракт, а не текущую реализацию. - Сначала предпочитать `role`, `aria-*`, label, stable id или href. +- `data-testid` ограничен source/result/verdict/empty-state; общие + locator-паттерны собраны в `web/e2e/helpers/page.ts`, policy — в + `docs/testing-strategy.md`. - `data-testid` использовать только там, где семантического locator нет. - Не фиксировать абсолютное число инструментов, если проверка не связана с этим инвариантом. @@ -72,7 +75,7 @@ E2E запускается отдельно от быстрых проверок - обязательное поведение агента — `AGENTS.md`; - индекс и карта документов — `docs/README.md`; - качество и точные команды — `docs/quality-gates.md`; -- тестовая архитектура — `docs/testing-strategy.md` после Q4; +- тестовая архитектура — `docs/testing-strategy.md`; - lint implementation — `web/eslint-plugins/README.md`; - продуктовые решения — backlog и активные планы. diff --git a/docs/backlog.md b/docs/backlog.md index 98c450f..7f42ed4 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -132,10 +132,7 @@ `resizeSize: "Width and/or height must be positive"`). Проверяется на resize-png / crop-png. Ожидание: человекочитаемое сообщение на текущем языке. Fixme: `web/e2e/known-issues.spec.ts` (тест «error message localized»). -5. **Theme storage key в e2e устарел** — production использует ключ `theme`, а - `web/e2e/navigation.spec.ts:30-48` проверяет `easy-png-tools:theme`. - Ожидание: исправить тест на фактический контракт и снять `test.fixme`. -6. **Reset и auto-run требуют решения** — `pipeline.spec.ts:55-63` ожидает +5. **Reset и auto-run требуют решения** — `pipeline.spec.ts:55-63` ожидает очистку результата, но текущий `SchemaToolView.svelte:162-173` может сразу перезапустить обработку. Нужно определить семантику Reset, затем исправить тест и снять `test.fixme`. diff --git a/docs/checklist-manual-testing.md b/docs/checklist-manual-testing.md index 537fa01..3b8895e 100644 --- a/docs/checklist-manual-testing.md +++ b/docs/checklist-manual-testing.md @@ -1,13 +1,13 @@ -# Чек-лист ручного тестирования preview +# Чек-лист ручного тестирования production UI -> Вспомогательный документ к `docs/archive/plan-testing-preview.md` и e2e-тестам -> (`web/e2e/`, `pnpm --dir web test:e2e`). Автоматизированная часть закрывает +> Вспомогательный документ к e2e-тестам (`web/e2e/`, `pnpm --dir web test:e2e`) +> и стратегии `docs/testing-strategy.md`. Автоматизированная часть закрывает > «нет падений/ошибок»; здесь — то, что руками, и что Playwright не покрывает. > > Перед прогоном: `pnpm --dir web build`, поднять локально `web/` (dev или build > → `node scripts/serve-static.mjs`). > -> Критерий приёмки большинства пунктов: **нет зависит, нет «залипания», +> Критерий приёмки большинства пунктов: **нет зависаний, нет «залипания», > сообщения об ошибках человекочитаемые**. ## A. Краевые PNG-файлы @@ -23,8 +23,8 @@ - [ ] Анимированный/APNG, если попадётся — не ломает конвейер. - [ ] Повреждённый/не-PNG файл — понятная ошибка, нет краша (авто-чека в pipeline.spec.ts). -- [ ] PNG с битой CRCh / 16-bit — как ведут себя анализаторы (ориентация, - размер, прозрачность). +- [ ] PNG с битой CRC / 16-bit — как ведут себя анализаторы (ориентация, размер, + прозрачность). ### Файлы (`tests/fixtures/manual-edge-cases/`) @@ -59,8 +59,7 @@ красного alert-текста в виде `errors.*`. - [ ] Невалидные цвета в плашках/градиентах (мисс-спелл `#xyz`, короткие hex, именные цвета) — silent repair или понятная ошибка. -- [ ] Пустой text-source на text-инструменте — run пропускается, ошибок нет - (кейс из плана п.14). +- [ ] Пустой text-source на text-инструменте — run пропускается, ошибок нет. - [ ] Смена параметра после Reset — авто-перезапуск работает (Debounce 200 мс). ## C. Буфер обмена и drag-n-drop @@ -111,8 +110,6 @@ alert. `docs/backlog.md`, `web/e2e/known-issues.spec.ts`. - [ ] **Локализация ошибок:** получить человекочитаемое сообщение на текущем языке вместо сырого i18n-ключа. `docs/backlog.md`, `web/e2e/i18n.spec.ts`. -- [ ] **Theme persistence:** переключить тему и проверить сохранение в ключе - `theme`. `docs/backlog.md`, `web/e2e/navigation.spec.ts`. - [ ] **Reset/auto-run:** проверить выбранную семантику Reset и автоматический перезапуск после изменения параметров. `docs/backlog.md`, `web/e2e/pipeline.spec.ts`. diff --git a/docs/plan-codebase-quality.md b/docs/plan-codebase-quality.md index 6395885..9011b66 100644 --- a/docs/plan-codebase-quality.md +++ b/docs/plan-codebase-quality.md @@ -1,9 +1,9 @@ # План: линтеры, тесты и качество кодовой базы -> Статус: **draft — Q0–Q3 готовы к ревью; Q4 — следующий этап**. +> Статус: **draft — Q0–Q4 готовы к ревью; Q5 — следующий этап**. > > Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и -> инфраструктурных скриптов. Q0–Q3 выполнены; Q4–Q8 выполняются отдельными +> инфраструктурных скриптов. Q0–Q4 выполнены; Q5–Q8 выполняются отдельными > атомарными шагами. > > Цель: сделать качество кодовой базы воспроизводимым, убрать ложную «зелёность» @@ -152,36 +152,30 @@ pnpm --dir web test:e2e - Проверены production build, verify и отдельный E2E smoke production-маршрутов. **Результат:** `/v1/**`, UI-v1-файлы и legacy-конфигурация отсутствуют; -следующий этап — Q4. +следующий этап — Q5. ### Q4. Тестовый контракт e2e -**Оценка:** M. **Зависимости:** Q0–Q3, решение по i18n и генераторам. +**Статус:** выполнен. **Проверено:** `pnpm verify` и полный Playwright smoke +проходят; 105 тестов passed, 4 known-issue теста skipped. **Оценка:** M. +**Зависимости:** Q0–Q3. -- Основной locator: `role`, `aria-*`, `getByLabel`, stable `id`/`href`. -- `data-testid` использовать только там, где семантического API недостаточно: - source, result, empty-state, download. -- Убрать зависимость от: - - абсолютных чисел `TOTAL`/`GROUPS`; - - точного количества fuzzy-search результатов; - - внутренних CSS-классов; - - `img[alt="Result image"]`; - - полных локализованных предложений. -- Для каталога проверять конкретные инструменты и согласованность отображённых - карточек общему числу, а не фиксированное число `122`. -- Централизовать helpers для открытия инструмента, загрузки, результата, ошибки - и download. -- Зафиксировать locator/i18n/fixture/fixme-политику в - `docs/testing-strategy.md`; детали конкретных тестов оставлять рядом с ними. -- В unit-тестах проверять ключи через `t()` и i18n helpers. В e2e проверять - поведение и отдельный i18n smoke; `data-i18n-key` добавлять только при - реальной необходимости тестировать ключ в DOM. -- Убрать нестандартные символы из поведенческих ожиданий (`→`, длинные тире, - `×`); названия тестов не переписывать механически без необходимости. -- Исправить stale-проверки темы, source-locator и locale assertions. +- Добавлен `docs/testing-strategy.md` с locator, fixture, i18n и `test.fixme` + policy. +- В production markup добавлены только разрешённые hooks для source/result/ + verdict/empty-state; текстовые элементы получили accessible labels, фильтры — + `aria-pressed`, а каталог — семантические heading/status landmarks. +- E2E переведён на `role`, label и stable `href`; удалены `TOTAL`/`GROUPS`, + exact fuzzy counts, internal CSS selectors, `alt`-locators и полные verdict + strings. Точные verdict-переводы перенесены в unit-тесты. +- Helpers централизованы в `web/e2e/helpers/page.ts`; исправлены stale + theme/source/locale checks. Theme fixme снят. +- Q5-фиксы resize/crop, generator, Reset и error-сценария не смешивались с этим + этапом. -**Готово, когда:** изменение формулировки, класса или порядка элементов не -ломает поведенческий тест без изменения пользовательского контракта. +**Результат:** изменение формулировки, класса или порядка элементов не ломает +поведенческий тест без изменения пользовательского контракта; следующий этап — +Q5. ### Q5. Закрыть известные preview-баги @@ -379,9 +373,9 @@ refactor, новые i18n-правила и исправление продук ## 7. Решения, которые нужно принять на ревью -- Какие semantic locators и допустимые `data-testid` закрепить в Q4? -- Какие browser/e2e проверки должны быть обязательными для PR, а какие — - отдельным ночным прогоном? +- Какой default выбрать для `resize-png` и `crop-png` при загрузке? - Подтверждаем ли auto-run генераторов без кнопки Generate? - Что именно должен делать Reset при включённом auto-run? -- Какие i18n-сценарии проверяются в e2e, а какие остаются unit-проверками? +- Какой независимый invalid-input сценарий заменит resize-dependent error test? +- Какие browser/e2e проверки должны быть обязательными для PR, а какие — + отдельным ночным прогоном? diff --git a/docs/quality-gates.md b/docs/quality-gates.md index e2f0a27..a979663 100644 --- a/docs/quality-gates.md +++ b/docs/quality-gates.md @@ -27,7 +27,8 @@ pnpm verify ## E2E E2E не входит в `verify`, потому что Playwright поднимает build и статический -сервер: +сервер. Locator, fixture, i18n и `test.fixme`-политика описаны в +`docs/testing-strategy.md`: ```text pnpm --dir web test:e2e diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md new file mode 100644 index 0000000..ebe931a --- /dev/null +++ b/docs/testing-strategy.md @@ -0,0 +1,87 @@ +# Стратегия e2e-тестирования + +> Статус: **active**. Канонические правила для `web/e2e/`; продуктовые решения и +> известные баги остаются в `docs/backlog.md` и +> `docs/checklist-manual-testing.md`. + +## 1. Что проверяем + +E2E проверяет пользовательский контракт production UI: открытие маршрута, +доступность элемента, загрузку или ввод данных, появление результата, ошибку, +скачивание и переключение локали. Внутренняя реализация, порядок карточек, +CSS-классы и конкретный формат временного URL не являются контрактом. + +Точные переводы проверяются в unit-тестах i18n. В e2e проверяется поведение и +отдельный RU/EN smoke, а не полный локализованный текст каждого verdict. + +## 2. Приоритет locator-ов + +1. `getByRole()` с доступным именем и `getByLabel()`. +2. Стабильные `href` или `id`, если семантического API нет. +3. Разрешённые `data-testid` только для визуальных границ, которые нельзя + надёжно назвать через accessibility API: + - `source-image`; + - `result-image`; + - `result-verdict`; + - `empty-state`. +4. `input[type="file"]` и download-button допускаются только внутри + `web/e2e/helpers/page.ts`. + +Нельзя добавлять `data-testid` для счётчиков, групп, metadata, verdict текста, +кнопок темы или переключателя языка без отдельного обоснования. + +## 3. Запрещённые зависимости + +В e2e не фиксируются: + +- абсолютные `TOTAL`, `GROUPS` и другие snapshot-числа каталога; +- точное число fuzzy-search результатов или порядок карточек; +- внутренние CSS-классы и HTML-структура, если есть role/label/href; +- `alt` как способ найти результат или источник; +- полные локализованные предложения и длинные тире в ожиданиях; +- локализованные имена контролов в общих сценариях без явной установки `en`. + +Поведенческий тест может проверять, что известный инструмент видим или скрыт, +ошибка имеет текст и не является сырым i18n-ключом, результат доступен для +скачивания. Это не требует повторять словарь в e2e. + +## 4. Локаль и fixtures + +Обычные сценарии запускаются с `en` через `useEnglish()` или `openTool()`. RU/EN +проверяется отдельным `i18n.spec.ts`; после смены локали locator не должен +зависеть от старого accessible name. + +Изображения создаются воспроизводимыми helpers в `web/e2e/helpers/fixtures.ts`. +Тесты не добавляют большие бинарные файлы и не зависят от локальных +пользовательских файлов. `toPass()` допускается только для ограниченного +ожидания hydration или асинхронного результата; он не должен превращать проверку +продукта в бесконечный retry. + +## 5. Helpers и `test.fixme` + +`web/e2e/helpers/page.ts` владеет общими операциями `openTool`, `uploadImage`, +`resultImage`, `sourceImage`, `verdictStatus`, `textResult`, `errorAlert`, +`emptyState`, `rangeInput` и `downloadResult`. Специфика должна вызывать эти +helpers, а не повторять raw-селекторы. + +`test.fixme` допускается только для зарегистрированной проблемы в backlog или +checklist. В комментарии указывается ссылка на неё; Q4 не исправляет продуктовое +поведение. После исправления тест и ссылка обновляются вместе. + +## 6. Проверки + +Для изменений e2e выполняются: + +```text +pnpm --dir web check +pnpm --dir web test:rules +pnpm --dir web lint:all +pnpm --dir web exec prettier --check . +pnpm check:docs +pnpm --dir web test:e2e +pnpm verify +``` + +`verify` не включает Playwright: E2E остаётся отдельным тяжёлым gate. В CI +non-blocking E2E не считается заменой локальному smoke; перед ревью фиксируются +число passed, skipped и известные fixme. diff --git a/web/e2e/catalog.spec.ts b/web/e2e/catalog.spec.ts index 07e4b48..3b31f8e 100644 --- a/web/e2e/catalog.spec.ts +++ b/web/e2e/catalog.spec.ts @@ -1,55 +1,50 @@ import { expect, test } from "playwright/test"; -import { expectNoErrors, trackErrors } from "./helpers/page"; +import { + expectNoErrors, + toolLink, + trackErrors, + useEnglish, +} from "./helpers/page"; -const TOTAL = 122; -const GROUPS = 8; +test.beforeEach(async ({ page }) => { + await useEnglish(page); +}); -test("catalog shows total and all groups", async ({ page }) => { +test("catalog exposes a total and category headings", async ({ page }) => { const sink = trackErrors(page); await page.goto("/list-tools"); - await expect(page.locator(".catalog-total b")).toHaveText(String(TOTAL)); - await expect(page.locator(".catalog-group")).toHaveCount(GROUPS); + const total = page.getByRole("status", { name: /tools available/i }); + await expect(total).toBeVisible(); + await expect(total).toHaveAttribute("aria-label", /^\d+ /); + await expect(page.getByRole("heading", { level: 2 }).first()).toBeVisible(); expectNoErrors(sink); }); test("catalog search narrows results", async ({ page }) => { await page.goto("/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"); + await page.getByRole("textbox", { name: "Search tools" }).fill("resize"); + await expect(toolLink(page, "resize-png")).toBeVisible(); + await expect(toolLink(page, "flip-png")).toBeHidden(); }); -test("category filter shows only matching group and resets on ALL", async ({ - page, -}) => { +test("category filter narrows the catalog and resets", async ({ page }) => { await page.goto("/list-tools"); - const allButtons = page.locator(".tool-card"); - const allCount = await allButtons.count(); - expect(allCount).toBe(TOTAL); + const convert = page.getByRole("button", { name: /convert/i, exact: true }); + const all = page.getByRole("button", { name: /^all$/i }); - 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 convert.click(); + await expect(convert).toHaveAttribute("aria-pressed", "true"); + await expect(toolLink(page, "convert-png-to-jpg")).toBeVisible(); + await expect(toolLink(page, "flip-png")).toBeHidden(); - 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); + await all.click(); + await expect(all).toHaveAttribute("aria-pressed", "true"); + await expect(toolLink(page, "flip-png")).toBeVisible(); }); test("workspace search matches a tool by title", async ({ page }) => { await page.goto("/"); - 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"); + await page.getByRole("textbox", { name: "Search tools" }).fill("flip"); + await expect(toolLink(page, "flip-png")).toBeVisible(); + await expect(toolLink(page, "resize-png")).toBeHidden(); }); diff --git a/web/e2e/generators.spec.ts b/web/e2e/generators.spec.ts index 93be4a4..17857b8 100644 --- a/web/e2e/generators.spec.ts +++ b/web/e2e/generators.spec.ts @@ -1,10 +1,15 @@ import { expect, test } from "playwright/test"; -import { expectNoErrors, openTool, trackErrors } from "./helpers/page"; +import { + expectNoErrors, + openTool, + resultImage, + trackErrors, +} from "./helpers/page"; test.describe("generators — known UI gap (#checklist, п.1)", () => { - // FIXME: Генераторы (21/121) открываются, но в UI нет ни полей схемы, ни кнопки - // «Generate» — только RU/EN/Reset. Пока баг открыт — fixme с ожидаемым - // сценарием; после фикса убрать fixme. + // FIXME: Генераторы открываются, но в UI нет ни полей схемы, ни кнопки + // «Generate» — только RU/EN/Reset. Задача Q5: `docs/backlog.md`, + // `docs/checklist-manual-testing.md`. После фикса убрать fixme. test.fixme("single-color-png renders Generate controls and produces a result", async ({ page, }) => { @@ -12,13 +17,13 @@ test.describe("generators — known UI gap (#checklist, п.1)", () => { const generate = page.getByRole("button", { name: "Generate" }); await expect(generate).toBeVisible(); await generate.click(); - await expect(page.locator('img[alt="Result image"]')).toBeVisible(); + await expect(resultImage(page)).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"); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); expectNoErrors(sink); }); }); diff --git a/web/e2e/helpers/page.ts b/web/e2e/helpers/page.ts index d6221b1..b6c81f9 100644 --- a/web/e2e/helpers/page.ts +++ b/web/e2e/helpers/page.ts @@ -1,6 +1,13 @@ -import { expect, type Page } from "playwright/test"; +import { expect, type Locator, type Page } from "playwright/test"; import type { SourceFile } from "./fixtures"; +export const TEST_IDS = { + source: "source-image", + result: "result-image", + verdict: "result-verdict", + empty: "empty-state", +} as const; + export type ErrorSink = { errors: string[] }; export function trackErrors(page: Page): ErrorSink { @@ -12,39 +19,72 @@ export function trackErrors(page: Page): ErrorSink { return { errors }; } +export async function useEnglish(page: Page): Promise { + await page.addInitScript(() => { + window.localStorage.setItem("locale", "en"); + }); +} + export async function openTool(page: Page, id: string): Promise { + await useEnglish(page); await page.goto(`/tools/${id}`); - await expect(page.locator(".schema-tool h1")).toBeVisible(); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); +} + +export function toolLink(page: Page, id: string): Locator { + return page.locator(`a[href="/tools/${id}"]`); +} + +export function resultImage(page: Page): Locator { + return page.getByTestId(TEST_IDS.result); +} + +export function sourceImage(page: Page): Locator { + return page.getByTestId(TEST_IDS.source); +} + +export function verdictStatus(page: Page): Locator { + return page.getByTestId(TEST_IDS.verdict); +} + +export function textResult(page: Page): Locator { + return page.getByLabel("Text result"); +} + +export function errorAlert(page: Page): Locator { + return page.getByRole("alert"); +} + +export function emptyState(page: Page): Locator { + return page.getByTestId(TEST_IDS.empty); +} + +export function rangeInput(page: Page, name: RegExp | string): Locator { + return page.getByRole("slider", { name }); } export async function uploadImage(page: Page, file: SourceFile): Promise { - await page.locator('.actions input[type="file"]').setInputFiles({ + const input = page.locator('input[type="file"]'); + await expect(input).toHaveCount(1); + await input.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 { +export async function downloadResult(page: Page): Promise { + const button = page.getByRole("button", { name: "Download result" }); + await expect(button).toBeVisible(); const [download] = await Promise.all([ page.waitForEvent("download"), - page.locator(selector).click(), + button.click(), ]); return download.suggestedFilename(); } export async function expectNoErrorAlert(page: Page): Promise { - await expect(page.locator('[role="alert"]')).toHaveCount(0); + await expect(errorAlert(page)).toHaveCount(0); } export async function expectNoErrors(sink: ErrorSink): Promise { diff --git a/web/e2e/i18n.spec.ts b/web/e2e/i18n.spec.ts index 3fed687..e6e46a8 100644 --- a/web/e2e/i18n.spec.ts +++ b/web/e2e/i18n.spec.ts @@ -1,13 +1,14 @@ import { expect, test } from "playwright/test"; import { opaquePng } from "./helpers/fixtures"; -import { openTool, uploadImage } from "./helpers/page"; +import { openTool, toolLink, uploadImage, useEnglish } from "./helpers/page"; test("поиск каталога находит по названию из другой локали", async ({ page, }) => { + await useEnglish(page); await page.goto("/list-tools"); - const input = page.locator(".catalog-search input"); - const flipCard = page.locator('a.tool-card[href*="flip-png"]'); + const input = page.getByRole("textbox"); + const flipCard = toolLink(page, "flip-png"); await input.fill("отразить"); await expect(flipCard).toContainText("Flip PNG"); await page.getByRole("button", { name: "RU", exact: true }).click(); @@ -22,27 +23,19 @@ test("RU/EN: переключение переводит заголовок ин await openTool(page, "resize-png"); const ruBtn = page.getByRole("button", { name: "RU", exact: true }); const enBtn = page.getByRole("button", { name: "EN", exact: true }); - await expect(async () => { - await ruBtn.click(); - await expect(page.locator(".schema-tool h1")).toHaveText( - "Изменить размер PNG", - ); - }).toPass(); + const heading = page.getByRole("heading", { level: 1 }); + await ruBtn.click(); + await expect(heading).toHaveText("Изменить размер PNG"); await expect(ruBtn).toHaveAttribute("aria-pressed", "true"); await enBtn.click(); - await expect(page.locator(".schema-tool h1")).toHaveText("Resize PNG"); + await expect(heading).toHaveText("Resize PNG"); }); -// Валится на известном баге resize-png (дефолтные 0-параметры дают ошибку при -// аплоаде, см. known-issues.spec.ts). Здесь он используется как детерминированная -// ошибка: важно, что её текст локализован, а не сырой ключ. -// TODO: найти другую ошибку, эта будет исправлена при фиксе resize-png test("ошибка run'а локализуется, не сырой i18n-ключ", async ({ page }) => { await openTool(page, "resize-png"); await uploadImage(page, opaquePng); - const alert = page.locator("[role='alert']"); + const alert = page.getByRole("alert"); await expect(alert).toBeVisible(); - const text = (await alert.textContent()) ?? ""; - expect(text).not.toMatch(/^errors\./); - expect(text.length).toBeGreaterThan(3); + await expect(alert).toHaveText(/\S/); + await expect(alert).not.toHaveText(/^errors\./); }); diff --git a/web/e2e/known-issues.spec.ts b/web/e2e/known-issues.spec.ts index a61a000..45d78ea 100644 --- a/web/e2e/known-issues.spec.ts +++ b/web/e2e/known-issues.spec.ts @@ -1,12 +1,12 @@ import { expect, test } from "playwright/test"; import { opaquePng } from "./helpers/fixtures"; -import { openTool, uploadImage } from "./helpers/page"; +import { openTool, resultImage, uploadImage } from "./helpers/page"; -// FIXME: В файле зарегистрированы баги preview (см. docs/checklist-manual-testing.md -// → «Известные баги preview»). Тела assert'ят ОЖИДАЕМОЕ поведение. Статус fixme -// означает «мы знаем, что сейчас падает»; когда баг починят — убрать fixme и -// тест станет зелёным «сам по себе». (Тест про локализацию ошибок переехал в -// i18n.spec.ts — он больше не issue.) +// FIXME: В файле зарегистрированы баги production UI (см. +// `docs/backlog.md` и `docs/checklist-manual-testing.md`, раздел G). Тела +// assert'ят ОЖИДАЕМОЕ поведение. Статус fixme означает «мы знаем, что сейчас +// падает»; когда баг починят — убрать fixme и тест станет зелёным «сам по +// себе». test.describe("known bugs — documented as fixme", () => { test.fixme("resize-png: upload produces a resized result (no alert)", async ({ @@ -14,8 +14,8 @@ test.describe("known bugs — documented as fixme", () => { }) => { await openTool(page, "resize-png"); await uploadImage(page, opaquePng); - await expect(page.locator("[role='alert']")).toHaveCount(0); - await expect(page.locator('img[alt="Result image"]')).toBeVisible(); + await expect(page.getByRole("alert")).toHaveCount(0); + await expect(resultImage(page)).toBeVisible(); }); test.fixme("crop-png: upload produces a cropped result (no alert)", async ({ @@ -23,7 +23,7 @@ test.describe("known bugs — documented as fixme", () => { }) => { await openTool(page, "crop-png"); await uploadImage(page, opaquePng); - await expect(page.locator("[role='alert']")).toHaveCount(0); - await expect(page.locator('img[alt="Result image"]')).toBeVisible(); + await expect(page.getByRole("alert")).toHaveCount(0); + await expect(resultImage(page)).toBeVisible(); }); }); diff --git a/web/e2e/navigation.spec.ts b/web/e2e/navigation.spec.ts index 5038ec5..0892cac 100644 --- a/web/e2e/navigation.spec.ts +++ b/web/e2e/navigation.spec.ts @@ -1,14 +1,18 @@ import { expect, test } from "playwright/test"; -import { expectNoErrors, trackErrors } from "./helpers/page"; +import { expectNoErrors, trackErrors, useEnglish } from "./helpers/page"; const ROUTES = ["/", "/list-tools", "/kit", "/tools/flip-png"]; +test.beforeEach(async ({ page }) => { + await useEnglish(page); +}); + 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"); + await expect(page.getByRole("main")).toBeVisible(); expectNoErrors(sink); }); } @@ -18,40 +22,33 @@ test("unknown tool route responds 404 on static build", async ({ page }) => { expect(resp?.status()).toBe(404); }); -// FIXME: -// Error: expect(received).toBe(expected) // Object.is equality - -// Expected: "dark" -// Received: null - -// 36 | expect(["light", "dark"]).toContain(before); -// 37 | expect(["light", "dark"]).toContain(after); -// > 38 | expect(stored).toBe(after); -test.fixme("theme toggle flips preview theme and persists to localStorage", async ({ - page, -}) => { +test("theme toggle persists the selected theme", async ({ page }) => { + const sink = trackErrors(page); await page.goto("/"); - 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"), - ); + const main = page.getByRole("main"); + const before = await main.getAttribute("data-theme"); expect(["light", "dark"]).toContain(before); + + await page.getByRole("button", { name: "Toggle theme" }).click(); + await expect(main).not.toHaveAttribute("data-theme", before ?? ""); + const after = await main.getAttribute("data-theme"); + const stored = await page.evaluate(() => localStorage.getItem("theme")); expect(["light", "dark"]).toContain(after); expect(stored).toBe(after); + + await page.reload(); + await expect(main).toHaveAttribute("data-theme", after ?? ""); + expectNoErrors(sink); }); test("language toggle marks the active button", async ({ page }) => { await page.goto("/"); 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(); + const en = group.getByRole("button", { name: "EN" }); + await en.click(); + await expect(en).toHaveAttribute("aria-pressed", "true"); + await expect(group.getByRole("button", { name: "RU" })).toHaveAttribute( + "aria-pressed", + "false", + ); }); diff --git a/web/e2e/pipeline.spec.ts b/web/e2e/pipeline.spec.ts index 9757564..b91b7a3 100644 --- a/web/e2e/pipeline.spec.ts +++ b/web/e2e/pipeline.spec.ts @@ -7,33 +7,26 @@ import { opaquePng, } from "./helpers/fixtures"; import { + downloadResult, + emptyState, + errorAlert, expectNoErrorAlert, expectNoErrors, - metaValue, openTool, - suggestedDownloadName, + rangeInput, + resultImage, + sourceImage, trackErrors, uploadImage, + useEnglish, } from "./helpers/page"; -test("flip-png: full flow - upload, result, meta, download", async ({ - page, -}) => { +test("flip-png: full flow - upload, result, download", async ({ page }) => { const sink = trackErrors(page); await openTool(page, "flip-png"); await uploadImage(page, landscapePng); - await expect(page.locator('img[alt="Result image"]')).toBeVisible(); - await expect(page.locator('img[alt="Result image"]')).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 expect(resultImage(page)).toBeVisible(); + expect(await downloadResult(page)).toBe("flip-png.png"); await expectNoErrorAlert(page); expectNoErrors(sink); }); @@ -42,12 +35,8 @@ 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 image"]')).toBeVisible(); - const downloadName = await suggestedDownloadName( - page, - 'button[aria-label="Download result"]', - ); - expect(downloadName).toBe("convert-png-to-jpg.jpg"); + await expect(resultImage(page)).toBeVisible(); + expect(await downloadResult(page)).toBe("convert-png-to-jpg.jpg"); await expectNoErrorAlert(page); expectNoErrors(sink); }); @@ -55,53 +44,50 @@ test("convert-png-to-jpg: produces a downloadable jpg", async ({ page }) => { test.fixme("reset clears result but keeps source", async ({ page }) => { await openTool(page, "flip-png"); await uploadImage(page, opaquePng); - await expect(page.locator('img[alt="Result image"]')).toBeVisible(); + await expect(resultImage(page)).toBeVisible(); await page.getByRole("button", { name: "Reset" }).click(); - await expect(page.locator('img[alt="Result image"]')).toHaveCount(0); - await expect(page.locator(".empty")).toContainText("no result yet"); - await expect(page.locator('img[alt="source"]')).toBeVisible(); + await expect(resultImage(page)).toHaveCount(0); + await expect(emptyState(page)).toBeVisible(); + await expect(sourceImage(page)).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 image"]')).toBeVisible(); - const src1 = await page - .locator('img[alt="Result image"]') - .getAttribute("src"); - await expect(async () => { - await page.locator('input[type="range"]').fill("20"); - await expect(page.locator('img[alt="Result image"]')).not.toHaveAttribute( - "src", - src1 ?? "", - ); - }).toPass(); + await expect(resultImage(page)).toBeVisible(); + const firstSource = await resultImage(page).getAttribute("src"); + await rangeInput(page, /radius/i).fill("20"); + await expect(resultImage(page)).not.toHaveAttribute("src", firstSource ?? ""); }); test("runs without Web Worker (fallback)", async ({ browser }) => { const context = await browser.newContext(); - await context.addInitScript(() => { - Object.defineProperty(window, "Worker", { - value: undefined, - configurable: true, + try { + 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 image"]')).toBeVisible(); - await expectNoErrorAlert(page); - expectNoErrors(sink); - await context.close(); + const page = await context.newPage(); + await useEnglish(page); + const sink = trackErrors(page); + await openTool(page, "flip-png"); + await uploadImage(page, landscapePng); + await expect(resultImage(page)).toBeVisible(); + await expectNoErrorAlert(page); + expectNoErrors(sink); + } finally { + 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 image"]')).toHaveCount(0); + await expect(errorAlert(page)).toBeVisible(); + await expect(resultImage(page)).toHaveCount(0); expectNoErrors(sink); }); @@ -110,7 +96,7 @@ for (const fixture of [onePixelPng, largePng]) { const sink = trackErrors(page); await openTool(page, "flip-png"); await uploadImage(page, fixture); - await expect(page.locator('img[alt="Result image"]')).toBeVisible(); + await expect(resultImage(page)).toBeVisible(); expectNoErrors(sink); }); } diff --git a/web/e2e/text-and-verdicts.spec.ts b/web/e2e/text-and-verdicts.spec.ts index 7ad669e..7c823ce 100644 --- a/web/e2e/text-and-verdicts.spec.ts +++ b/web/e2e/text-and-verdicts.spec.ts @@ -10,10 +10,18 @@ import { expectNoErrorAlert, expectNoErrors, openTool, + resultImage, trackErrors, uploadImage, + verdictStatus, } from "./helpers/page"; +async function expectVerdict(page: Parameters[0]) { + const verdict = verdictStatus(page); + await expect(verdict).toBeVisible(); + await expect(verdict).toHaveText(/\S/); +} + for (const [id, input] of [ ["base64-to-png", tinyBase64], ["hex-to-png", "ff0000ff"], @@ -26,11 +34,11 @@ for (const [id, input] of [ ] as const) { test(`text input → ${id} produces result image`, async ({ page }) => { const sink = trackErrors(page); + await openTool(page, id); await expect(async () => { - await openTool(page, id); - await page.locator(".text-source textarea").fill(input); + await page.getByRole("textbox", { name: "Text data" }).fill(input); await page.getByRole("button", { name: "Render text" }).click(); - await expect(page.locator('img[alt="Result image"]')).toBeVisible(); + await expect(resultImage(page)).toBeVisible(); }).toPass({ timeout: 25_000 }); await expectNoErrorAlert(page); expectNoErrors(sink); @@ -41,62 +49,61 @@ 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); + const output = page.getByLabel("Text result"); + await expect(output).toBeVisible(); + await expect(output).toHaveText(/\S/); await expectNoErrorAlert(page); expectNoErrors(sink); }); -for (const [input, expected] of [ - [tinyBase64, "Yes — this is a valid PNG signature."], - ["aGVsbG8=", "No — the signature does not match a PNG file."], -] 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("verify-is-png renders different verdicts for valid and invalid input", async ({ + page, +}) => { + await openTool(page, "verify-is-png"); + const input = page.getByRole("textbox", { name: "Text data" }); + const render = page.getByRole("button", { name: "Render text" }); + const verdict = verdictStatus(page); -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 — all pixels are fully opaque.", - ); + await input.fill(tinyBase64); + await render.click(); + await expect(verdict).toBeVisible(); + const validText = await verdict.textContent(); + + await input.fill("aGVsbG8="); + await render.click(); + await expect(verdict).toBeVisible(); + const invalidText = await verdict.textContent(); + expect(invalidText).not.toBe(validText); }); -test("png-is-transparent: transparent image → 'Yes'", async ({ page }) => { +test("png-is-transparent: opaque image renders a verdict", async ({ page }) => { + await openTool(page, "png-is-transparent"); + await uploadImage(page, opaquePng); + await expectVerdict(page); +}); + +test("png-is-transparent: transparent image renders a verdict", async ({ + page, +}) => { await openTool(page, "png-is-transparent"); await uploadImage(page, transparentPng); - await expect(page.locator(".verdict-text")).toContainText( - "Yes — there are transparent or semi-transparent pixels.", - ); + await expectVerdict(page); }); -test("png-is-grayscale: colored image → 'No'", async ({ page }) => { +test("png-is-grayscale: colored image renders a verdict", async ({ page }) => { await openTool(page, "png-is-grayscale"); await uploadImage(page, opaquePng); - await expect(page.locator(".verdict-text")).toContainText( - "No — colored pixels were found.", - ); + await expectVerdict(page); }); -test("png-orientation: landscape image → Landscape", async ({ page }) => { +test("png-orientation: landscape image renders a verdict", async ({ page }) => { await openTool(page, "png-orientation"); - await uploadImage(page, opaquePng); // 64×48 → landscape - await expect(page.locator(".verdict-text")).toContainText("Landscape"); + await uploadImage(page, opaquePng); + await expectVerdict(page); }); -test("png-file-size: returns a size in KB", async ({ page }) => { +test("png-file-size: renders a verdict", 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"); + await expectVerdict(page); }); diff --git a/web/e2e/tools-smoke.spec.ts b/web/e2e/tools-smoke.spec.ts index 72caee9..c63720e 100644 --- a/web/e2e/tools-smoke.spec.ts +++ b/web/e2e/tools-smoke.spec.ts @@ -5,8 +5,11 @@ import { expectNoErrorAlert, expectNoErrors, openTool, + resultImage, trackErrors, + textResult, uploadImage, + verdictStatus, } from "./helpers/page"; type Kind = "image" | "text-out" | "verdict"; @@ -98,14 +101,14 @@ test.describe("smoke: tools produce output without errors", () => { test(`tool ${id} (${kind})`, async ({ page }) => { const sink = trackErrors(page); - const outputLocator = (): ReturnType => { + const outputLocator = () => { switch (kind) { case "image": - return page.locator('img[alt="Result image"]'); + return resultImage(page); case "text-out": - return page.locator(".result-pre code"); + return textResult(page); case "verdict": - return page.locator(".verdict-text"); + return verdictStatus(page); } }; diff --git a/web/src/lib/components/CatalogGroup.svelte b/web/src/lib/components/CatalogGroup.svelte index 39a8ac0..c5c9a5e 100644 --- a/web/src/lib/components/CatalogGroup.svelte +++ b/web/src/lib/components/CatalogGroup.svelte @@ -12,7 +12,7 @@
- {label} +

{label}

{count.toString().padStart(2, "0")} {t("catalog.toolsCount")}
@@ -35,7 +35,8 @@ padding-bottom: var(--space-m); border-bottom: var(--size-border) solid var(--color-border); } - .group-title span { + .group-title h2 { + margin: 0; font: var(--font-size-s) var(--font-mono); letter-spacing: var(--space-text-xl); text-transform: uppercase; diff --git a/web/src/lib/components/CatalogHeader.svelte b/web/src/lib/components/CatalogHeader.svelte index 55551a8..e48a8e0 100644 --- a/web/src/lib/components/CatalogHeader.svelte +++ b/web/src/lib/components/CatalogHeader.svelte @@ -12,10 +12,13 @@

{t("catalog.heading")}

{t("catalog.lead")}

-
+ {total} {t("catalog.toolsAvailable")} -
+