ИИ-агент добавил проверку промокода и сообщил, что форма работает. Примем правку через Playwright MCP: проверим неверный код, восстановление после отказа и пересчёт цены. Намеренно оставим в исходной странице ошибку, которую приёмка обязана обнаружить.

MCP — протокол контекста модели: через подключённый сервер клиент агента получает инструменты работы с браузером. Playwright MCP даёт агенту возможность открывать страницу, читать её структуру и выполнять действия. Решение о приёмке всё равно опирается на заранее заданные условия и наблюдаемые результаты.

Нужны Node.js 24 или новее, основы HTML и JavaScript, а также клиент агента с уже подключённым Playwright MCP. Подключайте сервер по настройкам своего клиента; здесь используем готовое соединение. Отдельный материал о подключении MCP к локальному серверу поможет подготовить этот этап.

Поднимите стенд, который можно проверить без аккаунта

Создайте папку promo-acceptance. Пакеты для самого стенда не нужны: страницу и API обслужит встроенный HTTP-сервер Node.js. Полная стоимость заказа — 1000 учебных рублей. При STUDY20 сервер вернёт 800, а для другого непустого кода — ответ об отказе, после которого человек должен иметь возможность исправить ввод.

Страница не принимает оплату и не сохраняет заказы. Цель — проверить восстановление интерфейса после неуспешного запроса.

В index.html поместите форму. Ссылки подписи, подсказки и сообщения намеренно заданы явно: их можно проверить по структуре страницы.

<!doctype html>
<html lang="ru">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Промокод учебного заказа</title>
  <style>
    body { font: 1.1rem/1.5 system-ui; margin: 2rem; }
    main { max-width: 34rem; }
    input, button { font: inherit; padding: .5rem; }
    label, input, button { display: block; }
    button { margin-block-start: 1rem; }
    :focus-visible { outline: 3px solid #164bc0; outline-offset: 3px; }
    [aria-invalid="true"] { border: 2px solid #a00020; }
  </style>
</head>
<body>
  <main>
    <h1>Учебный заказ</h1>
    <p>Стоимость: <output id="total">1000</output> ₽</p>
    <form novalidate>
      <label for="promo">Промокод</label>
      <input id="promo" name="promo" required maxlength="16"
        aria-describedby="promo-hint" autocomplete="off">
      <p id="promo-hint">Введите код из учебного задания</p>
      <p id="promo-error"></p>
      <div id="problem" role="alert"></div>
      <p id="status" role="status"></p>
      <button>Применить промокод</button>
    </form>
  </main>
  <script type="module" src="/app.js"></script>
</body>
</html>

Создайте app.js. Это исходная версия с воспроизводимым дефектом: кнопка возвращается в доступное состояние только при успехе. После 422 она останется выключенной. В обычном проходе с верным кодом дефект незаметен.

const form = document.querySelector('form');
const promo = document.querySelector('#promo');
const fieldError = document.querySelector('#promo-error');
const problem = document.querySelector('#problem');
const status = document.querySelector('#status');
const total = document.querySelector('#total');
const button = document.querySelector('button');
let pending = false;

form.addEventListener('submit', async event => {
  event.preventDefault();
  if (pending) return;
  fieldError.textContent = '';
  problem.textContent = '';
  status.textContent = '';
  promo.removeAttribute('aria-invalid');
  promo.setAttribute('aria-describedby', 'promo-hint');
  if (!promo.value.trim()) {
    fieldError.textContent = 'Введите промокод';
    promo.setAttribute('aria-invalid', 'true');
    promo.setAttribute('aria-describedby', 'promo-hint promo-error');
    promo.focus();
    return;
  }
  pending = true;
  button.disabled = true;
  status.textContent = 'Проверяем код…';
  try {
    const response = await fetch('/api/promo', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ code: promo.value.trim() })
    });
    const data = await response.json();
    if (!response.ok) throw new Error('Код не подошёл. Проверьте его и повторите');
    total.value = String(data.total);
    status.textContent = 'Промокод применён. Стоимость 800 рублей';
    pending = false;
    button.disabled = false;
  } catch {
    status.textContent = '';
    problem.textContent = 'Код не подошёл. Проверьте его и повторите';
  }
});

Добавьте server.mjs. Сервер сам определяет стоимость: число из интерфейса не считается доверенным итогом. Ограничения HTML можно обойти прямым запросом, поэтому сервер проверяет вход независимо от required и maxlength в форме.

