Привет, Хабр! Меня зовут Настя, я - влюблённый в профессию тестировщик.

В этой статье хочу рассказать о том, как я придумала и с помощью AI разработала браузерное расширение, которое записывает шаги воспроизведения, ошибки и сетевые запросы, а потом собирает промпт для LLM.

В статье приведу немного инженерной части: архитектура, приватность и грабли.

Для начала рассмотрим повседневные задачи, знакомые каждому тестировщику:

  1. Описание баг-репорта. Желательно указать не только шаги, но и добавить скриншот, логи и т.д. В итоге это может превратиться в  «развлечение» минут на 30.

  2. Проверка списков опций, ссылок, фильтров… На n-ом элементе можно потеряться в том, что проверено, а что ещё нет.

  3. Не открыты DevTools. Вспоминаешь об этом обычно, когда нужны какие-нибудь данные (например, об упавшем запросе). В итоге страдаешь снова воспроизводишь.

  4. Нет времени возникновения проблемы. Сразу на время я не обращаю внимания и только спустя время начинаю искать лог-файл с нужным стектрейсом.

  5. Отчёт о сессии исследовательского тестирования. Обычно на это никогда нет времени.

Всё это воспринималось мной как данность и неотъемлемая часть работы QA...

От вдохновения до идеи

Отвлечёмся от рутины и поговорим о вдохновении. Уверена, каждый из нас вдохновляется разным. Кто-то путешествиями, кто-то музыкой, кто-то прогулками... На разработку браузерного расширения меня вдохновили:

Эти доклады (и не только) подтолкнули к размышлениям о тленности моего бытия, так сказать. Насколько я отстала от жизни, что до сих пор описываю баг-репорты вручную, в то время как кто-то уже и тесты генерит, и тест-кейсы. Унывать я долго не стала, и в итоге меня осенило: 

Браузер видит всё происходящее: каждый клик, каждый ввод, каждый запрос, каждую ошибку в консоли и т.д. Если это где-то сохранять, у меня будет полный контекст проблемы, а превратить контекст в баг-репорт может LLM.

Так появилась идея: я тестирую, расширение собирает, LLM описывает. Разработку я начала в ноябре 2025-го, весной рассказала о ней на Heisenbug 2026 Spring, уже вышла версия 1.2.0.

Что получилось

