diff --git a/docs/README.md b/docs/README.md index c8097bc..2349810 100644 --- a/docs/README.md +++ b/docs/README.md @@ -51,17 +51,17 @@ только если темы нет в существующем документе; иначе обновляется источник истины. -## Текущий baseline Q6c +## Текущий baseline Q6d -На момент фиксации Q6c: +На момент фиксации Q6d: - production registry содержит 122 инструмента; - `pnpm --dir web check` проходит без ошибок и предупреждений; -- `pnpm --dir web test` — 42 test files / 455 tests; +- `pnpm --dir web test` — 42 test files / 457 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 — 112 passed, 0 skipped. +- `pnpm verify` проходит; production E2E — 121 passed, 0 skipped. Baseline и следующий шаг поддерживаются в `docs/plan-codebase-quality.md`. diff --git a/docs/backlog.md b/docs/backlog.md index ff0d3ba..488c706 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -107,7 +107,8 @@ ## Качество production UI -Q5 закрывает известные preview-проблемы; активных `test.fixme` в e2e больше нет. +Q5–Q6d закрывают известные preview-проблемы и baseline special fixtures; +активных `test.fixme` в e2e больше нет. - [x] Генераторы auto-run без кнопки Generate — `web/e2e/generators.spec.ts`. - [x] `resize-png` и `crop-png` используют размеры текущего source через общий @@ -116,6 +117,11 @@ Q5 закрывает известные preview-проблемы; активн auto-run — `web/e2e/pipeline.spec.ts`. - [x] Ошибки хранят i18n key/vars и переводятся при выводе; изменение языка обновляет alert — `web/e2e/i18n.spec.ts`. +- [x] Malformed/special PNG fixtures: CRC, truncated, palette и 16-bit проходят + через upload без краша — `web/e2e/png-fixtures.spec.ts`. +- [ ] Ошибка декодирования изображения из `createImageBitmap` пока показывается + сырым сообщением браузера; нужен `ToolError`-маппер и i18n-ключ. Это + продуктовая задача, не Q6d. ## Идеи diff --git a/docs/checklist-manual-testing.md b/docs/checklist-manual-testing.md index edfcee4..16cf3d3 100644 --- a/docs/checklist-manual-testing.md +++ b/docs/checklist-manual-testing.md @@ -19,12 +19,13 @@ - [ ] PNG 1×1 — инструменты не падают (проверено авто: flip-png, см. suite). - [ ] PNG с полупрозрачностью/чётким альфа-краёв (чёрно-белая шахматка) — remove-background, feather-edges, clean-edges дают корректные края. -- [ ] Палитровый PNG (индексированные цвета) — конвертируется/анализируется. +- [ ] Палитровый PNG (индексированные цвета) — конвертируется/анализируется + (авто-покрытие: `web/e2e/png-fixtures.spec.ts`). - [ ] Анимированный/APNG, если попадётся — не ломает конвейер. - [ ] Повреждённый/не-PNG файл — понятная ошибка, нет краша (авто-чека в - pipeline.spec.ts). + `pipeline.spec.ts` и `png-fixtures.spec.ts`). - [ ] PNG с битой CRC / 16-bit — как ведут себя анализаторы (ориентация, размер, - прозрачность). + прозрачность); CRC/16-bit авто-покрыты в `png-fixtures.spec.ts`. ### Файлы (`tests/fixtures/manual-edge-cases/`) @@ -95,9 +96,9 @@ корректный (не «кракозябры»). - [ ] remove-background на сложной полупрозрачности — края не «звенят». -## G. Закрытые Q5/Q6b/Q6c-проблемы +## G. Закрытые Q5/Q6b/Q6c/Q6d-проблемы -> Q5/Q6b/Q6c-проблемы закрыты автоматическими тестами; для ручной проверки +> Q5/Q6b/Q6c/Q6d-проблемы закрыты автоматическими тестами; для ручной проверки > остаются субъективные сценарии разделов A–F. - [x] **Генераторы:** auto-run по дефолтам, результат и изменение параметра без @@ -114,6 +115,8 @@ `web/e2e/pipeline.spec.ts`. - [x] **Output MIME/quality:** JPG/WebP/BMP имеют ожидаемые расширения и сигнатуры, quality 10/90 меняет размер файла; `web/e2e/pipeline.spec.ts`. +- [x] **Malformed/special PNG:** CRC, truncated, palette и 16-bit fixtures + проходят через upload без краша; `web/e2e/png-fixtures.spec.ts`. ## Как долго diff --git a/docs/plan-codebase-quality.md b/docs/plan-codebase-quality.md index 52e977c..28aa59c 100644 --- a/docs/plan-codebase-quality.md +++ b/docs/plan-codebase-quality.md @@ -1,9 +1,9 @@ # План: линтеры, тесты и качество кодовой базы -> Статус: **draft — Q0–Q5 и Q6a–Q6c готовы к ревью; Q6d — следующий этап**. +> Статус: **draft — Q0–Q5 и Q6a–Q6d готовы к ревью; Q6e — следующий этап**. > > Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и -> инфраструктурных скриптов. Q0–Q5 и Q6a–Q6c выполнены; Q6d–Q8 выполняются +> инфраструктурных скриптов. Q0–Q5 и Q6a–Q6d выполнены; Q6e–Q8 выполняются > отдельными атомарными шагами. > > Цель: сделать качество кодовой базы воспроизводимым, убрать ложную «зелёность» @@ -243,9 +243,24 @@ JPG/WebP/BMP, не превращаясь в UX-райз кнопки Download. - E2E проверяет JPEG `FF D8 FF`, WebP `RIFF/WEBP`, BMP `BM` и PNG-фолбэк. - Ошибки кодирования и unsupported MIME используют существующие i18n-ключи. -#### Q6d–Q6f. Оставшиеся направления +#### Q6d. Malformed и special PNG fixtures + +**Статус:** выполнен. Runtime-fixtures покрывают CRC, truncated, palette и +16-bit без бинарных файлов в git. E2E фиксирует браузерный контракт: critical +CRC/truncated отклоняются без падения, ancillary CRC и хвост после `IEND` +допускаются, palette/16-bit декодируются. Unit фиксирует контракт анализаторов +для RGBA-данных, которые браузер отдаёт после нормализации. + +- `web/e2e/helpers/fixtures.ts` собирает indexed, 16-bit, CRC-mutation, + truncation и tail-варианты из уже существующих `crc32`/`chunk`. +- Проверки выполняются в Chromium; browser matrix остаётся Q6f. +- Поведение `createImageBitmap` является частью browser-контракта, а не + самостоятельного PNG-парсера product-кода. +- Сырое сообщение браузера при decode-ошибке не локализуется в Q6d; это + отдельная задача в backlog. + +#### Q6e–Q6f. Оставшиеся направления -- malformed и special PNG fixtures: CRC, truncated, palette, 16-bit; - smoke всех типов инструментов из registry; - component-тесты `SchemaToolView`, `SchemaPreview` и schema fields; - browser matrix, mobile viewport, visual snapshots и coverage thresholds. @@ -408,7 +423,6 @@ refactor, новые i18n-правила и исправление продук ## 7. Решения, которые нужно принять на ревью - Какие executor/worker/fallback сценарии войдут в первый Q6 coverage PR? -- Какие special PNG fixtures обязательны для baseline? - Нужны ли browser/mobile matrix и visual snapshots до coverage thresholds? - Какие unused design tokens можно удалить, а какие должны остаться? - Какие browser/e2e проверки должны быть обязательными для PR, а какие — diff --git a/docs/quality-gates.md b/docs/quality-gates.md index a979663..13a7f03 100644 --- a/docs/quality-gates.md +++ b/docs/quality-gates.md @@ -35,7 +35,9 @@ pnpm --dir web test:e2e ``` Запускать его обязательно для изменений маршрутов, загрузки файла, результата, -download, темы или локализации. В текущем CI это отдельный non-blocking +download, темы или локализации. Изменения runtime fixtures/загрузки файла +проверяются отдельным `web/e2e/png-fixtures.spec.ts`; ручные бинарники из +`tests/fixtures/` не входят в `verify`. В текущем CI это отдельный non-blocking baseline; после закрытия известных ошибок его можно сделать blocking. ## Быстрые подмножества @@ -46,6 +48,7 @@ baseline; после закрытия известных ошибок его м | TypeScript/Svelte | `pnpm --dir web check` и `pnpm --dir web test` | | Lint-правило или lint-конфиг | `pnpm --dir web test:rules` и `pnpm --dir web lint:all` | | Пользовательский поток | `pnpm --dir web test:e2e` | +| Fixtures/загрузка файла | `pnpm --dir web test:e2e` | | Полный локальный gate | `pnpm verify` | ## Errors и warnings diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index 4ec58e8..3058ceb 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -53,9 +53,11 @@ CSS-классы и конкретный формат временного URL Изображения создаются воспроизводимыми helpers в `web/e2e/helpers/fixtures.ts`. Тесты не добавляют большие бинарные файлы и не зависят от локальных -пользовательских файлов. `toPass()` допускается только для ограниченного -ожидания hydration или асинхронного результата; он не должен превращать проверку -продукта в бесконечный retry. +пользовательских файлов. Runtime-fixtures для malformed/special PNG проверяют +browser-контракт: critical CRC и truncated отклоняются, ancillary CRC и хвост +после `IEND` допускаются, palette/16-bit декодируются. `toPass()` допускается +только для ограниченного ожидания hydration или асинхронного результата; он не +должен превращать проверку продукта в бесконечный retry. ## 5. Helpers и `test.fixme` @@ -68,7 +70,7 @@ ZIP-сценарием: общий image-smoke не применяется к н `test.fixme` допускается только для зарегистрированной проблемы в backlog или checklist. В комментарии указывается ссылка на неё. После исправления тест и -ссылка обновляются вместе; в текущем Q6a baseline активных fixme нет. +ссылка обновляются вместе; в текущем Q6d baseline активных fixme нет. ## 6. Проверки diff --git a/web/e2e/helpers/fixtures.ts b/web/e2e/helpers/fixtures.ts index 3e55e0d..fbb9faf 100644 --- a/web/e2e/helpers/fixtures.ts +++ b/web/e2e/helpers/fixtures.ts @@ -23,6 +23,22 @@ function crc32(data: Uint8Array): number { return (c ^ 0xffffffff) >>> 0; } +const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + +function makeIhdr( + width: number, + height: number, + bitDepth: number, + colorType: number, +): Buffer { + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = bitDepth; + ihdr[9] = colorType; + return ihdr; +} + function chunk(type: string, data: Buffer): Buffer { const out = Buffer.alloc(8 + data.length + 4); out.writeUInt32BE(data.length, 0); @@ -32,18 +48,23 @@ function chunk(type: string, data: Buffer): Buffer { return out; } +function findChunk(png: Buffer, type: string): number { + let offset = PNG_SIGNATURE.length; + while (offset + 12 <= png.length) { + const length = png.readUInt32BE(offset); + const chunkType = png.subarray(offset + 4, offset + 8).toString("ascii"); + if (chunkType === type) return offset; + offset += 12 + length; + } + throw new Error(`PNG chunk ${type} not found`); +} + /** Строит валидный 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++) { @@ -57,13 +78,100 @@ export function makePng( } } return Buffer.concat([ - sig, - chunk("IHDR", ihdr), + PNG_SIGNATURE, + chunk("IHDR", makeIhdr(width, height, 8, 6)), chunk("IDAT", deflateSync(raw)), chunk("IEND", Buffer.alloc(0)), ]); } +export function makeIndexedPng( + width: number, + height: number, + palette: [number, number, number][], + pixelAt: (x: number, y: number) => number, +): Buffer { + const plte = Buffer.alloc(palette.length * 3); + for (const [index, color] of palette.entries()) { + plte[index * 3] = color[0]; + plte[index * 3 + 1] = color[1]; + plte[index * 3 + 2] = color[2]; + } + const stride = width + 1; + const raw = Buffer.alloc(height * stride); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + raw[y * stride + 1 + x] = pixelAt(x, y); + } + } + return Buffer.concat([ + PNG_SIGNATURE, + chunk("IHDR", makeIhdr(width, height, 8, 3)), + chunk("PLTE", plte), + chunk("IDAT", deflateSync(raw)), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +export function make16BitPng( + width: number, + height: number, + pixelAt: (x: number, y: number) => [number, number, number, number], +): Buffer { + const stride = width * 8 + 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 * 8; + raw.writeUInt16BE(r, p); + raw.writeUInt16BE(g, p + 2); + raw.writeUInt16BE(b, p + 4); + raw.writeUInt16BE(a, p + 6); + } + } + return Buffer.concat([ + PNG_SIGNATURE, + chunk("IHDR", makeIhdr(width, height, 16, 6)), + chunk("IDAT", deflateSync(raw)), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +export function withPngChunk(png: Buffer, type: string, data: Buffer): Buffer { + const iendOffset = png.length - 12; + return Buffer.concat([ + png.subarray(0, iendOffset), + chunk(type, data), + png.subarray(iendOffset), + ]); +} + +export function withBadChunkCrc(png: Buffer, type: string): Buffer { + const copy = Buffer.from(png); + const offset = findChunk(copy, type); + const length = copy.readUInt32BE(offset); + copy[offset + 8 + length + 3] ^= 0xff; + return copy; +} + +export function truncatePng(png: Buffer, length: number): Buffer { + return Buffer.from(png.subarray(0, length)); +} + +export function truncateChunkData( + png: Buffer, + type: string, + length: number, +): Buffer { + const offset = findChunk(png, type); + return truncatePng(png, offset + 8 + length); +} + +export function withTrailingBytes(png: Buffer, tail: Buffer): Buffer { + return Buffer.concat([png, Buffer.from(tail)]); +} + const checker = (x: number, y: number): [number, number, number, number] => x % 2 === y % 2 ? [255, 0, 0, 255] : [255, 255, 255, 255]; @@ -102,6 +210,66 @@ export const corruptPng: SourceFile = { buffer: Buffer.from("this is definitely not a png file", "utf8"), }; +const specialPng = makePng(8, 8, checker); +const specialWithText = withPngChunk( + specialPng, + "tEXt", + Buffer.from("Comment\0q6d", "ascii"), +); +const palette4 = [ + [255, 0, 0], + [0, 255, 0], + [0, 0, 255], + [255, 255, 255], +] as [number, number, number][]; +const palette256 = Array.from({ length: 256 }, (_, index) => [ + index, + (index * 3) % 256, + (index * 7) % 256, +]) as [number, number, number][]; + +export const crcBadIdatPng = asSourceFile( + withBadChunkCrc(specialPng, "IDAT"), + "crc-bad-idat.png", +); +export const crcBadAncillaryPng = asSourceFile( + withBadChunkCrc(specialWithText, "tEXt"), + "crc-bad-ancillary.png", +); +export const truncatedHeaderPng = asSourceFile( + truncatePng(specialPng, PNG_SIGNATURE.length + 12 + 13), + "truncated-header.png", +); +export const truncatedNoIendPng = asSourceFile( + truncatePng(specialPng, specialPng.length - 12), + "truncated-no-iend.png", +); +export const idatCutPng = asSourceFile( + truncateChunkData(specialPng, "IDAT", 4), + "idat-cut.png", +); +export const garbageTailPng = asSourceFile( + withTrailingBytes(specialPng, Buffer.from("trailing-garbage", "ascii")), + "garbage-tail.png", +); +export const palette4Png = asSourceFile( + makeIndexedPng( + 8, + 8, + palette4, + (x, y) => (Math.floor(x / 2) + Math.floor(y / 2)) % palette4.length, + ), + "palette-4.png", +); +export const palette256Png = asSourceFile( + makeIndexedPng(16, 16, palette256, (x, y) => (x + y * 16) % 256), + "palette-256.png", +); +export const sixteenBitPng = asSourceFile( + make16BitPng(8, 8, (x, y) => [x * 8000, y * 8000, 32768, 65535]), + "16bit.png", +); + export const tinyBase64 = makePng(8, 8, solidRed).toString("base64"); export const svgMarkup = diff --git a/web/e2e/png-fixtures.spec.ts b/web/e2e/png-fixtures.spec.ts new file mode 100644 index 0000000..6536b61 --- /dev/null +++ b/web/e2e/png-fixtures.spec.ts @@ -0,0 +1,69 @@ +import { expect, test, type Page } from "playwright/test"; +import { + crcBadAncillaryPng, + crcBadIdatPng, + garbageTailPng, + idatCutPng, + palette4Png, + palette256Png, + sixteenBitPng, + truncatedHeaderPng, + truncatedNoIendPng, + type SourceFile, +} from "./helpers/fixtures"; +import { + errorAlert, + expectNoErrorAlert, + expectNoErrors, + openTool, + resultImage, + trackErrors, + uploadImage, +} from "./helpers/page"; + +async function expectRejected(page: Page, file: SourceFile): Promise { + const sink = trackErrors(page); + await openTool(page, "flip-png"); + await uploadImage(page, file); + await expect(errorAlert(page)).toBeVisible(); + await expect(resultImage(page)).toHaveCount(0); + expectNoErrors(sink); +} + +async function expectAccepted(page: Page, file: SourceFile): Promise { + const sink = trackErrors(page); + await openTool(page, "flip-png"); + await uploadImage(page, file); + await expect(resultImage(page)).toBeVisible(); + await expectNoErrorAlert(page); + expectNoErrors(sink); +} + +const rejectedFixtures = [ + crcBadIdatPng, + truncatedHeaderPng, + truncatedNoIendPng, + idatCutPng, +]; + +const acceptedFixtures = [ + crcBadAncillaryPng, + garbageTailPng, + palette4Png, + palette256Png, + sixteenBitPng, +]; + +test.describe("PNG special fixtures", () => { + for (const file of rejectedFixtures) { + test(`${file.name}: shows an error without a result`, async ({ page }) => { + await expectRejected(page, file); + }); + } + + for (const file of acceptedFixtures) { + test(`${file.name}: decodes without an error`, async ({ page }) => { + await expectAccepted(page, file); + }); + } +}); diff --git a/web/eslint.config.js b/web/eslint.config.js index c59e96b..e3025d1 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -63,7 +63,7 @@ export default tseslint.config( "e2e/text-and-verdicts.spec.ts", "e2e/tools-smoke.spec.ts", "e2e/generators.spec.ts", - "e2e/known-issues.spec.ts", + "e2e/png-fixtures.spec.ts", "e2e/i18n.spec.ts", // Тесты и фикстуры кастомных линт-правил лежат вне src/ (не в // tsconfig), поэтому для типизированного парсинга резолвятся diff --git a/web/src/lib/core/analyze.test.ts b/web/src/lib/core/analyze.test.ts index 1bcb8ff..c248029 100644 --- a/web/src/lib/core/analyze.test.ts +++ b/web/src/lib/core/analyze.test.ts @@ -72,6 +72,40 @@ describe("hasTransparency", () => { }); }); +describe("special decoded pixel data", () => { + it("палитра после browser-декодирования сохраняет количество цветов", () => { + const image = makeImage(2, 2, [ + [255, 0, 0, 255], + [0, 255, 0, 255], + [0, 0, 255, 255], + [255, 255, 255, 255], + ]); + + expect(imageInfo(image)).toMatchObject({ + width: 2, + height: 2, + hasAlpha: false, + colorCount: 4, + }); + }); + + it("16-bit после downscale остаётся цветным и непрозрачным", () => { + const image = makeImage( + 4, + 4, + Array.from({ length: 16 }, (_, index) => { + const value = index * 8; + return [value, value, 128, 255]; + }), + ); + + expect(imageInfo(image).colorCount).toBe(16); + expect(hasTransparency(image)).toBe(false); + expect(isGrayscale(image)).toBe(false); + expect(orientationOf(image)).toBe("square"); + }); +}); + describe("orientationOf", () => { it("определяет ориентацию", () => { expect(