惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

B
Blog
C
Check Point Blog
The GitHub Blog
The GitHub Blog
Y
Y Combinator Blog
SecWiki News
SecWiki News
有赞技术团队
有赞技术团队
Latest news
Latest news
D
DataBreaches.Net
Blog — PlanetScale
Blog — PlanetScale
Project Zero
Project Zero
H
Help Net Security
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
G
GRAHAM CLULEY
Engineering at Meta
Engineering at Meta
T
Threat Research - Cisco Blogs
腾讯CDC
P
Proofpoint News Feed
L
LINUX DO - 热门话题
C
Cisco Blogs
P
Palo Alto Networks Blog
Vercel News
Vercel News
P
Privacy International News Feed
爱范儿
爱范儿
Scott Helme
Scott Helme
L
Lohrmann on Cybersecurity
MyScale Blog
MyScale Blog
K
Kaspersky official blog
B
Blog RSS Feed
美团技术团队
Microsoft Security Blog
Microsoft Security Blog
O
OpenAI News
博客园 - 叶小钗
量子位
T
Tenable Blog
C
Cybersecurity and Infrastructure Security Agency CISA
J
Java Code Geeks
Application and Cybersecurity Blog
Application and Cybersecurity Blog
Hacker News: Ask HN
Hacker News: Ask HN
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
L
LINUX DO - 最新话题
F
Fortinet All Blogs
N
News | PayPal Newsroom
The Hacker News
The Hacker News
C
CXSECURITY Database RSS Feed - CXSecurity.com
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
博客园 - 【当耐特】
N
News and Events Feed by Topic
V
Visual Studio Blog
Google DeepMind News
Google DeepMind News
Last Week in AI
Last Week in AI

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Bug fingerprinting для UI: почему stack trace не работает и что вместо
Creatman · 2026-05-06 · via Все публикации подряд на Хабре

Уровень сложностиСложный

Время на прочтение8 мин

Охват и читатели173

Аналитика

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-бага:

  1. Что мы искали — locator (getByRole('button', { name: 'Place Order' }))

  2. Что мы проверяли — assertion (toBeVisible, toHaveText, toHaveURL)

  3. Тип ошибки — class (TimeoutError, AssertionError)

  4. Где — URL pattern (без волатильных частей)

  5. Содержание — 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]

После этих трёх правил:

До нормализации

После

getByRole('button').nth(2)

getByRole('button').nth(2) (nth не трогаем — это explicit пользовательский intent)

getByRole('item') :nth-child(3)

getByRole('item') :nth-child

/run/abc123de-...-12345.../info

/run/:uuid/info

/users/12345/profile

/users/:id/profile

/search?q=x&utm=y

/search

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.