QA Trace - AI Bug Reports, Private & Local - расширение для браузеров Chrome, Firefox и Яндекс. Расширение работает только на настроенных адресах и делает следующее:

  • Записывает действия: click, dblclick, input, change, открытие и перезагрузка вкладки - с селектором элемента, ближайшей меткой (label'ом), введённым значением (пароли маскируются) и временем.

  • Ловит ошибки: консольные (window.onerror и unhandledrejection), сетевые (упавшие fetch/XHR запросы со статусом, заголовками и телом) и UI-ошибки по CSS-селектору (можно настроить несколько селекторов, по умолчанию div[id^="__error"]

  • Опционально сохраняет полный лог fetch/XHR запросов на URL.

  • По кнопке собирает промпт в одном из трёх режимов: «Шаги для тикета», «Задокументировать шаги» и «Полный отчёт действий».

Промпт можно скопировать и вставить в чат с любой LLM, отправить в LLM (OpenAI, DeepSeek или кастомную) из расширения либо кинуть webhook'ом в свою систему (например, n8n). Промпт генерируется на русском или английском языке в зависимости от настроек.

Как это выглядит в работе

  1. В настройках добавляю адреса тестовых стендов в «Адреса для мониторинга».

    1. Если хочу записывать все запросы, включаю «Записывать все fetch/XHR запросы для этого URL».

    2. Если хочу записывать действия на стороннем сервисе, с которым проверяю интеграцию, включаю для этого адреса «Не отслеживать ошибки для этого URL».

  2. Тестирую как обычно. В это время расширение отслеживает и записывает действия, ошибки, запросы. Если что-то пошло не так (например, запрос вернул неуспешный статус), расширение показывает всплывающее уведомление на странице (DevTools открывать не обязательно).

  3. Нашла проблему - открываю окно расширения, нажимаю «Сгенерировать промт». Выбираю режим, вкладки для включения в контекст (по умолчанию выбирается текущая вкладка, если нужно учесть несколько вкладок, выбираю несколько) и действие, с которого начать. Исключаю ожидаемые ошибки (например, ошибка логина с неверными кредами) из промпта. Если технических ошибок нет, а поведение неправильное, описываю проблему вручную.

  4. Смотрю промпт, при желании правлю (обычно так не делаю), копирую - или отправляю в LLM и получаю краткое описание и описание для тикета.

Почему QA Trace

Конечно, есть и другие хорошие инструменты с похожим принципом, но почти все устроены одинаково: расширение записывает сессию (часто с видео) и загружает её в облако. Нужен аккаунт, данные лежат в облаке.

Мне хотелось инструмент, который можно использовать без регистрации, смс и всего такого. Просто установить и пользоваться. Отсюда главное архитектурное требование: всё локально.

Архитектура

Как работают расширения

У всех браузерных расширений есть несколько основных компонентов:

  1. manifest.json файл - своего рода сердце расширения. Это первое что считывает браузер. В этом файле определены самые разные данные: имя, версия, иконки, скрипты, ...

  2. background процесс (service-worker.js) работает с хранилищами (например, local storage): сохраняет данные, читает.

  3. content-script.js работает с DOM страницы: видит элементы, добавляет свои и т.д.

  4. popup - окно расширения, с которым мы как раз и работаем.

  5. Страница с настройками (options page). Обычно открывается по соответствующей кнопке и/или после установки.

Компоненты обмениваются сообщениями и данными, это и обеспечивает работу расширения.

Расширение QA Trace

Чтобы расширение перехватывало ошибки в консоли и fetch/XHR запросы, ему нужен доступ к контексту основной страницы, а основные компоненты работают в изолированном контексте. Поэтому помимо основных компонентов расширение QA Trace содержит дополнительный скрипт (page-hooks.js), который внедряется в контекст страницы, обрабатывает запросы и консоль и передаёт эту информацию в основные компоненты.

Схематично расширение можно изобразить так:

схема расширения QA Trace
схема расширения QA Trace

Приватность

Для меня это было важно, и на данный момент реализовано это так:

Только разрешённые адреса

Расширение требует разрешение на доступ ко всем сайтам. Это то, что может «напрячь», но это нужно только потому, что я не знаю заранее, какие адреса будут тестироваться. И первое, что делает расширение на странице, - сверяется со списком «Адреса для мониторинга» из настроек. Если текущий адрес не найден в списке, обработка заканчивается (вызывается return), ничего не отслеживается и не записывается (ни действия, ни ошибки).

Query и hash не попадают в хранилище

В query-строках и hash могут быть токены, поэтому при сохранении URL режется до origin + path. 

На первый взгляд всё просто, но hash-роутинг в SPA (#/orders/42) - это навигация, которую хочется оставить, чтобы понимать, на какой странице была ошибка. Компромисс получился таким:

// Hash-routed SPAs keep navigation state in the fragment, so the route path is preserved to
// identify the page. A query inside the route and non-route fragments (e.g. OAuth implicit
// `#access_token=…`) are dropped so credentials never reach storage.
private static safeHashRoute(hash: string | undefined | null): string {
    const route = (String(hash ?? '').replace(/^#/, '').split('?')[0]) ?? ''
    return /^!?\//.test(route) ? `#${route}` : ''
}

Origin режется на выходе, а не при сохранении

Следующий этап: убрать протокол и хост из всего, что уходит в промпт, в LLM, в webhook. Это делается при выгрузке, а не при сохранении. Локально я хочу видеть полные URL - мне же нужно понимать, что и где упало, а модели не нужно знать какой стенд использовался.

Здесь тоже есть нюансы: хост живёт не только в поле url. Он в тексте ошибки, в стеке, в заголовках, в теле ответа. Поэтому для свободного текста есть отдельная функция, и там регулярки чуть хитрее, чем new URL():

// Matches a URL authority embedded in free text and stops before the path, so the path is kept.
const EMBEDDED_ORIGIN = /(?:\b[a-z][a-z0-9+.\-]*:\/\/|(?<=^|[\s"'(<])\/\/)[^\s/"'()<>]+/gi

// A scheme-less `host:port` (e.g. `db.internal.corp:5432`, `10.0.0.5:6379`) still exposes the host.
// To avoid eating stack frames like `app.js:128`, only IPs or multi-label hostnames whose
// final label is not a source-file extension match.
const EMBEDDED_HOST_PORT = /(?<=^|[\s"'(<])(?:\d{1,3}(?:\.\d{1,3}){3}|(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+(?!(?:js|jsx|ts|tsx|mjs|cjs|json|map|css|scss|less|html?|vue|svelte|py|rb|go|rs|php|txt|md|ya?ml|xml|png|jpe?g|gif|svg|wasm|woff2?)\b)[a-z][a-z0-9-]*):\d{1,5}/gi

Вторая регулярка - про host:port без схемы. db.internal.corp:5432 в тексте ошибки - утечка хоста, а app.js:128 в стеке - нет. Разделить их удалось по признаку «последний сегмент не похож на расширение файла».

Полный лог запросов - только по явному включению

Запись всех fetch/XHR запросов - это много данных и много чувствительного. По умолчанию пишутся только ошибки, а полный лог включается отдельно на конкретный адрес.

Чувствительные заголовки и поля тел

Authorization, Cookie, X-API-Key и подобные не записываются. В телах запросов и ответов поля вроде password, token, api_key заменяются на [REDACTED].

Ключи шифруются, пароль не хранится

Если включить интеграцию с LLM или webhook, ключ API или пароль лежит в Extension storage зашифрованным, ключ шифрования выводится из кодовой фразы. Сама фраза не сохраняется - её расширение запросит при использовании. Решила сделать так, чтобы не сохранять чувствительные данные.

Лимиты и срок жизни данных

Всё собранное автоматически удаляется через 12 часов. Также есть лимиты на количество действий (по умолчанию 1000), ошибок (по умолчанию 50) и запросов (по умолчанию 150) и на длину текстовых полей (по умолчанию 500). 

Так же есть внутренний лимит на количество ошибок в минуту (не больше 120 на вкладку), иначе один setInterval с падающим запросом забьёт хранилище за секунды.

Грабли

window.onerror вместо патчинга console

Очень хотелось перехватывать console.error и ловить всё, и сначала так и было. Но в таком случае терялся контекст ошибки, и вместо стектрейса с указанием файлов и методов, получалось, что ошибку кидает расширение. Из-за этого контекст, необходимый для локализации проблемы, полностью терялся. Поэтому сейчас отслеживаются только необработанные ошибки window.onerror и unhandledrejection.

Storage ограничен

browser.storage.local в Chrome - это примерно 10 МБ на расширение. И в эти 10 МБ должно уместиться всё: действия, ошибки, лог запросов и скриншоты. Пока отслеживались только ошибки, места хватало. Когда добавился полный лог fetch/XHR запросов, хранилище могло довольно быстро переполниться, и это могло привести к потере свежих данных.

Поэтому с версии 1.1.1 внесла изменения:

  • Скриншоты - JPEG с качеством 60 вместо PNG. Чтобы увидеть красный баннер на странице, этого достаточно.

  • Тела запросов и ответов считаются в байтах, а не в символах, потому что квота хранилища - в байтах.
    По умолчанию body читается не больше 100 000 символов и сохраняется с обрезкой до 5 000. Есть настройка «Сохранять body сетевых запросов полностью», но и у неё есть потолок - 4 МБ: тело больше заменяется маркером с размером (например, QA Trace: response body too large to store - 12.4 MB), а сама запись о запросе остаётся. Бинарные content-type не читаются.

  • Переполнение - не исключение, а штатная ситуация. Каждая запись идёт через persist(): если set отклонён из-за квоты, освобождаем место и повторяем, пока не влезет. Освобождаем по порядку: самый старый скриншот → половина лога запросов → половина тел сетевых ошибок → половина ошибок → половина действий. Остаётся всегда новая половина.

  • Лимит на запросы. К лимиту на ошибки (120 в минуту на вкладку) добавился такой же для полного лога - 600 запросов в минуту на вкладку.

Мелкая, но неприятная деталь: отличить квоту от любой другой ошибки записи нетривиально. Часть браузеров отклоняет промис DOMException с именем, но пустым message, поэтому проверяются оба поля. Любая другая ошибка записи показывается в всплывающем окне: раньше она уходила в console.warn в service-worker.js, который никто не открывает.

Firefox и 24 ГБ памяти

После релиза 1.1.1 с полным логом fetch/XHR несколько раз переставал отвечать браузер Firefox. Начала отслеживать используемые ресурсы и увидела, что Firefox использует почти 24 ГБ. Главный процесс, не процесс расширения. В хранилище расширения при этом лежало 2,5 МБ. Chrome примерно в такой же ситуации - ничего.

Понятно, что причина в записи всех fetch/XHR запросов моим расширением: весь лог запросов хранился одним значением, и каждая батч-запись переписывала массив целиком - 2,4 МБ старого плюс 2,4 МБ нового. Двести записей в минуту - это примерно гигабайт в минуту. В добавок к этому ещё и content-script.js подписывался на storage.onChanged на каждой вкладке, и главный процесс раздавал каждое уведомление всем открытым вкладкам.

Поэтому внесла правки и опубликовала 1.2.0:

  • Лог запросов - по одной записи на ключ. networkRequestIndex хранит {id, timestamp, bytes}, сам запрос лежит под networkRequest.<id> и пишется один раз. Запись стоит столько, сколько весит запрос, а не весь лог. Ключи, больше не связанные с запросами, каждый час подчищает таймер.

  • Никаких storage.onChanged. Страница настроек сообщает о сохранении сообщением, service-worker.js рассылает его открытым вкладкам, те сбрасывают закэшированную конфигурацию.

  • Свой лимит 9,5 МБ на все записанные данные - чуть ниже квоты Chrome (в Firefox, кстати, квоты нет совсем). Та же лестница сброса, что и при переполнении хранилища, только срабатывает до записи.

Вывод, который стоило знать заранее: в Firefox цена storage.local.set пропорциональна байтам в этом вызове - старое плюс новое, - а не тому, что изменилось.

background процесс засыпает

background процессservice-worker.js останавливается после ~30 секунд простоя и поднимает по событию. Две записи в storage.local, пришедшие подряд из разных вкладок, легко превращаются в гонку read-modify-write. Решение - очередь записи на промисах: каждая операция ждёт предыдущую. В 1.1.1 сверху добавила батчинг: события копятся 300 мс и пишутся одной операцией - N добавлений схлопываются в одну запись.

Про AI

Расширение не «понимает» баг. Оно собирает контекст (действия с временем и селекторами, ошибки со стеками и деталями запроса - метод, URL, статус) и кладёт его в промпт вместе с инструкцией и примером тикета. Дальше работает модель, которую выбрали вы. Можно вообще не подключать API: скопировать промпт и вставить в любой чат.

Сам промпт устроен стандартно:

  • Системная часть объясняет модели формат данных и просит собрать из них тикет с кратким заголовком и описанием (summary и description).

  • Пользовательская часть - записанные данные, отфильтрованные по вашему выбору и пример тикета как образец формата.

Если настроите интеграцию с LLM в расширении, то ответ будет парситься в { summary, description } и показываться в окне расширения. Если модель вернула что-то не по схеме, будет ошибка.

Инсайты

Качество результата определяют два поля в настройках - пример тикета и пример для документации. Они задают формат, к которому модель подстраивается. 

Режим «Задокументировать шаги» может быть полезен для описания регрессионных сценариев и для коллег из поддержки.

Ограничения

  • Пока нет понятия «сессии»: данные - один общий поток, который живёт 12 часов. Начать, остановить и сохранить отдельную сессию нельзя.

  • Селекторы простые: #id, tag.class или просто tag. Для шагов воспроизведения этого хватает, для генерации автотестов - нет. В планах data-testid, aria-атрибуты и уникальные fallback-селекторы.

  • Не записываются клавиатурные события, submit, drag-and-drop, paste - только click, dblclick, input, change и события вкладок. Часть багов воспроизводится именно на них.

  • Нет видео. Это осознанно: видео - тяжёлый артефакт, и именно его облачные инструменты делают хорошо. QA Trace про структурированный контекст, а не про запись экрана.

Что дальше

  • Именованные сессии: start/stop, несколько сессий.

  • Экспорт и импорт сессии в JSON.

  • Ошибки, спрятанные в теле успешного ответа: некоторые бэкенды отвечают 200 OK, а внутри JSON - {"status": 500}. Сейчас расширение смотрит только на HTTP-статус, и это следующий кандидат в детектор.

  • Генерация Playwright-тестов из записанных действий. Этот пункт пока на уровне идеи, может быть, в итоге будут и не тесты вовсе, посмотрим.

Исходники

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

Еженедельные вопросы по QA, разбор теории, практика, рабочие кейсы, истории из жизни и про развитие QA Trace —  в телеграм-канале QA or not QA?.

Присоединяйтесь!

Комментарии (0)