function escapeHtml(value) { return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); } const p = (value) => '

' + value + '

'; const h2 = (value) => '

' + value + '

'; const code = (value) => '
' + escapeHtml(Array.isArray(value) ? value.join('\n') : value) + '
'; const figure = (src, alt, caption) => '
' + alt + '
' + caption + '
'; const ol = (items) => '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; const table = (caption, headers, rows) => '
' + headers.map((item) => '').join('') + '' + rows.map((row) => '' + row.map((item) => '').join('') + '').join('') + '
' + caption + '
' + item + '
' + item + '
'; const sources = (items) => ''; function plainText(html) { return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); } function bodyText(html) { return plainText(html.replace(/

Проверяемые источники<\/h2>[\s\S]*?(?=

|$)/, '')); } function revision(meta, chunks, referenceList) { const contentHtml = chunks.join('\n') + '\n' + h2('Проверяемые источники') + sources(referenceList); const length = bodyText(contentHtml).length; if (length < 5000 || length > 15000) throw new Error(meta.slug + ': основной текст вне диапазона: ' + length); return { ...meta, contentHtml, proseLength: length }; } const playwrightAssertions = { title: 'Playwright: test assertions, commit af0d2936 (7 октября 2022)', url: 'https://github.com/microsoft/playwright/blob/af0d2936ac6e8780214c251ff58a5e7d6eba41ca/docs/src/test-assertions-js.md', note: 'неизменяемый исходный документ версии v1.27.0, опубликованной 7 октября 2022 года. Описывает retrying assertion до условия или timeout; статья не запускает Playwright.', }; const webdriverDraft = { title: 'W3C WebDriver, Working Draft 25 октября 2022 года', url: 'https://www.w3.org/TR/2022/WD-webdriver2-20221025/', note: 'датированный Working Draft, доступный в октябре 2022 года. Он задаёт remote-control interface, но не определяет смысл прикладного состояния страницы.', }; const wcag = { title: 'W3C WCAG 2.1, Recommendation 5 июня 2018 года', url: 'https://www.w3.org/TR/2018/REC-WCAG21-20180605/', note: 'нормативная Recommendation, доступная к октябрю 2022 года. Используется только для границы: видимое состояние нуждается в доступной семантике, а не в одном визуальном эффекте.', }; const commonSources = [playwrightAssertions, webdriverDraft, wcag]; /** * Учебная in-memory модель пользовательского сценария. * Здесь нет DOM, Playwright, WebDriver session, браузера, таймеров, сети, * selector, screenshot, trace или статистики. Все события создаёт fixture. */ function createUiScenario() { return { boundary: Object.freeze({ kind: 'teaching-observable-ui-contract-v1', dom: 'not-created', browser: 'not-run', timer: 'not-created', playwright: 'not-run', webdriver: 'not-connected' }), state: { phase: 'editing', text: '', requestId: null, nextRequest: 1, savedText: null, error: null, transitionLog: [] }, }; } function snapshot(model) { return Object.freeze({ ...model.state, transitionLog: model.state.transitionLog.map((item) => ({ ...item })) }); } function visible(model) { const state = model.state; if (state.phase === 'editing') return Object.freeze({ role: 'status', name: state.text.trim() ? 'Черновик готов к отправке' : 'Нужно заполнить поле', testState: 'editing' }); if (state.phase === 'submitting') return Object.freeze({ role: 'status', name: 'Отправка ожидает подтверждения', testState: 'submitting', requestId: state.requestId }); if (state.phase === 'saved') return Object.freeze({ role: 'status', name: 'Изменение сохранено', testState: 'saved', requestId: state.requestId }); return Object.freeze({ role: 'alert', name: 'Сценарий требует восстановления', testState: 'recovery-required', requestId: state.requestId }); } function record(model, kind) { model.state.transitionLog.push({ step: model.state.transitionLog.length + 1, kind, visible: visible(model).testState }); } function typeText(model, value) { if (model.state.phase === 'submitting') return Object.freeze({ kind: 'editing-blocked-while-submitting', changed: false }); model.state.text = String(value); model.state.phase = 'editing'; model.state.error = null; record(model, 'typed'); return Object.freeze({ kind: 'text-written', changed: true, observation: visible(model) }); } function submit(model) { if (model.state.phase === 'submitting') return Object.freeze({ kind: 'submit-guard-active', changed: false, requestId: model.state.requestId }); if (!model.state.text.trim()) { model.state.error = 'empty-text'; record(model, 'submit-blocked-empty'); return Object.freeze({ kind: 'submit-blocked-empty', changed: false, observation: visible(model) }); } model.state.phase = 'submitting'; model.state.requestId = 'request-' + String(model.state.nextRequest++).padStart(2, '0'); record(model, 'submit-planned'); return Object.freeze({ kind: 'submit-planned', changed: true, requestId: model.state.requestId, transport: 'not-performed', observation: visible(model) }); } function acknowledge(model, payload) { if (model.state.phase !== 'submitting' || payload?.requestId !== model.state.requestId || payload?.outcome !== 'accepted') return Object.freeze({ kind: 'stale-or-invalid-acknowledgement', changed: false }); model.state.phase = 'saved'; model.state.savedText = model.state.text; model.state.error = null; record(model, 'acknowledged'); return Object.freeze({ kind: 'acknowledged', changed: true, observation: visible(model) }); } function fail(model, requestId) { if (model.state.phase !== 'submitting' || requestId !== model.state.requestId) return Object.freeze({ kind: 'stale-or-invalid-failure', changed: false }); model.state.phase = 'recovery-required'; model.state.error = 'no-teaching-acknowledgement'; record(model, 'acknowledgement-missing'); return Object.freeze({ kind: 'recovery-required', changed: true, observation: visible(model) }); } function rollback(model, previous) { const nextRequest = model.state.nextRequest; model.state = { ...previous, nextRequest, transitionLog: previous.transitionLog.map((item) => ({ ...item })) }; return Object.freeze({ kind: 'rollback-to-snapshot', changed: true, observation: visible(model) }); } export function runUiTestsFixture() { const model = createUiScenario(); const typed = typeText(model, 'Согласовать условия'); const beforeSubmit = snapshot(model); const planned = submit(model); const guarded = submit(model); const staleAck = acknowledge(model, { requestId: 'request-00', outcome: 'accepted' }); const missing = fail(model, planned.requestId); const recoverySnapshot = snapshot(model); const restored = rollback(model, beforeSubmit); const restoredSnapshot = snapshot(model); const secondPlan = submit(model); const lateFirstAck = acknowledge(model, { requestId: planned.requestId, outcome: 'accepted' }); const accepted = acknowledge(model, { requestId: secondPlan.requestId, outcome: 'accepted' }); const duplicate = acknowledge(model, { requestId: secondPlan.requestId, outcome: 'accepted' }); return Object.freeze({ boundary: model.boundary, evidence: { typed, planned, guarded, staleAck, missing, recoverySnapshot, restored, restoredSnapshot, secondPlan, lateFirstAck, accepted, duplicate }, assertions: Object.freeze({ noBrowser: model.boundary.browser === 'not-run', noTimer: model.boundary.timer === 'not-created', typedVisible: typed.observation.testState === 'editing', submitNamesObservedState: planned.observation.name === 'Отправка ожидает подтверждения', duplicateSubmitBlocked: guarded.kind === 'submit-guard-active', staleAckIgnored: staleAck.changed === false, missingAckRequiresRecovery: missing.observation.testState === 'recovery-required', rollbackRestoresEditing: restored.observation.testState === 'editing', rollbackPreservesDraft: restoredSnapshot.text === 'Согласовать условия', rollbackDoesNotInventSuccess: restoredSnapshot.savedText === null && restoredSnapshot.requestId === null, requestSequenceDoesNotRollback: secondPlan.requestId === 'request-02', lateFirstAckIgnored: lateFirstAck.changed === false, acceptedStateNamed: accepted.observation.testState === 'saved', duplicateAckIgnored: duplicate.changed === false, }) }); } const fixtureExample = [ "import { runUiTestsFixture } from './upgrade-2022-10.mjs';", 'const report = runUiTestsFixture();', 'if (!Object.values(report.assertions).every(Boolean)) throw new Error(\'fixture contract failed\');', "console.log(report.evidence.planned.observation.testState); // 'submitting'", "console.log(report.evidence.accepted.observation.name); // 'Изменение сохранено'", ]; const practice = revision({ slug: 'editorial-2022-10-practice-ui-tests', title: 'Проверки пользовательского сценария: ждать состояние, а не секунду', categories: ['Frontend', 'Тестирование'], cover: '/assets/editorial/2022/ui-tests-2022-state-contract.svg', excerpt: 'Практический контракт сценария: проверка ждёт наблюдаемое состояние, а не произвольную паузу.', readingMinutes: 12 }, [ p('Проблема E2E-проверки, которая после клика ждёт произвольную секунду, в том, что она проверяет не пользовательский сценарий, а удачное совпадение времени. На быстрой машине она может пройти раньше, на занятой — упасть позже, а при реальном дефекте просто задержать сигнал. Цена такого теста не только во флаке: команда перестаёт понимать, что именно должно быть готово после действия.'), p('Нужна короткая замена: тест ждёт не время, а наблюдаемое состояние с понятным владельцем. В этой статье состояние — учебный contract: `editing`, `submitting`, `saved`, `recovery-required`. Fixture не открывает страницу и не запускает Playwright. Она нужна, чтобы отдельно проверить переходы и rollback, прежде чем выбирать locator и писать настоящий browser-тест.'), h2('Секунда не является условием готовности'), p('Фраза `wait(1000)` отвечает только на вопрос «прошло ли 1000 миллисекунд». Она не говорит, обработан ли клик, заблокирована ли повторная отправка, принят ли ответ и виден ли пользователю итог. Поэтому после такой паузы assertion часто привязан к косвенному признаку: исчезла кнопка, появился class или закончилась анимация. Это слабая связь с задачей пользователя — отправить изменение и получить объяснимый результат.'), table('Что именно ждёт проверка', ['Подход', 'Наблюдение', 'Проблема', 'Замена'], [ ['Произвольная пауза', 'истёк отрезок времени', 'не доказывает переход', 'ожидать status с именем состояния'], ['Ожидание selector без смысла', 'элемент найден', 'элемент может быть старым или скрытым', 'проверить role, имя и test state'], ['Сразу после click', 'handler вернулся', 'подтверждение ещё не принято', 'сначала увидеть `submitting`, затем `saved`'], ['Один happy path', 'сохранение прошло', 'нет границы на stale reply и double submit', 'проверить guard, failure и rollback'], ]), h2('Сформулируйте контракт страницы'), p('Контракт начинается не с automation API, а с вопроса: что пользователь может увидеть и что означает каждая надпись. После заполнения поля модель возвращает status «Черновик готов к отправке». После submit — «Отправка ожидает подтверждения». Только acknowledgement с ожидаемым request id переводит сценарий в «Изменение сохранено». Если подтверждения модели нет, экран честно показывает alert «Сценарий требует восстановления». У каждого текста есть role и testState; это не CSS-деталь.'), p('Такое имя состояния полезно и без автоматизации. Product reviewer может спросить, допустима ли повторная отправка во время `submitting`; разработчик — где хранится request id; тестировщик — какой факт он ждёт. Когда ответ один, проверка не угадывает внутреннюю задержку. Она ждёт public contract. В реальном интерфейсе можно выбрать другие слова и роли, но менять их следует вместе с тестом и требованиями доступности.'), figure('/assets/editorial/2022/ui-tests-2022-state-contract.svg', 'Схема учебного сценария: editing после ввода текста переходит в submitting после submit; acknowledgement с ожидаемым request id ведёт в saved, отсутствие acknowledgement — в recovery-required; повторный submit во время submitting заблокирован.', 'Рисунок показывает переходы локальной модели, а не браузерный trace или реальный прогон теста.'), h2('Проверяемый пример без браузера'), p('В fixture создаются только объекты JavaScript. `submit` намеренно возвращает `transport: not-performed`: так код не делает вид, что отправил HTTP-запрос. Assertion проверяет, что submit назвал наблюдаемое состояние, duplicate submit остановлен, stale acknowledgement не меняет state, а правильное acknowledgement даёт `saved`. Запуск ниже проверяет только эти правила.'), code(fixtureExample), p('Этот пример не является рецептом locator-ов. Его ценность в более узком свойстве: transition нельзя признать успешным до acknowledgement, а отмена или ошибка не должны незаметно превратиться в `saved`. Когда такое свойство есть у reducer или state machine продукта, рядом с ним можно написать unit test. E2E затем проверит, что реальная страница действительно показывает тот же contract.'), h2('Маршрут: симптом → причина → проверка → действие'), ol([ 'Симптом. Найдите test с паузой после user action или с retry вокруг всего теста.', 'Причина. Назовите отсутствующий факт: submit принят, кнопка заблокирована, результат признан или ошибка объяснена.', 'Проверка contract. Выполните `node web/scripts/upgrade-2022-10.mjs --verify-fixture`. PASS доказывает только локальные assertions.', 'Действие. Добавьте в страницу один наблюдаемый status/alert с семантическим именем и стабильным смыслом.', 'Проверка реализации. В отдельном browser-тесте дождитесь этого состояния штатным assertion API; не переносите fixture как «реальный run».', 'Rollback. Если новый contract мешает сценариям, верните прежний state reducer и тест вместе; не оставляйте временную секунду как запасной оракул.', ]), h2('Граница Playwright и WebDriver'), p('Зафиксированная документация Playwright v1.27.0 описывает assertions, которые повторно проверяют условие до успеха или timeout. Это подтверждает идею ждать condition, но не решает, какое condition правильное для конкретной формы. Датированный Working Draft WebDriver описывает remote-control interface, не бизнес-смысл экрана. Следовательно, ни инструмент, ни протокол не могут назвать за команду «сохранено».'), p('WCAG 2.1 здесь важен другой причиной: видимая зелёная иконка не обязана быть достаточным сообщением состояния. Статья не проводит accessibility-аудит, но требует назвать role и доступное имя до выбора тестового selector. Иначе тест становится единственным потребителем скрытого `data-*` атрибута, а пользователь по-прежнему не получает ясного результата.'), h2('Ограничение и следующий шаг'), p('Модель не создаёт DOM, не измеряет timeout, не проверяет aria-дерево, не использует сеть, а её request id выдуман. Она не показывает частоту флаков и не доказывает, что любой `status` корректен для любой операции. Для реальной формы нужны отдельные решения о transport, retry, error copy и доступности. Учебная модель специально не маскирует эти решения.'), p('Следующий шаг — выбрать одну нестабильную проверку и выписать четыре состояния вокруг её клика. Для каждого состояния укажите видимый текст, owner transition и условие выхода. Затем удалите только одну паузу, заменив её assertion на это условие. Сравните изменение на обычном CI, но не объявляйте эффект без сохранённого отчёта и условий прогона.'), h2('Историческая граница октября 2022'), p('Использованы immutable commit документации Playwright от 7 октября 2022 года, датированный WebDriver Working Draft от 25 октября 2022 года и WCAG 2.1 Recommendation. Источники задают исторический контекст; всё поведение в примере — локальная модель, не утверждение о конкретной версии браузера или продукта.'), ], commonSources); const mechanism = revision({ slug: 'editorial-2022-10-mechanism-ui-tests', title: 'Механика UI-проверки: сделать состояние наблюдаемым', categories: ['Frontend', 'Тестирование'], cover: '/assets/editorial/2022/ui-tests-2022-observation-route.svg', excerpt: 'Как отделить transition, наблюдение и assertion, чтобы не лечить флак произвольным ожиданием.', readingMinutes: 13 }, [ p('Проблема теста, который ждёт секунду потому, что после клика ему нечего наблюдать, обычно скрыта в самой странице. Внутри меняется несколько флагов, затем кто-то закрывает модалку, а итог пользователю виден только случайно. Цена — флак и дорогая диагностика: лог показывает click, но не показывает, какой transition не состоялся и почему.'), p('Механика ниже строит один узкий мост: domain transition создаёт named visible state, а assertion проверяет именно его. Это не настоящий Playwright run и не реализация WebDriver. Локальная fixture специально не содержит таймеров, selector-ов, DOM или HTTP, чтобы можно было проверить reason перехода и rollback без истории конкретного браузера.'), h2('Три слоя, которые нельзя склеивать'), p('Первый слой — intent: пользователь ввёл текст или нажал submit. Второй — transition: state machine решила, что переход допустим, и записала request id. Третий — observation: экран получил status либо alert с доступным именем. Когда тест сразу читает внутренний флаг второго слоя, он перестаёт проверять пользовательский результат. Когда он ждёт только время, он не проверяет ни один слой. Нужен явный третий слой, связанный с переходом.'), table('Разделение ответственности', ['Слой', 'Хранит', 'Не должен утверждать', 'Проверка'], [ ['Intent', 'ввод и click', 'что сохранение принято', 'событие доступно пользователю'], ['Transition', 'phase и request id', 'что браузер уже отрисовал результат', 'guard и matching acknowledgement'], ['Observation', 'role, name, testState', 'что сеть работала', 'assertion пользовательского сценария'], ['Транспорт', 'запрос и reply', 'смысл UI без mapping', 'отдельный API-contract'], ]), h2('Почему request id входит в учебную модель'), p('Если confirmation приходит после submit, модели недостаточно boolean `isLoading`. Ей нужно знать, какую попытку она ждёт. Первый `submit` создаёт `request-01`; acknowledgement с `request-00` отвергается. После rollback счётчик не возвращается назад: повторный submit получает `request-02`, а запоздалый reply от первой попытки остаётся чужим. Это не утверждение о формате production ID и не требование передавать именно такое поле по сети. Это способ записать invariант: старый reply не может признать текущий transition.'), p('Дальше работает guard. Повторный submit во время `submitting` возвращает `submit-guard-active` и не создаёт второй request id. Такой исход важен именно как наблюдаемая граница: кнопка в реальном UI может стать disabled, но смысл должен жить не только в button. Иначе пользовательский сценарий будет зависеть от timing отрисовки, а компонент-автоматизация найдёт обходной путь.'), h2('Пример: local fixture проверяет owner перехода'), p('Ниже нет `page.click`, `locator` или sleep. Вызовы создают намерение, планируют transition и вручную передают acknowledgement. Проверка не измеряет задержку: она смотрит на shape evidence и на `testState`. Если заменить order событий, stale acknowledgement не сдвигает сценарий; если сделать rollback, состояние возвращается к snapshot. Это удобный first gate перед настоящим UI.'), code(fixtureExample), p('После failure fixture возвращает pre-submit snapshot: `editing` с черновиком, без request id и без сохранённого результата. При этом счётчик запросов не откатывается. Поэтому повторный submit получает новый id, а поздний `request-01` не может подтвердить вторую попытку. Это не метрика надёжности и не готовая политика production rollback. Это узкая защита от модели, которая после возврата случайно принимает старый reply за текущий успех.'), figure('/assets/editorial/2022/ui-tests-2022-observation-route.svg', 'Схема разделения UI-проверки: intent submit создаёт transition submitting с request id; accessible status становится наблюдением; acknowledgement с совпадающим id переводит в saved; stale reply и повторный submit не меняют переход; recovery выводится отдельным alert.', 'Рисунок отделяет модель переходов от инструмента автоматизации и не показывает настоящий DOM или WebDriver-сеанс.'), h2('Как писать browser assertion после контракта'), p('После модели assertion должен выражать пользовательский факт, например: после действия есть status с именем «Отправка ожидает подтверждения», а после контролируемого ответа — status «Изменение сохранено». В Playwright-документации v1.27.0 retrying assertion повторяет проверку выбранного условия до timeout. Поэтому timeout — ограничитель ожидания условия, не само условие. Если condition выбран как «кнопка где-то исчезла», инструмент добросовестно ждёт неправильный факт.'), p('Стабильный selector допустим, если он не подменяет семантику. Для чисто технического контейнера можно применять `data-testid`; для результата сценария лучше сначала искать роль и имя, которые уже нужны человеку. Это не абсолютное правило: составной виджет, локализация и визуально скрытый status потребуют отдельного решения. Важно зафиксировать, какой contract должен пережить рефакторинг, а какой selector является внутренней деталью.'), h2('Маршрут: симптом → причина → проверка → действие'), ol([ 'Симптом. В отчёте есть timeout либо sleep между click и проверкой, но не видно нужного состояния.', 'Причина. Intent, transition и observation склеены: тест знает click, но не знает owner результата.', 'Проверка. Найдите phase, request id и текст/role результата. Если одного нет, тесту нечего ждать корректно.', 'Действие. Добавьте named status для pending/saved и alert для recovery; свяжите их с transition в одном месте.', 'Проверка guard. Локально подтвердите duplicate submit и stale reply. Fixture не заменяет browser тест.', 'Rollback. Откатывайте весь новый contract вместе с тестом, если доступное состояние расходится с утверждённой UX-копией; не возвращайте sleep как постоянный патч.', ]), h2('Ошибки внедрения'), p('Первая ошибка — назвать все состояния `loading`. Пользователь не отличит ожидание подтверждения от повторной попытки, а тест не отличит новый запрос от старого reply. Вторая — очищать форму сразу после click. Тогда rollback нечего восстанавливать. Третья — делать success индикатор побочным эффектом network callback без проверки request id. В такой схеме поздний callback способен сообщить о старой операции поверх нового редактирования.'), p('Четвёртая ошибка — добавить `aria-live` или role только ради теста. WCAG не даёт универсальный шаблон для всех сообщений, поэтому роль должна соответствовать срочности и поведению интерфейса. Если команда ещё не выбрала доступную подачу результата, сначала нужно решить UX, а затем автоматизацию. Тест не должен легализовать сообщение, которое пользователю непонятно или чрезмерно навязчиво.'), h2('Ограничение и следующий шаг'), p('Учебная модель не знает re-render, framework scheduler, локализацию, авторизацию, несколько вкладок и фактическую доставку reply. Она не говорит, что любой stale reply следует молча отбросить: production может потребовать журнал, server reconciliation или повторное чтение данных. Фиксированная строка request id и ручной acknowledgement существуют только для детерминированной fixture.'), p('Следующий шаг — взять один настоящий тест и нарисовать его timeline без времени: intent, pending observation, controlled response, final observation. Отдельно обозначьте fail path. Если на этой схеме нет наблюдаемого условия после click, сначала добавьте contract страницы. Только потом заменяйте sleep на auto-retrying assertion выбранного инструмента.'), h2('Историческая граница октября 2022'), p('Техническая граница построена на immutable Playwright documentation commit v1.27.0, WebDriver Working Draft от 25 октября 2022 года и WCAG 2.1 Recommendation. Ни один из документов не является доказательством того, что локальная fixture открывала страницу или что конкретный UI обладает заявленной доступностью.'), ], commonSources); const field = revision({ slug: 'editorial-2022-10-field-ui-tests', title: 'Полевой маршрут UI-проверки: заменить sleep без потери сигнала', categories: ['Frontend', 'Тестирование'], cover: '/assets/editorial/2022/ui-tests-2022-diagnosis-rollback.svg', excerpt: 'Диагностика нестабильного сценария: от произвольной паузы к проверяемому состоянию, границе и rollback.', readingMinutes: 13 }, [ p('Когда UI-тест флакует, команда часто увеличивает ожидание и получает более медленный флак. Проблема не в том, что секунда «слишком короткая»: секунды не описывают момент, когда пользовательский сценарий готов. Цена — ложная уверенность в зелёном CI и потеря времени при следующем timeout, потому что исходное наблюдение так и не было сформулировано.'), p('Ниже — полевой маршрут без обещаний о чужом продукте. Он использует локальную модель с явными состояниями и отдельный rollback. Это не собранный trace, не прогон Playwright и не статистика флаков. Его назначение скромнее: разобрать один тест так, чтобы reviewer мог проверить связь между действием, наблюдением и условием завершения.'), h2('Соберите симптом без ярлыка «медленно»'), p('Возьмите падение и выпишите буквально: какое действие выполнено, какая текущая проверка стоит после него, какой элемент или текст она читает, какая пауза есть в коде. Не добавляйте объяснение «бекенд медленный», пока нет evidence. Часто оказывается, что assertion ждёт визуальную деталь, а не результат; либо submit можно выполнить дважды; либо ошибка спрятана за общим spinner. Эти ситуации отличаются исправлением, хотя все выглядят как timeout.'), table('Карта диагностики одного timeout', ['Наблюдение', 'Вероятная дырка contract', 'Локальная проверка', 'Первое изменение'], [ ['sleep после submit', 'нет именованного pending state', 'fixture: submit даёт submitting', 'добавить status ожидания'], ['сразу ждём success', 'пропущено промежуточное владение', 'fixture: request id существует до ack', 'проверить pending до финала'], ['два click дают два эффекта', 'нет guard', 'fixture: второй submit blocked', 'запретить transition в submitting'], ['поздний ответ закрывает новую форму', 'нет correlation', 'fixture: stale ack ignored', 'связать ack с request id'], ['после ошибки нечего повторить', 'нет recovery state', 'fixture: missing ack даёт alert', 'сохранить текст и назвать действие'], ]), h2('Разбор по одному сценарию'), p('Представим форму с текстовым полем. После click старый тест спит и затем ищет успех. Новый разбор вводит текст, ожидает status `submitting`, подаёт контролируемое acknowledgement и только после него ждёт `saved`. Это не означает, что browser-тест обязан подделывать любой API через публичный UI: способ контроля ответа зависит от архитектуры стенда. Важно другое: контроль должен быть явным, а наблюдение — тем, которое обещает страница.'), p('Если confirmation не приходит, contract не показывает success «на всякий случай». Он переходит в `recovery-required` и сохраняет text. В реальном продукте причина может быть server error, отмена, сеть или нарушение протокола. Учебная модель не выбирает между ними. Она отказывается от ложного вывода и требует отдельного понятного пути: повторить по согласованной политике, проверить результат или вернуться к редактированию.'), figure('/assets/editorial/2022/ui-tests-2022-diagnosis-rollback.svg', 'Диагностический маршрут: от sleep после submit к записи наблюдаемого status; затем проверка request id, ветка acknowledgement в saved и ветка отсутствия acknowledgement в recovery-required; rollback возвращает editing и сохраняет текст.', 'Это карта решения для ревью теста. Она не показывает длительности, browser trace или фактические причины timeout.'), h2('Выполните fixture как проверку модели'), p('CLI ниже не обращается к приложению. Он проверяет четырнадцать assertions локального объекта: отсутствие браузера и таймера, именованный pending state, guard повторного submit, отклонение stale acknowledgement, recovery, сохранение черновика при rollback, новую последовательность request id и корректное final acknowledgement. Поэтому PASS нельзя прикладывать как доказательство прохождения e2e. Его можно использовать как воспроизводимый ответ на вопрос «что именно наша маленькая модель гарантирует».'), code(['node web/scripts/upgrade-2022-10.mjs --verify-fixture', '', '# ожидаемый смысл PASS:', '# локальная модель различает submitting, saved и recovery-required', '# а не то, что страница, сеть или Playwright были запущены']), p('После fixture полезен ручной review кода настоящей страницы. Найдите, где создан request id, кто запрещает второй submit, где появляется pending status, что маппит transport outcome в UI и кто очищает текст. Запишите эти пять мест в PR. Если ответ «это делает useEffect где-то ниже», тест пока остаётся хрупким: будущий рефакторинг может вернуть секунду, не меняя бизнес-логику.'), h2('Порядок внедрения в реальный тест'), ol([ 'Зафиксируйте старый сигнал. Сохраните текст падения и текущий assertion; не начинайте с увеличения timeout.', 'Назовите наблюдаемое состояние. Выберите pending, success и recovery с понятными пользователю role/name.', 'Добавьте unit-level transition checks. Проверяйте guard, correlation и rollback там, где живёт state, а не через sleep.', 'Настройте контролируемый вход. В тестовом окружении выберите разрешённый способ получить success/failure; задокументируйте его отдельно.', 'Поставьте assertion на condition. Инструмент может retry condition до timeout, но condition должен быть именованным state, а не прошедшим временем.', 'Откатите честно. Если новый contract ломает согласованный UX, удалите и UI-state, и новый assertion одним изменением. Не оставляйте data-testid без владельца.', ]), h2('Граница технического evidence'), p('Документация Playwright v1.27.0 подтверждает наличие retrying assertions, но не говорит, как устроен ваш state reducer. WebDriver Working Draft задаёт remote-control protocol, но не оценивает полезность текста «сохранено». WCAG 2.1 — не сертификат доступности этой страницы. Эти источники помогают поставить границы и не подменять одну область другой.'), p('Чтобы утверждать, что реальный флак исчез, нужны новые evidence: commit, выбранное окружение, версия браузера и раннера, результаты повторяемых запусков, известные исключения. В этой статье их нет. Поэтому корректная формулировка результата после внедрения будет узкой: «тест теперь ждёт конкретный доступный state; условия прогона приложены отдельно». Это лучше, чем красивое, но непроверяемое «стало стабильно».'), h2('Типичные неверные исправления'), p('Не заменяйте `wait(1000)` на `wait(5000)`: вы меняете стоимость, а не сигнал. Не ловите любой текст «Успех»: он может остаться от предыдущего шага. Не делайте assertion на private loading flag, если пользователь не может увидеть связанный результат. Не отправляйте второй request автоматически без domain policy. И не скрывайте error, чтобы тест увидел только счастливую ветку: именно error и recovery показывают, что condition действительно связан с переходом.'), p('Отдельно опасен фальшивый rollback. Если тест очищает всё окружение, но UI-state откатывается не так, как продукт, проверка может проходить на чистом листе. В учебной fixture rollback делает простую вещь: возвращает snapshot переходов до submit. В продукте восстановление может потребовать router, store, cache и server state. Их надо тестировать отдельно, не расширяя значение маленького unit fixture.'), h2('Ограничение и следующий шаг'), p('Модель не включает реальный DOM, framework, test runner, API, timeouts, локализацию или разрешения. Роль `status` и `alert` в ней — контрактные labels, а не готовая accessibility-реализация. request id и acknowledgement — ручные values. Никакой подсчёт флаков, длительности или production impact здесь не сделан.'), p('Следующий шаг — провести один PR через эту карту: удалить одну бессодержательную паузу, добавить одно наблюдаемое состояние, покрыть guard и stale reply unit-level тестом, затем сделать один browser assertion. В описании PR оставьте границы модели и ссылку на реальный отчёт, если он запускался. Так изменение остаётся маленьким, проверяемым и обратимым.'), h2('Историческая граница октября 2022'), p('В статье используются только проверяемые источники, доступные к октябрю 2022 года: Playwright v1.27.0 documentation commit от 7 октября, WebDriver Working Draft от 25 октября и WCAG 2.1 Recommendation. Они не подменяют проверку конкретной страницы, а fixture не является browser run.'), ], commonSources); export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item); function verifyFixture() { const report = runUiTestsFixture(); const failed = Object.entries(report.assertions).filter(([, value]) => value !== true).map(([key]) => key); if (failed.length) { process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n'); process.exitCode = 1; return; } process.stdout.write('PASS fixture: ' + Object.keys(report.assertions).length + '/' + Object.keys(report.assertions).length + ' assertions\n'); } if (process.argv.includes('--verify-fixture')) verifyFixture(); if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');