import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const pages = {
  '/': ['index.html', 'text/html; charset=utf-8'],
  '/app.js': ['app.js', 'text/javascript; charset=utf-8']
};
function json(res, code, body) {
  res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
  res.end(JSON.stringify(body));
}
createServer(async (req, res) => {
  const path = new URL(req.url, 'http://127.0.0.1:4180').pathname;
  if (req.method === 'GET' && pages[path]) {
    const [file, type] = pages[path];
    res.writeHead(200, { 'Content-Type': type });
    return res.end(await readFile(new URL(file, import.meta.url)));
  }
  if (req.method !== 'POST' || path !== '/api/promo') {
    return json(res, 404, { message: 'Маршрут не найден' });
  }
  if (!req.headers['content-type']?.startsWith('application/json')) {
    return json(res, 415, { message: 'Ожидается JSON' });
  }
  try {
    const chunks = [];
    let size = 0;
    for await (const chunk of req) {
      size += chunk.length;
      if (size > 2048) return json(res, 413, { message: 'Большой запрос' });
      chunks.push(chunk);
    }
    const body = Buffer.concat(chunks).toString('utf8');
    let data;
    try { data = JSON.parse(body); }
    catch { return json(res, 400, { message: 'Повреждён JSON' }); }
    if (typeof data?.code !== 'string' || !data.code.trim() || data.code.length > 16) {
      return json(res, 422, { message: 'Проверьте формат кода' });
    }
    if (data.code !== 'STUDY20') return json(res, 422, { message: 'Код не найден' });
    json(res, 200, { total: 800 });
  } catch {
    if (!res.writableEnded) json(res, 500, { message: 'Сбой сервера' });
  }
}).listen(4180, '127.0.0.1', () => {
  console.log('Стенд: http://127.0.0.1:4180');
});

Сервер складывает исходные байтовые фрагменты в chunks и один раз декодирует их после Buffer.concat(chunks). Так кириллическая буква не повредится, если граница запроса пройдёт между её байтами. Лимит проверяется по исходному числу байтов.

В терминале из папки примера выполните:

node server.mjs

Адрес стенда — http://127.0.0.1:4180. Сначала откройте его вручную, чтобы убедиться, что сервер доступен из вашего окружения. Затем агент должен открыть тот же адрес своим браузерным инструментом. Доступ к localhost у браузера агента зависит от того, где запущены клиент и MCP-сервер. Удалённый браузер может находиться на другой машине. Одинаковая строка адреса этого не исправляет.

Задайте договор приёмки до исправления

Если попросить «проверь форму», агент может ограничиться успешным кодом. Опишите исходное состояние и путь через отказ сервера заранее, пока вы ещё не знаете, на каком шаге проявится ошибка. Скопируйте запрос в клиент с Playwright MCP:

Проверь только учебную страницу http://127.0.0.1:4180 через
подключённые браузерные инструменты Playwright MCP.
Пока не меняй файлы. Не ограничивайся чтением кода.

Начни с новой загрузки страницы: стоимость 1000, поле пустое.
1. Отправь пустую форму. Проверь текст «Введите промокод»,
   фокус поля и связь ошибки с полем. Стоимость остаётся 1000.
2. Введи WRONG и отправь. Дождись сообщения об отказе.
   Проверь, что кнопка снова доступна и значение WRONG сохранено.
3. Без перезагрузки замени код на STUDY20 и отправь.
   Ожидай стоимость 800, сообщение об успехе и пустую общую ошибку.
Для каждого пункта сообщи: действие, ожидаемое, наблюдаемое,
   итог passed/failed/blocked. Если продолжение невозможно,
   назови причину. Прикрепи доступные доказательства результата.

Это протокол проверки, а не обещание конкретных имён инструментов. Набор доступных вызовов зависит от версии сервера и конфигурации клиента. Агент должен использовать реально предоставленные инструменты; отсутствие возможности проверить фокус отмечается как blocked, а не подменяется предположением.

На исходном коде второй пункт обязан завершиться failed. После отказа кнопка выключена; третий пункт на этой же странице заблокирован предыдущим дефектом. Агенту не следует перезагружать страницу ради зелёного результата: требование явно проверяет повтор без перезагрузки.

Если отчёт заявил успех для всех пунктов, откройте доказательства. Значение цены 800 после отдельной новой загрузки не подтверждает возможность повтора на сломанной странице. Отчёт должен сохранять последовательность, особенно переход через ошибку.

Поручите узкую правку и повторите тот же сценарий

