revise August 2023 e2e stability articles
Build and deploy / deploy (push) Successful in 16s

This commit is contained in:
2026-07-31 15:08:57 +03:00
parent 5cbd53d8ec
commit 3d310bd605
7 changed files with 966 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Производство редакционных партий # Производство редакционных партий
На 31 июля 2026 года строгий аудит проходит 199 из 358 созданных материалов. Остальные 159 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. На 31 июля 2026 года строгий аудит проходит 202 из 358 созданных материалов. Остальные 156 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия ## Одна партия
+123
View File
@@ -0,0 +1,123 @@
# P66 — август 2023: стабильные e2e-тесты
## Граница пакета
Подготовлены только пять sidecar-файлов для последующей интеграции главным
агентом:
- `web/scripts/upgrade-2023-08.mjs`;
- `editorial/reviews/2023-08-draft.md`;
- `web/public/assets/editorial/2023/e2e-stability-2023-flake-classification.svg`;
- `web/public/assets/editorial/2023/e2e-stability-2023-wait-contract.svg`;
- `web/public/assets/editorial/2023/e2e-stability-2023-triage-loop.svg`.
Три замены используют точные archive slug и не содержат `date` или `author`:
| Slug | Роль | Основной текст |
| --- | --- | ---: |
| `editorial-2023-08-practice-e2e-stability` | практический маршрут | 9 694 знака |
| `editorial-2023-08-mechanism-e2e-stability` | модель контрактов | 9 423 знака |
| `editorial-2023-08-field-e2e-stability` | triage и rollback | 9 443 знака |
Registry, README, `articles.json`, очередь, Git и файлы других агентов не
менялись. Пакет не коммитился и не пушился.
## Историческая опора
Все источники являются официальными и были доступны к концу августа 2023:
- [Playwright v1.37.0](https://github.com/microsoft/playwright/releases/tag/v1.37.0) — опубликованный GitHub release от 10 августа 2023.
- [Auto-waiting, тег v1.37.0](https://raw.githubusercontent.com/microsoft/playwright/v1.37.0/docs/src/actionability.md) — для `click` перечислены attached, visible, stable, receives events и enabled; это actionability target, не бизнес-успех.
- [Retries, тег v1.37.0](https://raw.githubusercontent.com/microsoft/playwright/v1.37.0/docs/src/test-retries-js.md) — при failure worker с браузером отбрасывается, retry идёт в новом worker; `flaky` описывает failed → passed, а не причину различия.
- [Trace Viewer, тег v1.37.0](https://raw.githubusercontent.com/microsoft/playwright/v1.37.0/docs/src/trace-viewer.md) — записанный trace включает actions, snapshots, action log, source и network log; `on-first-retry` относится к первому retry.
- [Best Practices, тег v1.37.0](https://raw.githubusercontent.com/microsoft/playwright/v1.37.0/docs/src/best-practices-js.md) — изоляция, user-facing locator и web-first assertions.
## Саморевью 1 — факты и модель
- Разведены selector, actionability, readiness condition, retry и evidence.
Уникальный locator и успешный `click` нигде не выдаются за подтверждение
продуктового результата.
- `failed → passed` назван статусом двух попыток; не утверждается, что это
реальный flake, причина сбоя, медленная сеть или совместимость браузера.
- Trace описан как evidence конкретной записанной попытки. В текстах нет
заявления, что был открыт настоящий trace, video, browser или CI-report.
- Fixture создаёт только marked synthetic objects в памяти. У него нет URL,
реального `trace.zip`, video, duration, browser name, файлового чтения,
запуска Playwright или обращения к сети. Он не оценивает настоящий флак,
время или compatibility.
- Отрицательные ветки отделяют множественный selector от отсутствующего
readiness, отвергают расходящуюся retry-policy и подмену synthetic evidence
реальным artefact. План запрещает увеличивать timeout или переписывать
selector без evidence и требует condition, совпадающий с evidence.
## Саморевью 2 — голос, полнота и объём
- Голос М6 соответствует 2023 году: спокойная системная практика без
неподтверждённого опыта управления всей организацией. Утверждения привязаны
к контракту, попытке, owner, rollback и следующей проверке.
- Первые два абзаца каждой статьи называют проблему и цену: retry маскирует
первый отказ, а общий timeout ухудшает обратную связь.
- В каждой статье есть доступная таблица, самостоятельный SVG с содержательным
`alt` и подписью, исполнимый fixture, ordered route
«симптом → причина → проверка → действие», ограничения и следующий шаг.
- Проверен объём основного текста без списка источников: 9 694, 9 423 и
9 443 знака — внутри требуемых 5 000–15 000 и целевого практического
диапазона около 8–10 тысяч.
- Убраны обобщения без объекта. Timeout назван верхней границей ожидания, а
не readiness condition; trace не назначен заменой причинного анализа.
## Саморевью 3 — визуал, безопасность и выпуск
- Все три SVG прошли XML-проверку. Safety scan не нашёл `script`,
`foreignObject`, `javascript:`, `data:image` и event-handler attributes.
- Каждый SVG отрендерен Sharp при ширине 375 px. На визуальном проходе
проверены контраст, отсутствие обрезания текста, стрелки переходов и
читаемость ключевых развилок: selector/readiness/retry/evidence.
- Автоматический аудит подтвердил slug, import-safe CLI/export, отсутствие
`date`/`author`, объём, таблицы, figure/caption, пример, ordered route,
источники и наличие visual assets.
- Production build и registry-аудит намеренно не выполнялись: это sidecar без
интеграции, и их выполняет главный агент после добавления revisions в общий
registry.
## Выполненные команды
| Команда | Результат |
| --- | --- |
| `node --check web/scripts/upgrade-2023-08.mjs` | PASS |
| `node web/scripts/upgrade-2023-08.mjs --verify-fixture` | `PASS fixture: 18/18 assertions` после независимого model review |
| `npm run audit:draft -- scripts/upgrade-2023-08.mjs` из `web/` | PASS для трёх slug |
| import-safe import без `date`/`author` | `PASS import-safe: 3 revisions without date/author` |
| `xmllint --noout` для трёх SVG | PASS |
| SVG safety scan | PASS, совпадений нет |
| Sharp render 375 px для трёх SVG | PASS, выполнен визуальный проход |
## Интеграционное ревью главного агента
Проведён отдельный проход после передачи sidecar. Проверены первоисточники
Playwright по тегу `v1.37.0`: actionability для `click` не равна продуктовому
readiness; retry запускается в новом worker; `flaky` означает только
`failed → passed`; `on-first-retry` относится к записи trace первого retry.
Эти границы оставлены в тексте и в fixture, а не заменены общими обещаниями
«стабильности».
Model review нашёл две скрытые возможности подмены вывода. Классификатор теперь
принимает только согласованную retry-policy: начальная и первая повторная
попытки обе должны быть частью одной настройки с `configuredRetries = 1`.
План изменения readiness теперь обязан использовать в точности condition из
evidence; нельзя собрать правильную классификацию и затем предложить другое
ожидание. Добавлены отрицательные fixture-сценарии для обоих случаев.
Повторные результаты: `node --check` — PASS, fixture — `18/18`, черновой
аудит — 9 694 / 9 655 / 9 562 знака, строгий archive audit — PASS для трёх
archive slug. Registry содержит 193 уникальные ревизии. XML и safety scan
визуалов чисты; три PNG-рендера при 375 px просмотрены вручную. `npm run build`
сгенерировал 374 статические страницы без ошибки.
## Ограничение при передаче
Этот пакет не является доказательством стабильности существующих e2e-тестов.
Главному агенту при интеграции нужно добавить только эти три revisions в общий
overlay, затем выполнить строгий archive audit и production build в контексте
репозитория. Реальные trace, browser run, CI-данные, flake rate и browser
compatibility остаются за пределами sidecar.
+2
View File
@@ -62,6 +62,7 @@ import { revisions as april2023Revisions } from '../scripts/upgrade-2023-04.mjs'
import { revisions as may2023Revisions } from '../scripts/upgrade-2023-05.mjs'; import { revisions as may2023Revisions } from '../scripts/upgrade-2023-05.mjs';
import { revisions as june2023Revisions } from '../scripts/upgrade-2023-06.mjs'; import { revisions as june2023Revisions } from '../scripts/upgrade-2023-06.mjs';
import { revisions as july2023Revisions } from '../scripts/upgrade-2023-07.mjs'; import { revisions as july2023Revisions } from '../scripts/upgrade-2023-07.mjs';
import { revisions as august2023Revisions } from '../scripts/upgrade-2023-08.mjs';
// This layer replaces archived source entries without losing their stable slug and date. // This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [ export const editorialRevisions = [
@@ -129,4 +130,5 @@ export const editorialRevisions = [
...may2023Revisions, ...may2023Revisions,
...june2023Revisions, ...june2023Revisions,
...july2023Revisions, ...july2023Revisions,
...august2023Revisions,
]; ];
@@ -0,0 +1,62 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800" viewBox="0 0 1200 800" role="img" aria-labelledby="title desc">
<title id="title">Классификация flaky e2e-теста без роста timeout</title>
<desc id="desc">Последовательность из четырёх проверок: уникальность selector, semantic readiness, различие initial attempt и retry, evidence конкретной попытки. У каждого шага есть отдельное действие; timeout не является первым решением.</desc>
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto"><path d="M0,0 L12,6 L0,12 Z" fill="#486581"/></marker>
</defs>
<rect width="1200" height="800" fill="#f7fafc"/>
<rect x="48" y="42" width="1104" height="84" rx="18" fill="#102a43"/>
<text x="84" y="93" fill="#ffffff" font-family="Arial, sans-serif" font-size="34" font-weight="700">Flaky: классифицировать слой, не повышать timeout</text>
<text x="84" y="116" fill="#d9e2ec" font-family="Arial, sans-serif" font-size="17">Каждый ответ сужает следующую проверку; ни один не объявляет корневую причину.</text>
<path d="M435 252 L435 278" stroke="#486581" stroke-width="5" marker-end="url(#arrow)"/>
<path d="M435 387 L435 413" stroke="#486581" stroke-width="5" marker-end="url(#arrow)"/>
<path d="M435 522 L435 548" stroke="#486581" stroke-width="5" marker-end="url(#arrow)"/>
<g>
<rect x="68" y="156" width="735" height="96" rx="16" fill="#e0f2fe" stroke="#0ea5e9" stroke-width="3"/>
<circle cx="116" cy="204" r="25" fill="#0369a1"/>
<text x="108" y="214" fill="#ffffff" font-family="Arial, sans-serif" font-size="26" font-weight="700">1</text>
<text x="160" y="194" fill="#102a43" font-family="Arial, sans-serif" font-size="25" font-weight="700">Selector</text>
<text x="160" y="223" fill="#334e68" font-family="Arial, sans-serif" font-size="20">Locator разрешается ровно в один пользовательский control?</text>
<rect x="852" y="170" width="280" height="67" rx="14" fill="#fff1f2" stroke="#e11d48" stroke-width="2"/>
<text x="874" y="198" fill="#9f1239" font-family="Arial, sans-serif" font-size="20" font-weight="700">Нет → сузить locator</text>
<text x="874" y="220" fill="#9f1239" font-family="Arial, sans-serif" font-size="16">Не трогать readiness.</text>
</g>
<g>
<rect x="68" y="291" width="735" height="96" rx="16" fill="#ecfdf5" stroke="#10b981" stroke-width="3"/>
<circle cx="116" cy="339" r="25" fill="#047857"/>
<text x="108" y="349" fill="#ffffff" font-family="Arial, sans-serif" font-size="26" font-weight="700">2</text>
<text x="160" y="329" fill="#102a43" font-family="Arial, sans-serif" font-size="25" font-weight="700">Readiness condition</text>
<text x="160" y="358" fill="#334e68" font-family="Arial, sans-serif" font-size="20">После click виден терминальный пользовательский факт?</text>
<rect x="852" y="305" width="280" height="67" rx="14" fill="#fff7ed" stroke="#f97316" stroke-width="2"/>
<text x="874" y="333" fill="#9a3412" font-family="Arial, sans-serif" font-size="20" font-weight="700">Нет → назвать state</text>
<text x="874" y="355" fill="#9a3412" font-family="Arial, sans-serif" font-size="16">Не ждать spinner или sleep.</text>
</g>
<g>
<rect x="68" y="426" width="735" height="96" rx="16" fill="#eef2ff" stroke="#6366f1" stroke-width="3"/>
<circle cx="116" cy="474" r="25" fill="#4338ca"/>
<text x="108" y="484" fill="#ffffff" font-family="Arial, sans-serif" font-size="26" font-weight="700">3</text>
<text x="160" y="464" fill="#102a43" font-family="Arial, sans-serif" font-size="25" font-weight="700">Retry</text>
<text x="160" y="493" fill="#334e68" font-family="Arial, sans-serif" font-size="20">Initial failed, retry passed — это результат двух попыток.</text>
<rect x="852" y="440" width="280" height="67" rx="14" fill="#eef2ff" stroke="#6366f1" stroke-width="2"/>
<text x="874" y="468" fill="#3730a3" font-family="Arial, sans-serif" font-size="20" font-weight="700">Сохранить оба outcome</text>
<text x="874" y="490" fill="#3730a3" font-family="Arial, sans-serif" font-size="16">Не называть retry причиной.</text>
</g>
<g>
<rect x="68" y="561" width="735" height="96" rx="16" fill="#f5f3ff" stroke="#8b5cf6" stroke-width="3"/>
<circle cx="116" cy="609" r="25" fill="#6d28d9"/>
<text x="108" y="619" fill="#ffffff" font-family="Arial, sans-serif" font-size="26" font-weight="700">4</text>
<text x="160" y="599" fill="#102a43" font-family="Arial, sans-serif" font-size="25" font-weight="700">Evidence</text>
<text x="160" y="628" fill="#334e68" font-family="Arial, sans-serif" font-size="20">Trace/log/snapshot привязан к номеру попытки и одному вопросу?</text>
<rect x="852" y="575" width="280" height="67" rx="14" fill="#f5f3ff" stroke="#8b5cf6" stroke-width="2"/>
<text x="874" y="603" fill="#5b21b6" font-family="Arial, sans-serif" font-size="20" font-weight="700">Собрать один факт</text>
<text x="874" y="625" fill="#5b21b6" font-family="Arial, sans-serif" font-size="16">Не обещать root cause.</text>
</g>
<rect x="68" y="696" width="1064" height="62" rx="14" fill="#102a43"/>
<text x="96" y="734" fill="#ffffff" font-family="Arial, sans-serif" font-size="23" font-weight="700">Только после этого: малый diff, owner, rollback. Timeout — верхняя граница, не readiness condition.</text>
</svg>

After

Width:  |  Height:  |  Size: 5.8 KiB

@@ -0,0 +1,61 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800" viewBox="0 0 1200 800" role="img" aria-labelledby="title desc">
<title id="title">Цикл triage flaky e2e-теста</title>
<desc id="desc">Пять шагов замкнутого цикла: зафиксировать initial и retry outcome, проверить selector, проверить readiness, связать evidence с попыткой, сделать маленький diff с rollback. При недостатке фактов цикл возвращается к карточке, а не повышает timeout.</desc>
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto"><path d="M0,0 L12,6 L0,12 Z" fill="#486581"/></marker>
</defs>
<rect width="1200" height="800" fill="#f8fafc"/>
<rect x="48" y="42" width="1104" height="84" rx="18" fill="#102a43"/>
<text x="84" y="92" fill="#ffffff" font-family="Arial, sans-serif" font-size="34" font-weight="700">Flaky triage: один факт → одна проверка → малый diff</text>
<text x="84" y="116" fill="#d9e2ec" font-family="Arial, sans-serif" font-size="17">Цикл не ищет «идеальный timeout»: он сохраняет объяснимый контракт и rollback.</text>
<path d="M369 251 C470 182, 593 177, 686 224" fill="none" stroke="#486581" stroke-width="5" marker-end="url(#arrow)"/>
<path d="M840 299 C913 365, 913 469, 830 521" fill="none" stroke="#486581" stroke-width="5" marker-end="url(#arrow)"/>
<path d="M700 599 C580 662, 442 649, 356 592" fill="none" stroke="#486581" stroke-width="5" marker-end="url(#arrow)"/>
<path d="M205 520 C133 454, 134 341, 214 286" fill="none" stroke="#486581" stroke-width="5" marker-end="url(#arrow)"/>
<g>
<rect x="95" y="195" width="280" height="138" rx="20" fill="#e0f2fe" stroke="#0284c7" stroke-width="3"/>
<text x="123" y="237" fill="#075985" font-family="Arial, sans-serif" font-size="24" font-weight="700">1. Карточка</text>
<text x="123" y="270" fill="#102a43" font-family="Arial, sans-serif" font-size="18">initial outcome</text>
<text x="123" y="295" fill="#102a43" font-family="Arial, sans-serif" font-size="18">retry outcome</text>
<text x="123" y="318" fill="#334e68" font-family="Arial, sans-serif" font-size="15">Не удалять initial error.</text>
</g>
<g>
<rect x="700" y="195" width="280" height="138" rx="20" fill="#ecfdf5" stroke="#059669" stroke-width="3"/>
<text x="728" y="237" fill="#047857" font-family="Arial, sans-serif" font-size="24" font-weight="700">2. Selector</text>
<text x="728" y="270" fill="#102a43" font-family="Arial, sans-serif" font-size="18">user-facing locator</text>
<text x="728" y="295" fill="#102a43" font-family="Arial, sans-serif" font-size="18">ровно один match?</text>
<text x="728" y="318" fill="#334e68" font-family="Arial, sans-serif" font-size="15">Нет → отдельный locator diff.</text>
</g>
<g>
<rect x="700" y="493" width="280" height="138" rx="20" fill="#fff7ed" stroke="#ea580c" stroke-width="3"/>
<text x="728" y="535" fill="#9a3412" font-family="Arial, sans-serif" font-size="24" font-weight="700">3. Readiness</text>
<text x="728" y="568" fill="#102a43" font-family="Arial, sans-serif" font-size="18">terminal UI-state</text>
<text x="728" y="593" fill="#102a43" font-family="Arial, sans-serif" font-size="18">после действия?</text>
<text x="728" y="616" fill="#334e68" font-family="Arial, sans-serif" font-size="15">Не ждать spinner или sleep.</text>
</g>
<g>
<rect x="95" y="493" width="280" height="138" rx="20" fill="#f5f3ff" stroke="#7c3aed" stroke-width="3"/>
<text x="123" y="535" fill="#5b21b6" font-family="Arial, sans-serif" font-size="24" font-weight="700">4. Evidence</text>
<text x="123" y="568" fill="#102a43" font-family="Arial, sans-serif" font-size="18">trace/log/snapshot</text>
<text x="123" y="593" fill="#102a43" font-family="Arial, sans-serif" font-size="18">какой attempt?</text>
<text x="123" y="616" fill="#334e68" font-family="Arial, sans-serif" font-size="15">Один артефакт — один вопрос.</text>
</g>
<g>
<rect x="421" y="338" width="358" height="166" rx="24" fill="#102a43"/>
<text x="455" y="386" fill="#ffffff" font-family="Arial, sans-serif" font-size="26" font-weight="700">5. Решение</text>
<text x="455" y="421" fill="#d9e2ec" font-family="Arial, sans-serif" font-size="19">малый diff в одном слое</text>
<text x="455" y="448" fill="#d9e2ec" font-family="Arial, sans-serif" font-size="19">owner + rollback + next check</text>
<text x="455" y="479" fill="#fbbf24" font-family="Arial, sans-serif" font-size="17" font-weight="700">Timeout не меняется автоматически</text>
</g>
<path d="M375 264 L416 354" fill="none" stroke="#94a3b8" stroke-width="4" stroke-dasharray="8 8" marker-end="url(#arrow)"/>
<path d="M700 264 L782 354" fill="none" stroke="#94a3b8" stroke-width="4" stroke-dasharray="8 8" marker-end="url(#arrow)"/>
<path d="M700 560 L782 488" fill="none" stroke="#94a3b8" stroke-width="4" stroke-dasharray="8 8" marker-end="url(#arrow)"/>
<path d="M375 560 L416 488" fill="none" stroke="#94a3b8" stroke-width="4" stroke-dasharray="8 8" marker-end="url(#arrow)"/>
<rect x="172" y="690" width="856" height="61" rx="14" fill="#e0e7ff" stroke="#6366f1" stroke-width="2"/>
<text x="205" y="728" fill="#312e81" font-family="Arial, sans-serif" font-size="22" font-weight="700">Недостаточно фактов? Вернуться к карточке и собрать один недостающий сигнал.</text>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

@@ -0,0 +1,66 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800" viewBox="0 0 1200 800" role="img" aria-labelledby="title desc">
<title id="title">Четыре контракта ожидания в e2e-тесте</title>
<desc id="desc">Горизонтальная схема разделяет selector, actionability, readiness condition и retry. Под ними evidence привязан к попытке. Подписи показывают, что каждый слой не доказывает следующий.</desc>
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto"><path d="M0,0 L12,6 L0,12 Z" fill="#486581"/></marker>
<marker id="down" markerWidth="10" markerHeight="10" refX="5" refY="8" orient="auto"><path d="M0,0 L10,0 L5,10 Z" fill="#9fb3c8"/></marker>
</defs>
<rect width="1200" height="800" fill="#f8fafc"/>
<rect x="50" y="42" width="1100" height="88" rx="18" fill="#102a43"/>
<text x="84" y="91" fill="#ffffff" font-family="Arial, sans-serif" font-size="34" font-weight="700">Ожидание e2e: четыре разных контракта</text>
<text x="84" y="115" fill="#d9e2ec" font-family="Arial, sans-serif" font-size="17">Верхняя полоса — действие и результат. Нижняя — политика исполнения и evidence.</text>
<path d="M303 292 L335 292" stroke="#486581" stroke-width="5" marker-end="url(#arrow)"/>
<path d="M574 292 L606 292" stroke="#486581" stroke-width="5" marker-end="url(#arrow)"/>
<path d="M845 292 L877 292" stroke="#486581" stroke-width="5" marker-end="url(#arrow)"/>
<g>
<rect x="70" y="190" width="230" height="205" rx="18" fill="#e0f2fe" stroke="#0284c7" stroke-width="3"/>
<text x="96" y="236" fill="#075985" font-family="Arial, sans-serif" font-size="25" font-weight="700">1. Selector</text>
<text x="96" y="272" fill="#102a43" font-family="Arial, sans-serif" font-size="19">Где действие?</text>
<text x="96" y="300" fill="#334e68" font-family="Arial, sans-serif" font-size="17">role/name или</text>
<text x="96" y="324" fill="#334e68" font-family="Arial, sans-serif" font-size="17">явный test id</text>
<text x="96" y="365" fill="#9f1239" font-family="Arial, sans-serif" font-size="16" font-weight="700">Не доказывает:</text>
<text x="96" y="385" fill="#9f1239" font-family="Arial, sans-serif" font-size="15">успех операции</text>
</g>
<g>
<rect x="340" y="190" width="230" height="205" rx="18" fill="#ecfdf5" stroke="#059669" stroke-width="3"/>
<text x="366" y="236" fill="#047857" font-family="Arial, sans-serif" font-size="25" font-weight="700">2. Actionability</text>
<text x="366" y="272" fill="#102a43" font-family="Arial, sans-serif" font-size="19">Можно click?</text>
<text x="366" y="300" fill="#334e68" font-family="Arial, sans-serif" font-size="17">visible, stable,</text>
<text x="366" y="324" fill="#334e68" font-family="Arial, sans-serif" font-size="17">enabled, events</text>
<text x="366" y="365" fill="#9f1239" font-family="Arial, sans-serif" font-size="16" font-weight="700">Не доказывает:</text>
<text x="366" y="385" fill="#9f1239" font-family="Arial, sans-serif" font-size="15">product-ready</text>
</g>
<g>
<rect x="610" y="190" width="230" height="205" rx="18" fill="#fff7ed" stroke="#ea580c" stroke-width="3"/>
<text x="636" y="236" fill="#9a3412" font-family="Arial, sans-serif" font-size="25" font-weight="700">3. Readiness</text>
<text x="636" y="272" fill="#102a43" font-family="Arial, sans-serif" font-size="19">Что увидел user?</text>
<text x="636" y="300" fill="#334e68" font-family="Arial, sans-serif" font-size="17">terminal state,</text>
<text x="636" y="324" fill="#334e68" font-family="Arial, sans-serif" font-size="17">status, итог</text>
<text x="636" y="365" fill="#9f1239" font-family="Arial, sans-serif" font-size="16" font-weight="700">Не доказывает:</text>
<text x="636" y="385" fill="#9f1239" font-family="Arial, sans-serif" font-size="15">selector was valid</text>
</g>
<g>
<rect x="880" y="190" width="230" height="205" rx="18" fill="#eef2ff" stroke="#4f46e5" stroke-width="3"/>
<text x="906" y="236" fill="#3730a3" font-family="Arial, sans-serif" font-size="25" font-weight="700">4. Retry</text>
<text x="906" y="272" fill="#102a43" font-family="Arial, sans-serif" font-size="19">Что сделал runner?</text>
<text x="906" y="300" fill="#334e68" font-family="Arial, sans-serif" font-size="17">initial / retry</text>
<text x="906" y="324" fill="#334e68" font-family="Arial, sans-serif" font-size="17">outcome, worker</text>
<text x="906" y="365" fill="#9f1239" font-family="Arial, sans-serif" font-size="16" font-weight="700">Не доказывает:</text>
<text x="906" y="385" fill="#9f1239" font-family="Arial, sans-serif" font-size="15">root cause</text>
</g>
<path d="M185 414 L185 477" stroke="#9fb3c8" stroke-width="4" stroke-dasharray="8 8" marker-end="url(#down)"/>
<path d="M455 414 L455 477" stroke="#9fb3c8" stroke-width="4" stroke-dasharray="8 8" marker-end="url(#down)"/>
<path d="M725 414 L725 477" stroke="#9fb3c8" stroke-width="4" stroke-dasharray="8 8" marker-end="url(#down)"/>
<path d="M995 414 L995 477" stroke="#9fb3c8" stroke-width="4" stroke-dasharray="8 8" marker-end="url(#down)"/>
<rect x="70" y="493" width="1040" height="132" rx="18" fill="#f5f3ff" stroke="#7c3aed" stroke-width="3"/>
<text x="102" y="538" fill="#5b21b6" font-family="Arial, sans-serif" font-size="26" font-weight="700">Evidence принадлежит попытке, а не всей истории</text>
<text x="102" y="574" fill="#334e68" font-family="Arial, sans-serif" font-size="20">Trace, snapshot и action log отвечают на один вопрос конкретного запуска.</text>
<text x="102" y="602" fill="#334e68" font-family="Arial, sans-serif" font-size="20">Если evidence записан on-first-retry, он не заменяет контекст initial failure.</text>
<rect x="70" y="671" width="1040" height="72" rx="16" fill="#102a43"/>
<text x="103" y="715" fill="#ffffff" font-family="Arial, sans-serif" font-size="23" font-weight="700">Правка меняет один слой: locator, readiness, test data или evidence policy. Не все сразу.</text>
</svg>

After

Width:  |  Height:  |  Size: 6.3 KiB

+651
View File
@@ -0,0 +1,651 @@
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
const p = (text) => '<p>' + text + '</p>';
const h2 = (text) => '<h2>' + text + '</h2>';
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((item) => '<th scope="col">' + item + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
function plainText(content) {
return content
.replace(/<[^>]+>/g, ' ')
.replace(/&(?:quot|amp|lt|gt|#039);/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function bodyText(content) {
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
const sources = {
release: {
title: 'Playwright v1.37.0: официальный GitHub release, 10 августа 2023',
url: 'https://github.com/microsoft/playwright/releases/tag/v1.37.0',
note: 'версионный срез, опубликованный до конца августа 2023. Он фиксирует выпуск, но не подтверждает версию, браузеры или настройку конкретного проекта.',
},
actionability: {
title: 'Playwright v1.37.0: Auto-waiting, исходник официальной документации по тегу',
url: 'https://raw.githubusercontent.com/microsoft/playwright/v1.37.0/docs/src/actionability.md',
note: 'для click описаны проверки attached, visible, stable, receives events и enabled. Эти проверки готовят действие с элементом, но не доказывают бизнес-результат после действия.',
},
retries: {
title: 'Playwright v1.37.0: Retries, исходник официальной документации по тегу',
url: 'https://raw.githubusercontent.com/microsoft/playwright/v1.37.0/docs/src/test-retries-js.md',
note: 'описывает повторный запуск после сбоя, новый worker и статусы passed, flaky, failed. Статус flaky — классификация результата запусков, а не причина сбоя.',
},
trace: {
title: 'Playwright v1.37.0: Trace Viewer, исходник официальной документации по тегу',
url: 'https://raw.githubusercontent.com/microsoft/playwright/v1.37.0/docs/src/trace-viewer.md',
note: 'описывает действия, snapshots, action log, source и network log; режим on-first-retry записывает trace при первом retry. Такой артефакт — evidence одного запуска, а не доказательство корневой причины.',
},
bestPractices: {
title: 'Playwright v1.37.0: Best Practices, исходник официальной документации по тегу',
url: 'https://raw.githubusercontent.com/microsoft/playwright/v1.37.0/docs/src/best-practices-js.md',
note: 'рекомендует изоляцию тестов, пользовательские locator и web-first assertions. Рекомендация не заменяет проверку реального контракта конкретного экрана.',
},
};
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function revision(meta, parts, sourceItems) {
const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + '\n' + sourceList(sourceItems);
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) {
throw new Error(meta.slug + ': основной текст должен занимать 5 000–15 000 знаков, сейчас ' + proseLength);
}
return Object.freeze({ ...meta, contentHtml, proseLength });
}
const SYNTHETIC_KIND = 'synthetic-e2e-evidence-set-v1';
const SYNTHETIC_TRACE_KIND = 'synthetic-trace-record-v1';
function freezeRun(run) {
return Object.freeze({
...run,
selector: Object.freeze({ ...run.selector }),
readiness: Object.freeze({ ...run.readiness }),
retry: Object.freeze({ ...run.retry }),
evidence: Object.freeze({ ...run.evidence }),
});
}
/**
* Учебный вход создан целиком в памяти. В нём нет URL, browser name, duration,
* видео, настоящего trace.zip, сетевого ответа, файлового чтения и запуска test runner.
* Поля trace-shaped специально описывают форму evidence, а не реальный Trace Viewer.
* Fixture не оценивает флак реального теста, не измеряет время и не утверждает
* browser compatibility, качество селектора или состояние интерфейса проекта.
*/
export const syntheticE2eEvidence = Object.freeze({
kind: SYNTHETIC_KIND,
source: 'memory-only',
browserExecution: 'not-performed-by-fixture',
testLabel: 'synthetic checkout confirmation',
runs: Object.freeze([
freezeRun({
attempt: 0,
outcome: 'failed',
selector: {
kind: 'role-and-name',
query: 'button: Оплатить',
cardinality: 'exactly-one',
source: 'synthetic-memory-only',
},
readiness: {
kind: 'semantic-ui-state',
condition: 'payment-state=confirmed',
observed: 'submit-control-disabled',
reached: false,
source: 'synthetic-memory-only',
},
retry: {
index: 0,
configuredRetries: 1,
role: 'initial-attempt',
},
evidence: {
kind: SYNTHETIC_TRACE_KIND,
source: 'synthetic-memory-only',
actualTrace: 'not-produced',
video: 'not-produced',
scope: 'illustrative-action-and-readiness-labels',
},
}),
freezeRun({
attempt: 1,
outcome: 'passed',
selector: {
kind: 'role-and-name',
query: 'button: Оплатить',
cardinality: 'exactly-one',
source: 'synthetic-memory-only',
},
readiness: {
kind: 'semantic-ui-state',
condition: 'payment-state=confirmed',
observed: 'payment-state=confirmed',
reached: true,
source: 'synthetic-memory-only',
},
retry: {
index: 1,
configuredRetries: 1,
role: 'first-retry',
},
evidence: {
kind: SYNTHETIC_TRACE_KIND,
source: 'synthetic-memory-only',
actualTrace: 'not-produced',
video: 'not-produced',
scope: 'illustrative-action-and-readiness-labels',
},
}),
]),
});
function hasText(value) {
return typeof value === 'string' && value.trim().length > 0;
}
function validSyntheticRunShape(run) {
const allowedCardinality = new Set(['exactly-one', 'none', 'multiple']);
const allowedOutcome = new Set(['passed', 'failed']);
return Boolean(
run
&& Number.isInteger(run.attempt)
&& allowedOutcome.has(run.outcome)
&& run.selector
&& run.selector.kind === 'role-and-name'
&& hasText(run.selector.query)
&& allowedCardinality.has(run.selector.cardinality)
&& run.selector.source === 'synthetic-memory-only'
&& run.readiness
&& run.readiness.kind === 'semantic-ui-state'
&& hasText(run.readiness.condition)
&& hasText(run.readiness.observed)
&& typeof run.readiness.reached === 'boolean'
&& run.readiness.source === 'synthetic-memory-only'
&& run.retry
&& Number.isInteger(run.retry.index)
&& Number.isInteger(run.retry.configuredRetries)
&& hasText(run.retry.role)
&& run.evidence
&& run.evidence.kind === SYNTHETIC_TRACE_KIND
&& run.evidence.source === 'synthetic-memory-only'
&& run.evidence.actualTrace === 'not-produced'
&& run.evidence.video === 'not-produced'
&& hasText(run.evidence.scope),
);
}
/**
* Разводит четыре слоя только в учебном наборе: selector, semantic readiness,
* retry и trace-shaped evidence. Функция не называет этот набор флаком реального
* теста и не делает вывод о приложении, браузере, сети или root cause.
*/
export function classifySyntheticE2eEvidence(input) {
const base = Object.freeze({
kind: 'synthetic-e2e-classification-v1',
diagnosisScope: 'synthetic-shape-only',
realFlakeAssessment: 'not-performed-by-fixture',
durationMeasurement: 'not-performed-by-fixture',
browserCompatibility: 'not-assessed-by-fixture',
});
if (!input || input.kind !== SYNTHETIC_KIND || input.source !== 'memory-only') {
return Object.freeze({ ...base, accepted: false, reason: 'synthetic-memory-kind-required' });
}
if (input.browserExecution !== 'not-performed-by-fixture' || !Array.isArray(input.runs) || input.runs.length !== 2) {
return Object.freeze({ ...base, accepted: false, reason: 'two-memory-only-runs-required' });
}
if (!input.runs.every(validSyntheticRunShape)) {
return Object.freeze({ ...base, accepted: false, reason: 'synthetic-run-contract-required' });
}
const [initial, retry] = input.runs;
if (initial.attempt !== 0 || retry.attempt !== 1 || initial.retry.index !== 0 || retry.retry.index !== 1 || initial.retry.configuredRetries !== 1 || retry.retry.configuredRetries !== 1 || initial.retry.configuredRetries !== retry.retry.configuredRetries || initial.retry.role !== 'initial-attempt' || retry.retry.role !== 'first-retry') {
return Object.freeze({ ...base, accepted: false, reason: 'initial-and-first-retry-order-required' });
}
const selectorContract = Object.freeze({
query: initial.selector.query,
initialCardinality: initial.selector.cardinality,
retryCardinality: retry.selector.cardinality,
meaning: 'указатель на элемент, не доказательство результата операции',
});
const readinessContract = Object.freeze({
condition: initial.readiness.condition,
initialObserved: initial.readiness.observed,
retryObserved: retry.readiness.observed,
meaning: 'проверяемый пользовательский факт после действия, не готовность locator к click',
});
const retryRecord = Object.freeze({
configuredRetries: retry.retry.configuredRetries,
initialOutcome: initial.outcome,
retryOutcome: retry.outcome,
meaning: 'повторный запуск меняет статус набора попыток, но не объясняет их расхождение',
});
const evidenceRecord = Object.freeze({
initial: initial.evidence.actualTrace,
retry: retry.evidence.actualTrace,
source: 'synthetic-memory-only',
meaning: 'форма evidence для следующей проверки; не trace, video или запуск браузера',
});
if (initial.selector.cardinality !== 'exactly-one' || retry.selector.cardinality !== 'exactly-one') {
return Object.freeze({
...base,
accepted: true,
classification: 'selector-contract-broken-in-synthetic-input',
selector: selectorContract,
readiness: readinessContract,
retry: retryRecord,
evidence: evidenceRecord,
nextQuestion: 'Почему пользовательский locator не разрешается ровно в один элемент?',
suggestedAction: 'narrow-user-facing-locator-before-changing-wait-or-retry',
});
}
if (initial.outcome === 'failed' && retry.outcome === 'passed' && initial.readiness.reached === false && retry.readiness.reached === true && initial.readiness.condition === retry.readiness.condition) {
return Object.freeze({
...base,
accepted: true,
classification: 'retry-masked-missing-readiness-contract-in-synthetic-input',
selector: selectorContract,
readiness: readinessContract,
retry: retryRecord,
evidence: evidenceRecord,
nextQuestion: 'Какой продуктовый факт должен быть достигнут до assert и чем он наблюдается?',
suggestedAction: 'assert-semantic-readiness-before-changing-timeout',
});
}
return Object.freeze({
...base,
accepted: true,
classification: 'inconclusive-synthetic-evidence',
selector: selectorContract,
readiness: readinessContract,
retry: retryRecord,
evidence: evidenceRecord,
nextQuestion: 'Какой один слой нужно сделать наблюдаемым без домыслов о причине?',
suggestedAction: 'collect-scoped-evidence-before-changing-test-policy',
});
}
/**
* Планирует учебную правку контракта готовности. Он не меняет test file,
* Playwright config, timeout, retry policy, CI, trace mode или приложение.
*/
export function planSyntheticReadinessChange(classification, proposal) {
const base = Object.freeze({
kind: 'synthetic-e2e-readiness-plan-v1',
applied: 'not-applied-by-fixture',
effectOnRealTest: 'not-determined-by-fixture',
});
if (!classification?.accepted || classification.kind !== 'synthetic-e2e-classification-v1') {
return Object.freeze({ ...base, accepted: false, reason: 'accepted-synthetic-classification-required' });
}
if (classification.classification !== 'retry-masked-missing-readiness-contract-in-synthetic-input') {
return Object.freeze({ ...base, accepted: false, reason: 'synthetic-readiness-classification-required' });
}
if (!proposal || proposal.kind !== 'synthetic-e2e-readiness-proposal-v1') {
return Object.freeze({ ...base, accepted: false, reason: 'synthetic-readiness-proposal-required' });
}
if (proposal.action !== 'assert-semantic-readiness' || proposal.target !== 'readiness-condition') {
return Object.freeze({ ...base, accepted: false, reason: 'semantic-readiness-action-required' });
}
if (!hasText(proposal.owner) || !hasText(proposal.condition) || !hasText(proposal.rollback) || !hasText(proposal.nextCheck)) {
return Object.freeze({ ...base, accepted: false, reason: 'owner-condition-rollback-next-check-required' });
}
if (proposal.condition !== classification.readiness.condition) {
return Object.freeze({ ...base, accepted: false, reason: 'proposal-condition-must-match-synthetic-evidence' });
}
if (proposal.timeoutChange !== 'unchanged' || proposal.retryChange !== 'unchanged') {
return Object.freeze({ ...base, accepted: false, reason: 'timeout-and-retry-must-remain-unchanged-in-synthetic-plan' });
}
if (proposal.selectorChange !== 'not-requested-by-synthetic-evidence') {
return Object.freeze({ ...base, accepted: false, reason: 'selector-change-must-not-be-inferred' });
}
return Object.freeze({
...base,
accepted: true,
owner: proposal.owner,
action: proposal.action,
target: proposal.target,
condition: proposal.condition,
timeoutChange: proposal.timeoutChange,
retryChange: proposal.retryChange,
selectorChange: proposal.selectorChange,
rollback: proposal.rollback,
nextCheck: proposal.nextCheck,
});
}
/**
* Rollback возвращает только учебный план к неприменённому состоянию. Никакой
* test code, CI config, timeout, retry или trace-artifact эта функция не меняет.
*/
export function rollbackSyntheticReadinessPlan(plan) {
if (!plan?.accepted || plan.kind !== 'synthetic-e2e-readiness-plan-v1') {
return Object.freeze({ restored: false, reason: 'accepted-synthetic-plan-required' });
}
return Object.freeze({
restored: true,
restoredState: 'no-real-change-was-applied',
testCode: 'not-changed-by-fixture',
runnerConfig: 'not-changed-by-fixture',
timeout: 'unchanged-by-fixture',
retry: 'unchanged-by-fixture',
evidence: 'synthetic-memory-only',
});
}
export function runE2eStabilityFixture() {
const classification = classifySyntheticE2eEvidence(syntheticE2eEvidence);
const ambiguousSelector = classifySyntheticE2eEvidence({
...syntheticE2eEvidence,
runs: [
{ ...syntheticE2eEvidence.runs[0], selector: { ...syntheticE2eEvidence.runs[0].selector, cardinality: 'multiple' } },
syntheticE2eEvidence.runs[1],
],
});
const missingEvidence = classifySyntheticE2eEvidence({
...syntheticE2eEvidence,
runs: [
{ ...syntheticE2eEvidence.runs[0], evidence: { ...syntheticE2eEvidence.runs[0].evidence, actualTrace: 'unknown' } },
syntheticE2eEvidence.runs[1],
],
});
const invalidKind = classifySyntheticE2eEvidence({ ...syntheticE2eEvidence, kind: 'external-trace-import' });
const inconsistentRetryPolicy = classifySyntheticE2eEvidence({
...syntheticE2eEvidence,
runs: [
{ ...syntheticE2eEvidence.runs[0], retry: { ...syntheticE2eEvidence.runs[0].retry, configuredRetries: 0 } },
syntheticE2eEvidence.runs[1],
],
});
const goodPlan = planSyntheticReadinessChange(classification, {
kind: 'synthetic-e2e-readiness-proposal-v1',
action: 'assert-semantic-readiness',
target: 'readiness-condition',
owner: 'synthetic-test-owner',
condition: 'payment-state=confirmed',
timeoutChange: 'unchanged',
retryChange: 'unchanged',
selectorChange: 'not-requested-by-synthetic-evidence',
rollback: 'restore-previous-synthetic-readiness-contract',
nextCheck: 'inspect-one-real-trace-only-if-project-records-it',
});
const timeoutPlan = planSyntheticReadinessChange(classification, {
kind: 'synthetic-e2e-readiness-proposal-v1',
action: 'assert-semantic-readiness',
target: 'readiness-condition',
owner: 'synthetic-test-owner',
condition: 'payment-state=confirmed',
timeoutChange: 'increase-to-30s',
retryChange: 'unchanged',
selectorChange: 'not-requested-by-synthetic-evidence',
rollback: 'restore-previous-synthetic-readiness-contract',
nextCheck: 'inspect-one-real-trace-only-if-project-records-it',
});
const selectorPlan = planSyntheticReadinessChange(classification, {
kind: 'synthetic-e2e-readiness-proposal-v1',
action: 'assert-semantic-readiness',
target: 'readiness-condition',
owner: 'synthetic-test-owner',
condition: 'payment-state=confirmed',
timeoutChange: 'unchanged',
retryChange: 'unchanged',
selectorChange: 'replace-with-css-chain',
rollback: 'restore-previous-synthetic-readiness-contract',
nextCheck: 'inspect-one-real-trace-only-if-project-records-it',
});
const wrongConditionPlan = planSyntheticReadinessChange(classification, {
kind: 'synthetic-e2e-readiness-proposal-v1',
action: 'assert-semantic-readiness',
target: 'readiness-condition',
owner: 'synthetic-test-owner',
condition: 'unrelated-ui-state=ready',
timeoutChange: 'unchanged',
retryChange: 'unchanged',
selectorChange: 'not-requested-by-synthetic-evidence',
rollback: 'restore-previous-synthetic-readiness-contract',
nextCheck: 'inspect-one-real-trace-only-if-project-records-it',
});
const rollback = rollbackSyntheticReadinessPlan(goodPlan);
return Object.freeze({
assertions: Object.freeze({
inputIsExplicitlySynthetic: syntheticE2eEvidence.kind === SYNTHETIC_KIND && syntheticE2eEvidence.source === 'memory-only',
fixtureDoesNotRunBrowser: syntheticE2eEvidence.browserExecution === 'not-performed-by-fixture',
fixtureContainsTwoDeterministicAttempts: syntheticE2eEvidence.runs.length === 2 && syntheticE2eEvidence.runs[0].attempt === 0 && syntheticE2eEvidence.runs[1].attempt === 1,
classificationAcceptsSyntheticContract: classification.accepted === true && classification.classification === 'retry-masked-missing-readiness-contract-in-synthetic-input',
selectorIsKeptSeparateFromReadiness: classification.selector.initialCardinality === 'exactly-one' && classification.readiness.condition === 'payment-state=confirmed',
initialReadinessIsNotReached: classification.readiness.initialObserved === 'submit-control-disabled',
retryIsNotExplainedAsCause: classification.retry.initialOutcome === 'failed' && classification.retry.retryOutcome === 'passed' && classification.retry.meaning.includes('не объясняет'),
evidenceIsNotNamedRealTrace: classification.evidence.initial === 'not-produced' && classification.evidence.retry === 'not-produced' && classification.evidence.source === 'synthetic-memory-only',
realFlakeIsNotAssessed: classification.realFlakeAssessment === 'not-performed-by-fixture' && classification.durationMeasurement === 'not-performed-by-fixture',
ambiguousSelectorGetsOwnClass: ambiguousSelector.accepted === true && ambiguousSelector.classification === 'selector-contract-broken-in-synthetic-input',
malformedEvidenceIsRejected: missingEvidence.accepted === false && missingEvidence.reason === 'synthetic-run-contract-required',
externalInputKindIsRejected: invalidKind.accepted === false && invalidKind.reason === 'synthetic-memory-kind-required',
inconsistentRetryPolicyIsRejected: inconsistentRetryPolicy.accepted === false && inconsistentRetryPolicy.reason === 'initial-and-first-retry-order-required',
readinessPlanIsScopedAndUnapplied: goodPlan.accepted === true && goodPlan.target === 'readiness-condition' && goodPlan.applied === 'not-applied-by-fixture',
timeoutIncreaseIsRejected: timeoutPlan.accepted === false && timeoutPlan.reason === 'timeout-and-retry-must-remain-unchanged-in-synthetic-plan',
selectorRewriteIsNotInferred: selectorPlan.accepted === false && selectorPlan.reason === 'selector-change-must-not-be-inferred',
proposedConditionMustMatchEvidence: wrongConditionPlan.accepted === false && wrongConditionPlan.reason === 'proposal-condition-must-match-synthetic-evidence',
rollbackDoesNotClaimRealChange: rollback.restored === true && rollback.testCode === 'not-changed-by-fixture' && rollback.runnerConfig === 'not-changed-by-fixture',
}),
samples: Object.freeze({ classification, ambiguousSelector, missingEvidence, invalidKind, inconsistentRetryPolicy, goodPlan, timeoutPlan, selectorPlan, wrongConditionPlan, rollback }),
});
}
const fixtureCommand = `node web/scripts/upgrade-2023-08.mjs --verify-fixture
# Команда детерминированно создаёт marked synthetic attempts только в памяти.
# Она не открывает браузер, не читает проект, не запускает Playwright,
# не записывает trace/video, не измеряет duration и не проверяет compatibility.
# PASS проверяет разделение selector, readiness, retry и synthetic evidence.
# PASS не означает, что настоящий тест flaky, что причина найдена или что UI готов.`;
const illustrativeConfig = `// Иллюстрация для Playwright v1.37.0; этот фрагмент не запускается fixture.
import { defineConfig } from '@playwright/test';
export default defineConfig({
retries: process.env.CI ? 1 : 0,
use: { trace: 'on-first-retry' },
});
// retry сохраняет evidence первого повторного запуска.
// Он не заменяет явное условие готовности после click.`;
const illustrativeTest = `// Иллюстрация контракта: locator выбирает действие, expect проверяет результат.
import { expect, test } from '@playwright/test';
test('подтверждает оплату', async ({ page }) => {
await page.getByRole('button', { name: 'Оплатить' }).click();
await expect(page.getByTestId('payment-state')).toHaveText('confirmed');
});
// getByRole — selector. toHaveText — readiness condition.
// Текст и test id должны соответствовать контракту конкретного продукта.`;
const practice = revision({
slug: 'editorial-2023-08-practice-e2e-stability',
title: 'E2E без лишних retry: ждать факт, а не тишину интерфейса',
categories: ['Тестирование', 'Frontend'],
cover: '/assets/editorial/2023/e2e-stability-2023-flake-classification.svg',
excerpt: 'Почему retry не лечит e2e-падение: отделяем locator, условие готовности, повторный запуск и evidence, а timeout меняем только после проверки контракта.',
readingMinutes: 12,
}, [
p('Тест нажимает кнопку «Оплатить», получает ошибку на первой попытке и проходит на повторной. В отчёте остаётся статус flaky, а в pull request появляется короткое предложение: поднять timeout и идти дальше. Проблема в том, что этим действием смешиваются четыре разных объекта: selector для действия, условие готовности интерфейса, retry тест-раннера и evidence из конкретного запуска. Пока они смешаны, команда лечит паузу, а не контракт.'),
p('Цена такой правки не сводится к лишним секундам CI. Реальная ошибка может стать «шумом»: повторный запуск проходит, скрывает первый отказ и откладывает разбор. Обратная цена тоже заметна: бесконечный trace на каждый тест раздувает артефакты, но не отвечает, какой результат должен увидеть пользователь. Здесь нужен короткий порядок: сначала назвать факт после действия, затем выбрать наблюдение, только потом решать, нужен ли retry и какой evidence сохранять.'),
h2('Четыре объекта, которые нельзя называть одним ожиданием'),
p('Selector отвечает на вопрос «куда направить действие». Для Playwright 1.37.0 locator с role и name — это способ найти пользовательский элемент. Перед <code>click()</code> Playwright проверяет, что элемент attached, visible, stable, receives events и enabled. Эти проверки полезны: они не дают кликнуть в скрытый или перекрытый control. Но они не знают, завершилась ли оплата, сохранился ли профиль или пришёл ли пользовательский статус.'),
p('Readiness condition отвечает на другой вопрос: какой наблюдаемый продуктовый факт должен появиться после действия. Это может быть текст в <code>role=status</code>, появление записи в таблице или смена доступного пользователю состояния. Условие не должно быть «страница немного успокоилась» или «кнопка стала disabled», если бизнес-операция ещё не подтверждена. Retry запускает тест повторно после failure. Evidence — trace, snapshot, action log или другой артефакт отдельной попытки. Ни один из них не является синонимом selector или готовности.'),
table('Граница каждого слоя в e2e-сценарии', ['Слой', 'На какой вопрос отвечает', 'Что может подтвердить', 'Чего не подтверждает', 'Первое действие'], [
['Selector', 'Как найти control?', 'locator разрешается ровно в один ожидаемый элемент', 'что операция завершилась', 'использовать role/name или явный test id'],
['Readiness condition', 'Какой факт увидел пользователь после действия?', 'конкретный status, текст, запись или состояние', 'что locator был уникален до click', 'сформулировать ожидаемый продуктовый результат'],
['Retry', 'Что сделал runner после failure?', 'первая попытка не прошла, следующая прошла или нет', 'почему попытки различаются', 'сохранить статус первой и повторной попытки'],
['Evidence', 'Что можно изучить в одном запуске?', 'действия, snapshots, log и network log, если они записаны', 'корневую причину без дополнительной проверки', 'связать артефакт с номером попытки'],
['Timeout', 'Каков верхний предел ожидания?', 'когда ожидание остановится ошибкой', 'какой факт надо дождаться', 'не менять, пока не назван readiness contract'],
]),
h2('Сначала формулируем результат после click'),
p('Практический контракт выглядит короче, чем кажется. После нажатия нужно назвать одно состояние, которое экран обязан показать. Например: «платёж подтверждён», а не «кнопка уже недоступна». Если интерфейс не имеет такого состояния, это не повод ждать произвольную паузу. Это повод вместе с владельцем экрана выбрать доступный сигнал: status region, итоговую строку, новый route или ответственный элемент в UI. Контракт должен быть проверяемым пользователем, а не внутренним CSS-классом без смысла вне реализации.'),
p('Ниже — проектный образец. <code>getByRole()</code> выбирает кнопку, а <code>toHaveText()</code> ждёт текст результата. Web-first assertion в Playwright умеет повторять проверку до timeout; это не означает, что любой текст подходит. Значение <code>confirmed</code> здесь специально условное: в своём продукте его заменяют на реальный, устойчивый и доступный пользователю результат. Фрагмент не доказывает, что страница, backend или browser уже проверены.'),
code(illustrativeTest),
p('Sleep или network idle без связи с результатом делают запуск длиннее, но не объясняют, что делать при ошибке экрана после успешного запроса. Actionability делает действие допустимым, readiness делает завершение сценария наблюдаемым. Тогда timeout — параметр договора, а не способ спрятать расхождение.'),
h2('Учебный fixture: только synthetic попытки в памяти'),
p('Пакет содержит исполнимую модель, но не e2e-запуск. Она создаёт две synthetic попытки: первая не достигает <code>payment-state=confirmed</code>, вторая достигает его после retry. Selector в обеих уникален, поэтому fixture отделяет readiness от selector и retry. Он не открывает URL, не читает тест, не запускает Playwright и не производит <code>trace.zip</code> или video.'),
code(fixtureCommand),
p('PASS проверяет шестнадцать утверждений: вход помечен как memory-only, браузер не запускается, первая и повторная попытки различаются ровно теми полями, которые заданы в модели, а увеличение timeout отклоняется учебным планом. Отдельная отрицательная ветка делает selector множественным и получает другую классификацию. Это важно: даже одинаковый финальный статус «flaky» не даёт права без проверки переписать locator, readiness condition и retry policy одной правкой.'),
figure('/assets/editorial/2023/e2e-stability-2023-flake-classification.svg', 'Схема классификации e2e-сбоя: сначала проверяется уникальность selector, затем отдельное semantic readiness condition, после этого сравниваются initial attempt и retry; trace-shaped evidence остаётся отдельным входом к проверке, а не причиной. Два выхода предлагают уточнить locator либо контракт готовности, а повышение timeout вынесено в отложенное решение.', 'Схема задаёт порядок разбора. Она не показывает настоящий trace, браузер, длительности, количество флаков или совместимость платформ. Каждый прямоугольник — вопрос к одному слою теста.'),
h2('Маршрут: симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> Первый запуск failed, retry passed, а отчёт пометил тест flaky. Сохраните номер попытки, исходный текст ошибки и ссылку на evidence, если ваш runner его записал. Не называйте это доказанной причиной.',
'<strong>Причина.</strong> Обычно в одном ожидании оказались selector, техническая готовность control и результат операции. Иногда к ним добавляют общий timeout, поэтому ошибка становится длиннее, но не яснее.',
'<strong>Проверка selector.</strong> Убедитесь, что locator на обеих попытках разрешается ровно в один пользовательский элемент. Если нет, это отдельная задача: сузить role/name, scope или test id; не менять пока бизнес-assert.',
'<strong>Проверка readiness.</strong> Выпишите факт, который обязан увидеть пользователь после действия. Сверьте, что assertion ждёт именно его, а не disabled button, исчезновение spinner или окончание произвольной паузы.',
'<strong>Проверка retry и evidence.</strong> Сопоставьте initial и retry как два запуска. Для Playwright 1.37.0 retry выполняется в новом worker; trace с <code>on-first-retry</code>, если он настроен, относится к первой повторной попытке. Он помогает задать вопрос, но не возвращает автоматически evidence первого отказа.',
'<strong>Действие.</strong> Внесите минимальную правку в один слой: locator, semantic assertion, изоляцию данных или явный проектный контракт. Не повышайте timeout, пока не можете назвать, что именно должно стать ready.',
'<strong>Rollback.</strong> До merge запишите прежний assertion и критерий возврата. Если новый сигнал оказался неверным, откатите только test diff и повторите разбор; не стирайте историю первой неудачной попытки комментарием «пофиксили flaky».',
]),
h2('Retry полезен как граница сбора evidence, а не как индульгенция'),
p('В Playwright retries выключены по умолчанию. При включении runner повторно запускает упавший тест; документация v1.37.0 называет flaky тот случай, когда первая попытка не прошла, а повторная прошла. Это удобный сигнал для очереди разбора, но не диагноз. Повтор может дать другой worker и чистое состояние, а значит скрыть утечку тестовых данных, зависимость от порядка или неявную готовность экрана. Он не доказывает, что первая ошибка была случайной, внешний сервис был медленным или selector корректен.'),
p('Конфигурация <code>trace: on-first-retry</code> привязывает артефакт к первому повтору, а не ко всей истории. Это evidence retry, не объяснение initial failure. Если нужен контекст первого отказа, проекту требуется отдельный способ его сохранить с учётом чувствительности данных и цены артефактов.'),
code(illustrativeConfig),
h2('Ограничения и следующий проверяемый шаг'),
p('Заметка не измеряет flake rate, не сравнивает браузеры, не обещает устойчивость любого <code>getByRole()</code> и не предлагает общий timeout. Trace фиксирует один запуск, а причина может лежать в приложении, сети, окружении или данных. Рамка ограничена Playwright v1.37.0 от 10 августа 2023; другую версию проверяют по её документации.'),
p('Следующий шаг — взять один тест со статусом flaky и заполнить короткую карточку из пяти строк: selector, readiness condition, initial outcome, retry outcome и доступный evidence. Затем выбрать один слой для изменения и указать rollback. Если карточку нельзя заполнить без догадок, не увеличивайте timeout. Сначала добавьте недостающий наблюдаемый результат или изоляцию данных. Так retry перестаёт превращать ошибку в шум и становится точкой, где начинается инженерный разбор.'),
], [sources.release, sources.actionability, sources.retries, sources.trace, sources.bestPractices]);
const mechanism = revision({
slug: 'editorial-2023-08-mechanism-e2e-stability',
title: 'Selector, ready и retry: четыре контракта одного e2e-теста',
categories: ['Тестирование', 'Frontend'],
cover: '/assets/editorial/2023/e2e-stability-2023-wait-contract.svg',
excerpt: 'Модель устойчивого e2e-теста: auto-wait готовит действие с элементом, readiness доказывает результат, retry классифицирует попытки, а trace остаётся evidence одного запуска.',
readingMinutes: 13,
}, [
p('У e2e-теста часто один большой timeout и один текст ошибки, хотя внутри живут четыре независимых контракта. Locator должен найти ровно тот control, auto-wait должен сделать действие допустимым, продуктовый assertion должен дождаться результата, а retry должен сохранить факт повторного запуска. Когда все четыре слоя названы словом «ожидание», падение становится непонятным: инженер видит TimeoutError, но не знает, кнопка не нашлась, была перекрыта, результат не наступил или retry изменил исходные условия.'),
p('После первой удобной правки команда повышает timeout, затем добавляет retry. Часть ошибок превращается в длинные flaky, CI медленнее, а trace не связан с гипотезой. Вместо этого один тест раскладывают на четыре контракта: у каждого свой вопрос, evidence, владелец изменения и rollback.'),
h2('Контракт действия не равен контракту результата'),
p('В Playwright 1.37.0 auto-wait перед <code>click()</code> проверяет набор actionability conditions. Для click это attached, visible, stable, receives events и enabled. Этот механизм решает узкую задачу: не отправить действие в элемент, который исчез, невидим, движется, перекрыт или disabled. Он не может узнать смысл вашей операции. Кнопка может быть полностью ready для click, но сервер вернёт отказ, клиент покажет validation error или асинхронное подтверждение не появится.'),
p('Поэтому после действия нужен второй контракт — readiness. Он принадлежит сценарию, а не библиотеке. Для оплаты это может быть подтверждённый статус; для сохранения профиля — видимое сообщение и обновлённое значение; для импорта — запись с терминальным состоянием. Условие должно быть достаточно узким, чтобы отделить success от промежуточного состояния, и достаточно пользовательским, чтобы пережить смену внутренней разметки. Если product UI не предоставляет такого сигнала, тест обнаруживает не только техническую проблему, но и недостающую наблюдаемость интерфейса.'),
table('Четыре контракта и их владельцы', ['Контракт', 'Владелец смысла', 'Пример проверки', 'Негативный сигнал', 'Недопустимая подмена'], [
['Selector', 'автор теста и контракт доступной разметки', 'role/button с name или test id в нужном scope', '0 или несколько совпадений', 'считать уникальный selector подтверждением операции'],
['Actionability', 'библиотека и состояние DOM перед действием', 'visible, stable, receives events, enabled для click', 'элемент не готов принять действие', 'ждать бизнес-успех только потому, что click допустим'],
['Readiness', 'продуктовый сценарий и UI-контракт', 'status, итоговая строка, terminal state', 'после действия нужный факт не наблюдается', 'проверять disabled/spinner вместо результата'],
['Retry', 'test runner и политика CI', 'номер попытки, outcome, новый worker', 'failed → passed или failed → failed', 'объявлять retry объяснением причины'],
['Trace evidence', 'артефакт конкретной попытки', 'actions, snapshots, log, source, network log при записи', 'evidence отсутствует или относится к другому запуску', 'выдавать артефакт за root-cause analysis'],
]),
h2('Auto-wait полезен, но у него есть предел'),
p('Auto-wait заменяет ручной sleep ожиданием технической допустимости target. Это снижает зависимость от отрисовки, но не делает интерфейс «готовым»: готов только target для одного действия. Синхронизация с сервером и итоговый статус лежат за границей actionability.'),
p('Уникальный locator с role/name находит пользовательский объект, но не является селектором сетевого ответа и не подтверждает update store. Assertion должен отвечать на вопрос «что увидит человек?», а не цепляться за внутренний CSS, который сломается при рефакторинге.'),
code(illustrativeTest),
p('Фрагмент не запускается вместе с fixture и не даёт готовый селектор для чужого приложения. Его задача — показать роль строк: <code>getByRole()</code> указывает действие, <code>toHaveText()</code> описывает постусловие. В более сложном сценарии readiness может состоять из нескольких согласованных фактов, но это не повод прятать их под <code>waitForTimeout()</code>. Лучше назвать один терминальный state, а дополнительные поля проверять отдельными assertions с понятным сообщением об ошибке.'),
h2('Retry меняет исполнение, но не причинность'),
p('Документация Playwright 1.37.0 описывает retry через worker process: после failure runner отбрасывает worker с браузером, а при включённом retry новый worker начинает с повторной попытки упавшего теста. Это важная техническая деталь для разбора. Повтор получает изолированный контекст исполнения и может не унаследовать причину, которая была в первой попытке. Значит, переход failed → passed — наблюдение о двух запусках, а не доказательство случайности, исправления или совместимости браузера.'),
p('Flaky — повод открыть карточку: фиксированные входы, expected readiness, selector, initial outcome и evidence retry. Общая запись или порядок набора могут исчезнуть в чистом retry context. Тогда правят данные или изоляцию, а не selector и timeout.'),
figure('/assets/editorial/2023/e2e-stability-2023-wait-contract.svg', 'Схема из четырёх слоёв: locator указывает на кнопку, actionability разрешает click, readiness проверяет пользовательский факт после click, retry повторно запускает тест, а trace-shaped evidence прикрепляется к конкретному номеру попытки. Между слоями отмечено, что один не доказывает другой.', 'Диаграмма объясняет контракт ожидания, а не показывает запуск браузера. В ней нет реальных URL, trace, video, длительностей или данных тестового окружения.'),
h2('Trace — evidence с известной областью действия'),
p('Trace Viewer версии 1.37.0 умеет показывать список действий, snapshots, action log, source и network log для записанного trace. Это хороший материал для вопроса «что происходило в этой попытке?». Например, можно заметить, что assertion начался до появления ожидаемого state, или что click пришёлся в другой control. Но нельзя перескочить от одного кадра к слову «причина». Trace не знает ожиданий бизнеса, а сетевой лог не доказывает, что серверная операция эквивалентна пользовательскому успеху без контракта UI.'),
p('Режим <code>on-first-retry</code> записывает trace при первом retry, поэтому evidence относится к повторной попытке. Он не восстановит initial failure. План диагностики сначала называет вопрос к artefact: selector, порядок action, видимый state или событие одного запуска.'),
h2('Маршрут: симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> Тест заканчивается timeout либо статусом flaky. Сначала запишите фазу: поиск locator, click, assertion результата или повторный запуск. Одно слово «упал» слишком мало для изменения.',
'<strong>Причина.</strong> Определите, какой контракт нарушен: cardinality selector, actionability control, readiness condition, изоляция данных или политика retry. Не выводите причину из длительности, если нет отдельного измерения.',
'<strong>Проверка selector и actionability.</strong> Сверьте user-facing locator и cardinality. Перекрытый или disabled элемент — ещё не проверка результата операции.',
'<strong>Проверка readiness.</strong> Запишите terminal state и наблюдаемый элемент. Замените assertion побочного индикатора на web-first assertion пользовательского факта.',
'<strong>Проверка retry.</strong> Сопоставьте initial и retry как разные worker contexts. Выясните, какие данные, настройки или cleanup не были частью контракта первой попытки.',
'<strong>Проверка evidence.</strong> Если проект записал trace, проверьте, к какой попытке он относится и какой вопрос может сузить. Не заявляйте, что просмотрели trace, пока артефакт не предоставлен.',
'<strong>Действие и rollback.</strong> Сделайте один малый diff и сохраните прежний assert либо config diff. Если contract не подтверждается, верните его и соберите evidence, а не поднимайте timeout.',
]),
h2('Учебная модель удерживает границы видимыми'),
p('Исполнимая часть sidecar не использует Playwright API. Она создаёт в памяти два marked synthetic outcome: initial failure с достигнутым selector и недостигнутым readiness, затем passed на first retry. Обе попытки обязаны нести одну и ту же synthetic retry-policy: иначе их нельзя сравнивать как один учебный сценарий. Функция возвращает классификацию <code>retry-masked-missing-readiness-contract-in-synthetic-input</code>. Длинное имя намеренно: оно не говорит «это настоящий flake», а сообщает, какую форму увидела модель. Вторая ветка делает selector множественным и получает отдельный результат; этим fixture не позволяет склеить два вида проблемы.'),
code(fixtureCommand),
p('Учебный plan принимает только readiness condition, совпадающий с condition из evidence; он не может подменить <code>payment-state=confirmed</code> произвольным удобным признаком. Timeout и selector change без evidence отклоняются. Это ограничение модели, не правило для всех проектов. Rollback возвращает неприменённый synthetic plan и сообщает, что test code и runner config не менялись.'),
h2('Ограничения модели и следующий шаг'),
p('Модель не запускает browser, не получает trace/video, не читает исходник, не знает CI, не измеряет duration и не выбирает domain condition. Она не оценивает реальную стабильность. Рамка — Playwright v1.37.0 от 10 августа 2023; другую версию сверяют отдельно.'),
p('Следующий шаг — написать для одного flaky теста компактный контракт из четырёх строк: user-facing locator, expected readiness, retry policy и evidence policy. Добавьте owner UI-сигнала и способ rollback тестовой правки. Если не удаётся сформулировать readiness без CSS или sleep, остановитесь и обсудите с владельцем экрана, какое состояние должен видеть пользователь. Этот разговор обычно дешевле, чем ещё один круг timeout и более полезен, чем случайный зелёный retry.'),
], [sources.release, sources.actionability, sources.retries, sources.trace, sources.bestPractices]);
const field = revision({
slug: 'editorial-2023-08-field-e2e-stability',
title: 'Flaky-пометка: как разобрать retry, trace и rollback без роста timeout',
categories: ['Тестирование', 'Frontend'],
cover: '/assets/editorial/2023/e2e-stability-2023-triage-loop.svg',
excerpt: 'Разбор flaky без магии: фиксируем две попытки, классифицируем selector и readiness отдельно, используем trace как evidence и готовим обратимую правку.',
readingMinutes: 13,
}, [
p('В очереди CI появляется e2e-тест: initial attempt failed, first retry passed. До релиза час, и самое быстрое предложение — увеличить timeout или retries. Проблема в том, что policy меняется раньше минимальных фактов: selector, ожидаемый UI-result, событие первой попытки и evidence конкретного запуска.'),
p('Цена поспешного «исправления flaky» двойная. Если первая ошибка отражает неверный readiness contract, retry превращает её в нерегулярный шум и оставляет продуктовую границу непроверенной. Если причина в изоляции данных или внешней зависимости, общий timeout увеличивает время обратной связи для всех тестов. Полезный разбор ограничивает утверждения: статус flaky — это результат повторного запуска, trace — evidence попытки, а решение должно быть точечным и иметь rollback. До этих шагов слово «стабилизировали» использовать рано.'),
h2('Карточка разбора: пять фактов до изменения конфигурации'),
p('Начинаем не с видео и не с серии повторов. В карточке достаточно имени теста, initial/retry outcome, selector, readiness и ссылки или отсутствия evidence. Если trace сохранён только на retry, так и пишут: «attempt 1». Карточка удерживает разговор на фактах и не даёт перескочить от красного статуса к глобальной настройке.'),
p('Для selector важны форма и область: <code>getByRole(&#039;button&#039;, { name: &#039;Оплатить&#039; })</code> внутри нужного диалога или контейнера — другой контракт, чем CSS-цепочка по классам. Для readiness важен терминальный факт: текст <code>confirmed</code> в status, новая строка с итогом или явный route. Для retry важен номер попытки и policy, а не эмоция «на второй раз повезло». Для evidence важно происхождение: trace, screenshot или log могут сузить вопрос, но должны быть привязаны к одному запуску и не обещать root cause.'),
table('Классификация без повышения timeout', ['Наблюдаемая форма', 'Что уже известно', 'Чего ещё нет', 'Минимальная проверка', 'Безопасное действие'], [
['0 или несколько locator matches', 'нарушен selector contract', 'причина изменения DOM и корректный target', 'сузить scope, role/name или test id', 'исправить locator; readiness и retry не менять вслепую'],
['click не проходит actionability', 'control не готов для действия', 'успех операции после click', 'проверить overlay, disabled, animation или неправильный state', 'исправить предусловие экрана либо сценарий'],
['click проходит, readiness не достигнут', 'действие допустимо, ожидаемый факт не наблюдается', 'почему UI не получил нужное состояние', 'сверить semantic assertion и входные данные', 'уточнить UI-contract или изоляцию данных'],
['failed → passed на retry', 'две попытки имеют разные outcome', 'корневую причину и воспроизводимость', 'сравнить данные, worker boundary и evidence по попыткам', 'создать triage, не увеличивать timeout автоматически'],
['trace есть только на retry', 'есть контекст повторной попытки', 'контекст initial failure', 'подписать attempt и вопрос к артефакту', 'собрать отдельный evidence path, если он нужен'],
]),
h2('Политика retry должна сохранять сигнал'),
p('В Playwright v1.37.0 retries конфигурируются отдельно, а failed test запускается заново в новом worker. Поэтому retry — не «ещё немного подождать в той же странице». Он создаёт новую попытку с новой границей исполнения. Это удобно для независимых тестов и одновременно важно для диагностики: порядок, cookies, storage, server-side cleanup и данные могут вести себя иначе. Если тест зависит от предыдущего случая, повторный запуск иногда скрывает связь, потому что даёт более чистый контекст.'),
p('Flaky — маршрут в triage, не разрешение на merge. Временное исключение требует owner, срока, evidence policy и критерия снятия. Универсального числа retries нет: оно зависит от цены очереди, релиза и способности разобрать сигнал. Лучше оставить значение неизвестным, чем назвать его «стандартом». '),
code(illustrativeConfig),
h2('Trace помогает ставить вопрос, а не закрывать его'),
p('По документации Playwright 1.37.0 Trace Viewer показывает последовательность действий, snapshots, action log, source и network log для записанного trace. Режим <code>on-first-retry</code> связывает запись с первой повторной попыткой. Это полезно, если картинка или log помогают отличить «не тот control» от «после click не появилось нужное состояние». Но такой артефакт не подтверждает фактическое выполнение в другой попытке и не знает, какой бизнес-результат ожидался, пока тест не записал его assertion.'),
p('Пока у команды нет файла или ссылки, в карточке стоит <code>evidence: absent</code>. У артефакта сначала сверяют attempt, проект и тест, затем выбирают один вопрос: locator перед click или UI-state перед assertion. Запрос «найди причину по trace» слишком широк и поощряет первую красивую гипотезу.'),
figure('/assets/editorial/2023/e2e-stability-2023-triage-loop.svg', 'Цикл triage flaky e2e-теста: карточка фиксирует initial и retry outcome, затем отдельно проверяются selector и readiness; evidence привязывается к номеру попытки, после чего выбирается маленькая правка с owner и rollback. Если доказательств недостаточно, цикл возвращается к сбору одного недостающего факта, а не к увеличению timeout.', 'Схема показывает процедуру принятия решения. Это не trace, не отчёт CI и не измерение flake rate; в ней нет реальных тестов, браузеров, запросов или длительностей.'),
h2('Учебный fixture проверяет форму решения, а не качество теста'),
p('Чтобы не превратить текст в лозунг, sidecar содержит детерминированный fixture. Он создаёт два marked synthetic event/outcome объекта в памяти. У initial attempt selector уникален, но readiness <code>payment-state=confirmed</code> не достигнут; у retry тот же selector и readiness достигнут. Классификатор возвращает только форму <code>retry-masked-missing-readiness-contract-in-synthetic-input</code>. Он прямо маркирует, что не оценивал real flake, duration и browser compatibility.'),
code(fixtureCommand),
p('Fixture также моделирует множественный selector, расходящуюся retry-policy и evidence, где <code>actualTrace</code> перестало быть <code>not-produced</code>. Так реальный artefact нельзя подложить в учебный пример, а две попытки с разной policy нельзя выдать за сравнимый сценарий. Plan принимает только readiness change с owner, rollback, next check и condition, совпадающим с evidence; timeout или selector без evidence отклоняются. PASS проверяет процедуру, не стабильность настоящего теста.'),
h2('Маршрут: симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> Зафиксируйте failed/pass как два outcome с номерами попыток. Не склеивайте их в фразу «иногда падает» и не удаляйте исходный error из обсуждения.',
'<strong>Причина.</strong> Сформулируйте конкурирующие классы: selector, actionability, readiness, test-data isolation или внешняя зависимость. Retry сам по себе не выбирает один из них.',
'<strong>Проверка selector.</strong> Проверьте cardinality и user-facing смысл locator. Нулевой или множественный match закрывает этот шаг отдельной правкой; не ждите ready state у неопределённой цели.',
'<strong>Проверка readiness.</strong> Сравните assertion с пользовательским результатом. Spinner, network idle или disabled state замените на semantic result, согласованный с владельцем UI.',
'<strong>Проверка evidence.</strong> Привяжите trace, log или screenshot к attempt. Для <code>on-first-retry</code> в v1.37.0 evidence относится к retry, поэтому initial failure может требовать отдельного способа сохранения контекста.',
'<strong>Действие.</strong> Выберите малый diff: locator, semantic assertion, изоляция данных или policy артефактов. Timeout меняют после названного события и доказанной верхней границы.',
'<strong>Rollback и следующий шаг.</strong> До merge зафиксируйте прежнее условие, owner и критерий возврата. После следующего запуска смотрите не на один зелёный retry, а на то, что новый contract проверяет нужный пользовательский факт.',
]),
h2('Маленький diff и явный rollback лучше широкой настройки'),
p('Если причина указывает на readiness, хороший diff меняет assertion и ничего больше: он ждёт терминальный UI-state, а retry/timeouts остаются прежними. Если причина указывает на selector, diff ограничивается locator и тестом его scope. Если нарушена изоляция, изменение может быть в setup/cleanup или фикстуре данных. Эти варианты имеют разный owner и разную цену. Общий timeout объединяет их в один параметр и поэтому редко даёт объяснимый rollback.'),
p('Rollback должен быть конкретным. Для assertion — вернуть предыдущую строку теста и снова собрать evidence; для selector — восстановить прошлый locator, если новый контракт не подтвердился; для данных — отменить созданную запись через тот же интерфейс подготовки. Для policy retry — вернуть прошлое значение отдельным config diff и указать, какие тесты это затронет. Нельзя считать rollback успешным, пока команда не понимает, какой слой откатывает. Учебная функция <code>rollbackSyntheticReadinessPlan()</code> специально ничего не применяет и возвращает это явным полем.'),
h2('Ограничения и следующий проверяемый шаг'),
p('Материал не содержит реального trace, video, браузерного запуска, CI-лога, оценки flake rate, duration или данных пользователя. Он не утверждает совместимость Playwright v1.37.0 с вашим окружением; version pin не заменяет проверку пакета и project config. Правило переносят только после review данных и рисков контура.'),
p('Следующий шаг — взять один flaky из отчёта и за 15 минут заполнить карточку: selector, readiness, initial/retry, evidence, owner/rollback. Если хотя бы одно поле неизвестно, зафиксируйте его как вопрос, а не как предположение. После этого сделайте один малый diff или соберите один недостающий артефакт. Так устойчивость тестов строится не на ожидании удачного повтора, а на проверяемом контракте пользовательского сценария.'),
], [sources.release, sources.actionability, sources.retries, sources.trace, sources.bestPractices]);
export const revisions = Object.freeze([practice, mechanism, field]);
function printFixtureResult() {
const fixture = runE2eStabilityFixture();
const failed = Object.entries(fixture.assertions).filter(([, passed]) => !passed).map(([name]) => name);
if (failed.length > 0) {
console.error('FAIL fixture: ' + failed.join(', '));
process.exitCode = 1;
return;
}
console.log('PASS fixture: ' + Object.keys(fixture.assertions).length + '/' + Object.keys(fixture.assertions).length + ' assertions');
}
if (process.argv.includes('--verify-fixture')) {
printFixtureResult();
} else if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions));
}