TL;DR: Sentry дедуплицирует backend‑ошибки по хешу (error class + top stack frame + module). Для UI‑багов этот рецепт ломается — у expect(button).toBeVisible() нет stack frame в продуктовом смысле, есть локатор + assertion + URL. В webtest‑orch я собрал composite SHA-256 fingerprint из (normalized_selector | assertion type | error class | URL template | message[:80]) с тремя rules нормализации (:nth-child, UUID, /users/123 → /users/:id). Это даёт стабильный 8-hex BUG-id который выживает прогоны и даёт diff new / regression / persisting / fixed без БД и embedding«ов.»
Сразу прошу читателя простить меня но дальше будет много букав и кода.
Когда я писал webtest‑orch — Claude Code skill для e2e‑тестирования — упёрся в задачу которая на первый взгляд тривиальная. Тест провалился. На следующем прогоне — снова. Это тот же баг или новый? Если тот же — на каком прогоне он впервые появился, и был ли он fixed между ними? Если ответа нет, run‑diff невозможен, и любая регрессия теряется в шуме «9 fail»ов сегодня«.»
Backend‑задачу решил Sentry — composite hash из error class и top stack frame. UI‑багам этот рецепт не подходит, и в этой статье я разберу почему, и какой fingerprint в итоге работает.
Почему рецепт Sentry не подходит для UI
Sentry«s grouping algorithm дедуплицирует backend‑ошибки через комбинацию:»
Тип исключения (
TypeError,NullPointerException)Top frame stack trace (
module.py:42 → user_handler)Транзитивно — содержание top функции
Это работает потому что у backend‑исключения есть детерминированный stack — одна функция, одна строка, одно исключение.
UI‑баги выглядят иначе. Падает test:
Error: expect(locator).toBeVisible() failed Locator: getByRole('button', { name: 'Place Order' }) Expected: visible Actual: not found URL: https://shop.example.com/checkout
Какой здесь «top stack frame»? Технически — Playwright internals (expect.js:128), но для дедупликации это бесполезный носитель. Регрессии будут все из одного и того же expect.js:128, потому что Playwright использует одну функцию для всех expect(locator).toBeVisible() в мире.
То что на самом деле уникально для каждого UI‑бага:
Что мы искали — locator (
getByRole('button', { name: 'Place Order' }))Что мы проверяли — assertion (
toBeVisible,toHaveText,toHaveURL)Тип ошибки — class (
TimeoutError,AssertionError)Где — URL pattern (без волатильных частей)
Содержание — short snippet of error message
Эти пять полей в комбинации идентифицируют bug semantically — не его technical signature, а его product meaning.
Naive подход и почему он ломается
Первая итерация — конкатенация всех пяти полей:
fingerprint = sha256(f"{selector}|{assertion}|{error_class}|{url}|{message}")[:8]
Это работает на 80% случаев. Рушится на остальных 20%, и эти 20% — самые информативные.
Случай 1 — :nth-child(N)
Тест воспроизводит баг на третьем элементе списка. Selector:
getByRole('button').nth(2) // i.e. nth-child(3) in DOM
Завтра кто‑то добавил элемент в начало списка — теперь баг на четвёртом. Selector:
getByRole('button').nth(3) // i.e. nth-child(4) in DOM
Это тот же баг (кнопка с тем же accessible name не работает в том же UI‑контексте), но naive fingerprint видит два разных. Результат — каждое изменение в списке создаёт «новый» bug. Run‑diff бесполезен.
Случай 2 — UUIDs в URL
URL: /run/abc123de-1234-1234-1234-123456789abc/details
Каждый прогон — новый UUID. Каждый прогон — новый fingerprint. Каждый баг «новый» forever.
Случай 3 — числовые ID в URL
URL: /users/12345/profile
Тестовый юзер генерируется в setup project → разный ID каждый прогон. Тот же fingerprint problem.
Случай 4 — query strings
URL: /search?q=foo&utm_source=test&_=1715693845
Timestamp в query, UTM‑параметры из ad campaign на тестовой среде. Fingerprint меняется каждый раз когда запускаешь.
Решение — нормализация перед хешированием
import re import hashlib SELECTOR_NORMS = [ (re.compile(r":nth-child\(\d+\)"), ":nth-child"), (re.compile(r":nth-of-type\(\d+\)"), ":nth-of-type"), ] URL_NORMS = [ # UUIDs (любая позиция) (re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.I), ":uuid"), # Числовые ID между слешами (re.compile(r"/\d+(?=/|$|\?)"), "/:id"), # Query strings — целиком (re.compile(r"\?.*$"), ""), ] def normalize_selector(s: str) -> str: for pat, repl in SELECTOR_NORMS: s = pat.sub(repl, s) return s def normalize_url(url: str) -> str: if not url: return "" # Stripping protocol+host если есть m = re.match(r"^https?://[^/]+(.*)", url) path = m.group(1) if m else url for pat, repl in URL_NORMS: path = pat.sub(repl, path) return path def compute_fingerprint(bug: dict) -> str: err = bug.get("error") or {} msg = (err.get("message") or "")[:120] selector = normalize_selector(bug.get("selector", "")) assertion = bug.get("assertion_type", "Generic") error_class = bug.get("error_class", "AssertionError") url_path = normalize_url(err.get("url") or "") composite = f"{selector}|{assertion}|{error_class}|{url_path}|{msg[:80]}" return hashlib.sha256(composite.encode("utf-8")).hexdigest()[:8]
После этих трёх правил:
До нормализации | После |
|---|---|
|
|
|
|
|
|
|
|
|
|
nth(N) (Playwright explicit API) специально не нормализуем — это intent программиста: «именно третья кнопка». А :nth-child(N) (CSS селектор) нормализуем, потому что туда обычно попадает positional index из автогенерированного селектора, который шумит между прогонами.
Длина fingerprint — 8 hex chars (32 бита). При коллизионной вероятности ~2^-32 на пару, для типичного suite в 50–100 багов это даёт <0.001% false collision rate. SHA-256 «full» использовать смысла нет — короткий fingerprint удобнее для UI отчётов (BUG-a3f9c2b1).
Извлечение полей из разнотипных runtime‑ошибок
Selector редко лежит в structured field. Playwright кидает его в error.message + snippet. Вот regex‑extractor для популярных Playwright‑локаторов:
def extract_selector(error_snippet: str, error_msg: str) -> str: haystack = (error_snippet or "") + " " + (error_msg or "") for prefix in ("getByRole", "getByLabel", "getByText", "getByTestId", "getByPlaceholder", "getByAltText", "getByTitle", "locator"): m = re.search(rf"{prefix}\([^)]+\)", haystack) if m: return m.group(0) return "" def extract_assertion_type(msg: str) -> str: for kw in ("toBeVisible", "toHaveText", "toHaveURL", "toHaveTitle", "toHaveAttribute", "toContainText", "toEqual", "toBe"): if kw in msg: return kw if re.search(r"timeout|Timeout", msg): return "Timeout" return "Generic" def extract_error_class(msg: str) -> str: m = re.match(r"^([A-Z][A-Za-z]+(Error|Exception)):", msg) if m: return m.group(1) if re.search(r"timeout|Timeout", msg): return "TimeoutError" return "AssertionError"
Парсер выбирает первый match из приоритетного списка. getByRole идёт первым потому что он чаще всего используется и лучше всего describes intent. locator(...) — fallback для кастомных селекторов.
Один edge case: если в error message несколько локаторов (например, expect(getByRole('button')).toContain(getByText('saved'))) — берётся первый. Это конвенция, не optimal, но стабильна между прогонами того же теста.
Run‑diff: что делать с fingerprint«ом»
Сам по себе fingerprint полезен только для дедупликации внутри одного прогона. Реальный value — diff между прогонами. Алгоритм:
def diff_runs(current: list[dict], previous: list[dict]) -> dict: prev_map = {b.get("fingerprintHash"): b for b in previous if b.get("fingerprintHash")} summary = {"new": 0, "regression": 0, "persisting": 0, "fixed": 0} out = [] cur_hashes = set() for bug in current: fp = bug["fingerprintHash"] cur_hashes.add(fp) prev = prev_map.get(fp) if prev is None: state = "new" elif (prev.get("diff") or {}).get("state") == "fixed": state = "regression" # был fixed, вернулся else: state = "persisting" bug["occurrenceCount"] = (prev.get("occurrenceCount") or 1) + 1 bug["diff"] = { "state": state, "previousRunId": prev.get("lastSeenRunId") if prev else None, } summary[state] += 1 out.append(bug) # Newly fixed: present in previous, absent in current for fp, prev in prev_map.items(): if fp in cur_hashes: continue if (prev.get("diff") or {}).get("state") == "fixed": continue # already fixed earlier — не считаем "newly" prev_copy = dict(prev) prev_copy["diff"] = {"state": "fixed", "previousRunId": prev.get("lastSeenRunId")} out.append(prev_copy) summary["fixed"] += 1 return {"bugs": out, "summary": summary}
Состояния:
new— fingerprint не было в предыдущем прогонеpersisting— был, всё ещё есть; счётчик occurrence инкрементитсяfixed— был в предыдущем, в текущем отсутствует, и НЕ был помечен fixed раньшеregression— был, был помечен fixed, потом вернулся
regression — самое важное состояние. Это сигнал что починка сломалась и нужно investigate. В отчёте webtest‑orch — 🚨 Regression в diff‑секции report.md.
Edge cases которые хочется упомянуть
Viewport‑dependent bugs. touch-target: BUTTON 86×20 на 390×844 — fingerprint включает viewport size. Если запускать на mobile и desktop, тот же баг имеет два разных fingerprint«а. Это правильное поведение — баг существует только на mobile, не на desktop. Если хочется коллапсировать в один — добавить ещё одну норм.правило: r"\d+x\d+" → "WxH". Это уже зависит от того как ты хочешь видеть отчёт.
Selector с переменным текстом. Динамические welcome‑сообщения вроде getByText('Welcome, John') создают fingerprint per username. Решение — переписывать spec на getByRole('heading', { name: /Welcome/ }) ещё на стадии генерации, не в fingerprint«е. Fingerprint надёжен только когда spec написан правильно.»
Несколько багов на одной странице. Если тест падает с expect(issues).toEqual([]) где issues содержит 8 axe‑core violations, fingerprint тестовой ошибки — один. Поэтому в webtest‑orch run_suite.py сначала расщепляет issues collector на отдельные bug records, потом fingerprint каждый отдельно. Это важная архитектурная деталь — без неё все a11y‑violations одной страницы дают один BUG-id и теряются индивидуальные регрессии.
SHA-256 vs xxhash vs CRC32. Я выбрал SHA-256 по двум причинам: (1) криптостойкость не нужна, но коллизии важны — на 32 бита SHA-256 распределяется лучше CRC32 на типичных коротких строках; (2) std lib в Python без зависимостей. xxhash был бы быстрее, но для 100–1000 багов в прогоне разница — миллисекунды.
Severity inference как side‑effect
Из тех же extracted полей можно вывести severity без LLM. axe‑core impact«ы маппятся напрямую:»
def severity_from_signals(bug: dict) -> str: issue_line = (bug.get("issueLine") or "").lower() # axe-core impact → severity if "a11y[critical]" in issue_line or "a11y[serious]" in issue_line: return "S1" if "a11y[moderate]" in issue_line: return "S2" if "a11y[minor]" in issue_line: return "S3" # Структурные теги от spec template if issue_line.startswith("heading-jump:"): return "S2" if issue_line.startswith("touch-target:"): return "S2" if issue_line.startswith("overflow:"): return "S1" # Heuristic match на keywords из title + error text = (bug.get("title", "") + " " + bug.get("error", {}).get("message", "")).lower() if any(k in text for k in ["auth", "login", "checkout", "payment", "5xx"]): return "S0" if any(k in text for k in ["uncaught", "hydration"]): return "S0" return "S2" # default
Эта эвристика покрывает 80% случаев. Для остальных 20% (P0 product regression со generic‑сообщением) добавлен override mechanism через комментарий перед test():
// @severity: S0 test('checkout completely broken', async ({ page }) => { /* ... */ });
fingerprint_bugs.py парсит spec файл, ищет // @severity: комментарии перед test(), матчит по test title и применяет override. Это решает false‑negative case когда heuristic недооценивает критичность.
Что получается на выходе
bugs.json после прогона:
{ "runId": "run-2026-04-30-1430", "bugs": [ { "id": "BUG-a3f9c2b1", "fingerprintHash": "a3f9c2b1", "title": "Checkout 'Place Order' button non-functional on mobile", "severity": "S0", "priority": "P0", "diff": { "state": "regression", "previousRunId": "run-2026-04-26-0902" }, "occurrenceCount": 3, "trackerMappings": { "linear": { "priority": 1 }, "github": { "labels": ["bug", "severity/s0", "priority/p0"] }, "jira": { "issueType": "Bug", "priorityName": "Highest" } } } ] }
Из этого структуры родится auto‑filing в трекеры (Linear/GitHub/Jira), markdown‑отчёт с severity breakdown, run‑diff summary в diff.json. Всё детерминированно, без LLM в pipeline — экономно и воспроизводимо.
Полная имплементация — в webtest‑orch, скрипт scripts/fingerprint_bugs.py, ~250 строк, MIT.
Полезное
webtest‑orch — Claude Code skill для e2e‑тестирования, использует этот fingerprint
Sentry grouping docs — оригинальный backend‑подход на котором этот UI‑вариант построен по аналогии
Playwright locator priority — почему
getByRoleидёт первым в нашем extractor
— Nick (Creatman). Делаю инструменты для разработчиков на Claude Code: webtest‑orch, cc‑janitor, claude‑code‑antiregression‑setup, claude‑statusline, ai‑context‑hierarchy, notebooklm‑claude‑workflows. Все MIT, все на github.com/CreatmanCEO.