После воспроизведения дайте задачу на исправление:

Исправь зависание формы после отказа API в app.js.
Возвращай pending и disabled в исходное состояние после любого
завершения запроса. Сохрани введённый код при ошибке.
При новой попытке очищай старую общую ошибку.
Не меняй серверный договор и сумму скидки.
После правки повтори исходный протокол через браузерные инструменты.
Перед повтором загрузи страницу заново, чтобы получить новый app.js.

Результат локальной правки можно проверить по структуре обработчика. Уберите pending = false и button.disabled = false из ветки успеха, затем добавьте finally после catch:

} catch {
  status.textContent = '';
  problem.textContent = 'Код не подошёл. Проверьте его и повторите';
} finally {
  pending = false;
  button.disabled = false;
}

Это замена окончания существующего try/catch, а не дополнительный самостоятельный обработчик. В finally сбрасываются pending и disabled, даже если запрос отклонён или JSON не разобран. Для рабочего интерфейса текст сетевого сбоя нужно отличать от отклонённого кода; стенд намеренно сводит их к одной понятной повторной попытке.

После изменения агент заново загружает страницу и выполняет весь протокол. В третьем пункте новая загрузка уже запрещена: проверяется продолжение после WRONG. Итоговая цена должна стать 800, общая ошибка исчезнуть, введённый STUDY20 сохраниться.

НаблюдениеЧто оно подтверждаетЧего ещё не доказывает
Дерево доступности содержит поле «Промокод»У поля есть доступное имяОшибка корректно озвучена диктором
После WRONG кнопка доступнаФорма восстановилась после отказаВторой запрос завершится успехом
На той же странице цена стала 800Повторный сценарий завершёнСервер сохранил заказ: хранения нет
Скриншот показывает сообщениеСообщение видно в этот моментФокус и связь aria-describedby верны
В сети ответ 200API приняло конкретный запросИнтерфейс правильно отобразил результат

Снимок структуры страницы удобен для поиска элементов и состояния доступности. Скриншот показывает внешний вид. Для результата запроса нужна сеть или серверный ответ, а для фокуса — наблюдение активного элемента. Если инструмент не предоставил данные, укажите это ограничение в отчёте.

Отделите доказательство от впечатления агента

Попросите итог в воспроизводимом формате. Полезная запись выглядит так: «После POST с WRONG получено 422; сообщение видно; кнопка доступна; поле содержит WRONG. Затем без перезагрузки отправлен STUDY20, цена 800, ошибка очищена». При наличии возможности добавьте адрес страницы, версию проверяемой правки и приложенные файлы доказательств.

Фраза «страница выглядит хорошо» не объясняет, какой путь пройден. Равно как чтение finally в исходнике не доказывает, что браузер получил обновлённый скрипт. Поэтому в протоколе есть повтор после загрузки новой версии.

MCP не создаёт защитную песочницу для компьютера и сети. Разрешения клиента, сервера и окружения определяют, к каким данным и действиям агент фактически получит доступ во время приёмки. Новый браузерный контекст тоже не отделяет серверные данные. Для этого упражнения используем локальный учебный объект без аккаунтов, чужих документов и настоящих платёжных действий.

Информация на странице — данные проверяемого интерфейса. Если на тестируемой странице появится текст с инструкцией агенту отправить файл или открыть сторонний адрес, это не часть согласованного сценария приёмки. Границы работы заданы запросом и инструментальными разрешениями.

Сохраните полезный сценарий после приёмки

Разовая проверка MCP помогает исследовать конкретную правку. Для повторного запуска при каждом изменении перенесите условия в обычный тест Playwright Test. Ручной разговорный сценарий и тестовый файл имеют разные способы запуска; отчёт агента сам по себе не становится набором регрессионных тестов.

Самостоятельно проверьте качество протокола: временно верните исходное окончание без finally и повторите только отрицательный проход. Отчёт обязан назвать недоступную кнопку. Затем восстановите исправление и повторите полный сценарий.

Во второй задаче добавьте учебный отказ 503 по коду WAIT в сервере. Затем отправьте STUDY20 без перезагрузки. До запуска запишите ожидаемое сообщение: так агент не сможет принять любой текст за правильный результат.

Продолжить практику проектирования таких заданий и проверки результатов агентских изменений можно на курсе по ИИ-агентам.


«Доктайп» — журнал о фронтенде. Читайте, слушайте и учитесь с нами.

ТелеграмПодкастБесплатные учебники