Skip to main content
Glama

tv-debug-mcp

MCP-сервер для полуручного прогона QA-кейсов на реальных Smart TV (Tizen / webOS) и на локальном Chrome — через Chrome DevTools Protocol. Агент управляет приложением: навигация пультом, лонгтап с точными таймингами, переходы по меню, чтение консоли и состояния плеера. Человек подтверждает то, что можно проверить только глазами.

Закрывает боль ручного тестирования сложных кейсов (лонгтап, перемещения, меню) на всём парке устройств, включая старые.

Быстрый старт

git clone https://github.com/Ediand11/tv-debug-mcp.git
cd tv-debug-mcp
npm install                                    # за корп-прокси: env -u HTTP_PROXY -u HTTPS_PROXY npm install
cp devices.example.json devices.json           # devices.json в .gitignore — ваш парк остаётся локальным
npm run check:browser                          # зелёный прогон без ТВ: свой Chrome + встроенная фикстура

Дальше — зарегистрировать сервер в своём MCP-клиенте, см. «Установка в MCP-клиенты». Для Claude Code это одна команда из корня репозитория:

claude mcp add tv-debug --scope user -- node "$PWD/src/server.js"

Тулы появятся как mcp__tv-debug__*. Проверить, что MCP видит парк: попросить агента вызвать tv_devices.

Чтобы гонять своё приложение, а не фикстуру:

  1. в devices.json описать устройство (platform, appId, host для ТВ или url для браузера) — поля и их проверки описаны в «Парк устройств»;

  2. завести apps/<id>.json с селекторами приложения и сослаться на него полем "app" — см. «App-профиль», готовый пример лежит в apps/fixture.json;

  3. для ТВ — Developer Mode на устройстве и подключённый sdb / ares.

Node ≥ 18. Зависимости: @modelcontextprotocol/sdk, ws, source-map-js (чистый JS-порт source-map 0.6, без wasm — важно для офлайн-запуска).

Установка в MCP-клиенты

Сервер — обычный stdio-MCP: команда node <абсолютный путь>/src/server.js, ни портов, ни демона. Дальше отличается только синтаксис конкретного клиента.

Claude Code

claude mcp add tv-debug --scope user -- node "$PWD/src/server.js"

⚠️ "$PWD" раскрывает оболочка в момент claude mcp add, а не Claude при запуске сервера: в конфиг уезжает уже готовый абсолютный путь. Поэтому команду обязательно выполнять из корня репозитория — иначе в конфиге окажется путь к тому каталогу, где вы стояли. Проверка — claude mcp list: там должен стоять абсолютный путь до src/server.js.

Codex CLI

~/.codex/config.toml:

[mcp_servers.tv-debug]
command = "node"
args = ["/absolute/path/to/tv-debug-mcp/src/server.js"]
startup_timeout_sec = 30

[mcp_servers.tv-debug.env]
TV_DEBUG_CONFIG = "/absolute/path/to/devices.json"

Переменные окружения — отдельная таблица [mcp_servers.<имя>.env], а не ключ внутри блока сервера: в TOML всё, что идёт после [mcp_servers.tv-debug], принадлежит этой таблице, и вложенный объект объявляется своим заголовком.

OpenCode

~/.config/opencode/opencode.json, ключ mcp, тип local. Переменные окружения здесь — ключ environment, не env, а команда — массив, а не строка:

{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "tv-debug": {
      "type": "local",
      "command": ["node", "/absolute/path/to/tv-debug-mcp/src/server.js"],
      "enabled": true,
      "environment": {"TV_DEBUG_CONFIG": "/absolute/path/to/devices.json"}
    }
  }
}

Cursor, Windsurf, Cline, VS Code — схема mcpServers

Один и тот же объект, различается только файл (~/.cursor/mcp.json, .vscode/mcp.json, панель настроек расширения):

{
  "mcpServers": {
    "tv-debug": {
      "command": "node",
      "args": ["/absolute/path/to/tv-debug-mcp/src/server.js"],
      "env": {"TV_DEBUG_CONFIG": "/absolute/path/to/devices.json"}
    }
  }
}

Стабильное имя команды вместо пути

npm link                       # из корня репозитория

npm link кладёт tv-debug-mcp в PATH (поле bin в package.json) и заодно ставит exec-бит: в репозитории у src/server.js права 644 при живом шебанге, то есть напрямую он не запускается. После линка в любом конфиге можно писать "command": "tv-debug-mcp" с пустым args.

Публикации в npm и запуска через npx нет и не планируется. devices.json и apps/<id>.json лежат рядом с пакетом, а глобальная установка кладёт их в каталог, который переписывается на каждом обновлении. Работать это будет только с TV_DEBUG_CONFIG на парк и абсолютными путями в поле app — то есть ровно та ручная настройка, ради избавления от которой npx и берут.

Переменные окружения

Переменная

Что задаёт

Если не задана

TV_DEBUG_CONFIG

путь к devices.json

devices.json рядом с пакетом

TV_DEBUG_CHROME

бинарь Chrome для platform: "pc"

/Applications/Google Chrome.app/Contents/MacOS/Google Chrome; поле chromePath устройства перебивает и то и другое

TV_DEBUG_DEVICE

устройство для платформенных приёмок (check:webos2, check:tizen3, webos4-regress-probe)

webos2 / tizen3 / webos4 соответственно

TV_DEBUG_CASES_DIR

куда tv_record кладёт записанные кейсы

cases/recorded/ рядом с пакетом (в .gitignore)

TV_DEV_URL + TV_DEV_APP

дополнительный прогон check:browser против живого dev-сервера; нужны обе

прогон только против встроенной фикстуры

TMPDIR

куда падают артефакты (.cpuprofile, .heapsnapshot, .png, HAR), когда path не задан явно

системный временный каталог

Зачем не Appium / не playwriter

  • Appium TV-драйверы тянут chromedriver, который мёртв на Tizen с Chrome ≤ 57 и держится на хаке подмены UA на webOS 3. Тяжёлая инфра, два разных драйвера.

  • playwriter / Playwright connectOverCDP требует свежий Chromium — не заведётся на webOS 3/4 (Chrome 38/53).

  • Этот MCP говорит с инспектором по «голому» CDP. Один кодовый путь от Chrome 38 до 120+, ноль зависимостей на устройстве. Тот же набор тулов работает и против браузера на ноуте.

Инструменты (18)

Тул

Что делает

tv_devices

Парк из devices.json: доступность и реальные capabilities каждого устройства

tv_install

Установка билда (.wgt / .ipk). uninstallFirst:true лечит «Author certificate not match»

tv_launch

Debug-запуск + attach по CDP. Режимы: свежий старт / reload / relaunch / attach. Дожидается bootReady из app-профиля и кладёт вердикт в attached.bootReady

tv_press

Клавиша пульта. durationMs = лонгтап; repeat+intervalMs = серия. Возвращает фокус до/после и inputMode

tv_state

Структурный снимок: url, заголовок, видимые сцены, фокус (текст, класс, путь, индекс/всего), попапы, счётчики

tv_snapshot

Раскладка экрана одним вызовом: ряды вокруг фокуса, их элементы с рефами e1, e2…, и neighbours — ближайший реф в каждую сторону. tv_goto {ref} ходит по ним точно

tv_record

Записать путь физическим пультом и скомпилировать в готовый tv_sequence + чек-лист. start перезапускает приложение, на экране горит «● REC», stop показывает кейс, файл создаёт write

tv_wait_for

Ожидание условия вместо sleep: focusText / element / elementGone / sceneName / selector / selectorGone / scene / text / expression / videoAdvancing

tv_goto

Жать направление, пока сфокусированный элемент не совпадёт с целью (имя из профиля, текст, селектор, testid). Ограничен maxSteps, дедлайном и детектом «фокус встал» / «обернулись по кругу». select: true — нажать ENTER по прибытии

tv_menu

Войти в меню приложения и выбрать раздел по имени; без имени — открыть и вернуть список разделов

tv_sequence

Весь кейс одним вызовом: вердикт, время и результат по каждому шагу, под device-lock

tv_screenshot

PNG кадра. В браузере работает всегда; на Tizen деградирует с пометкой (secure/overlay plane). Движок, который вообще не отдаёт кадр, ловится один раз: первый вызов выжигает таймаут, все следующие в этой сессии отказывают мгновенно

tv_console

Консоль / исключения / упавшие запросы с момента launch. Все уровни, фильтр, счётчик отброшенного буфером

tv_network

Полный лог запросов с момента launch: url, метод, статус, тело POST. Чтение тела ответа по requestId, экспорт в curl и HAR 1.2, ассерт expectRequest шагом кейса

tv_video_state

Программный снимок <video>: тикает ли currentTime (два замера), readyState, размеры, MediaError. Если <video> на странице нет вообще — читает объектный плеер Tizen (webapis.avplay) теми же полями плюс source: "avplay", кодек, битрейт и лестницу ABR

tv_evaluate

Произвольный JS в странице (escape hatch). На старых ТВ — только ES5

tv_profile

Запись JS CPU-профиля (start → действия → stop): файл .cpuprofile для DevTools + топ функций и файлов по self time. sourceMap деминифицирует топ на прод-сборке. Плюс метрики Performance.getMetrics (heap, DOM-узлы, слушатели, layout) — снимок на start и на stop, в ответе diff; action:"metrics" снимает их отдельно, без записи профиля

tv_heap

Снапшот кучи на устройстве (.heapsnapshot для DevTools → Memory → Load) + сводка по конструкторам и счётчик detached-нод; action:"diff" сравнивает два файла, как Comparison view

tv_sequence — шаги

{"launch": {"relaunch": true}}            // привести апп в известное состояние
{"press": "RIGHT", "repeat": 2}
{"longpress": "ENTER", "durationMs": 1600}
{"goto": {"direction": "DOWN", "text": "Library"}}
{"menu": "Settings"}
{"wait": {"scene": "player"}, "timeoutMs": 30000}
{"expect": {"selector": "[class*=context-menu]"}}
{"networkMark": true}                     // «считать запросы с этого места»
{"expectRequest": {"urlPattern": "track", "method": "POST", "bodyContains": "event_id"}}
{"eval": "document.title"}
{"sleep": 1500}
{"videoState": true, "expectAdvancing": true}
{"state": true}
{"profileStart": {"samplingIntervalUs": 1000}}
{"profileStop": {"path": "/tmp/scroll.cpuprofile", "sourceMap": "…/app.js.map"}}
{"metrics": true}                         // снимок Performance.getMetrics; {"collectGarbage": true} — с GC
{"snapshot": {"detail": "focus"}}         // структурный снимок раскладки в чекпоинте

expect — то же, что wait, но невыполнение валит шаг. stopOnFail по умолчанию true.

tv_press — клавиши

UP DOWN LEFT RIGHT ENTER BACK MENU INFO GUIDE SEARCH TOOLS CAPTION RED GREEN YELLOW BLUE PLAY PAUSE PLAY_PAUSE STOP REWIND FAST_FORWARD TRACK_NEXT TRACK_PREV RECORD CHANNEL_UP CHANNEL_DOWN PAGE_UP PAGE_DOWN VOLUME_UP VOLUME_DOWN VOLUME_MUTE EXIT DIGIT_0..9 (регистр не важен, можно сырой числовой keyCode). Коды взяты из платформенных input-слоёв Tizen (TvKeyCode) и webOS.

Лонгтап: {"key":"ENTER","durationMs":1600} — keydown, hold, keyup. Механика LongPressService: таймер стартует на keydown, keyup решает «клик или лонгтап». LG SSAP-пульт hold не выражает — поэтому синтетика, а не пульт.

tv_snapshot — раскладка экрана за один round-trip

Навигация без снапшота — это tv_presstv_statetv_presstv_state: агент не знает раскладку и щупает вслепую, а каждый ответ оседает в контексте. Снапшот отдаёт достаточно, чтобы спланировать 3–5 ходов сразу.

{"tier": "profile", "g": 1, "bytes": 1544,
 "focus": {"text": "Второй ролик", "ref": "e2", "index": 1, "total": 6},
 "rows": [{"i": 0, "focused": true, "items": [
            {"ref": "e1", "i": 0, "t": "Первый ролик про котиков"},
            {"ref": "e2", "i": 1, "t": "Второй ролик про горы", "focused": true}],
           "more": 3}],
 "neighbours": {"LEFT": "e1", "RIGHT": "e3", "UP": null, "DOWN": "e7"}}

detail: focus (только фокус, сцены, попапы — самый дешёвый), rows (по умолчанию), full (без фильтра по вьюпорту). Плюс maxRows, maxItemsPerRow, release. Шагом кейса — {"snapshot": true}, чтобы снять структуру в чекпоинте под операционной локой.

Два яруса рядов, и ответ говорит, какой сработал (tier):

  1. profile — блок snapshot из apps/<id>.json (row/item/label), либо tile/menu.item, если блока нет. Точно, и ряды могут нести подписи.

  2. generic — вообще без знания о приложении: ряд фокуса — это то, что уже считает tv_state (сиблинги с тем же первым классом), соседние ряды — сиблинги контейнера с такими же элементами.

Ни один не дал рядов — rows: [] и warning с указанием, что дописать в профиль. Структура не выдумывается: агент по ней пойдёт навигировать, и выдуманный ряд хуже отсутствующего.

neighbours — это геометрия раскладки, а не навигационный граф приложения. Ближайший центр в каждом направлении среди собранных элементов. Он доказывает, что ход tv_goto {ref} — одно нажатие; что приложение сделает по этому нажатию, он не знает.

Рефы протухают, и протухший отвергается, а не переразрешается. Их сносит следующий снапшот, навигация и TTL 60 с (та же константа, что у слота видео-сэмпла: карта живых Element на window — ложный ретейнер в tv_heap diff). Номера сквозные между снапшотами, поэтому e12 из прошлого поколения не может молча попасть на другой элемент. On-device всплывает и третий случай: список с переиспользованием DOM выкидывает ноду сам — ответ ref e1 points at an element that has left the DOM.

Компактность, по убыванию эффекта: фильтр по вьюпорту (на каталоге в 40 рядов решает всё), maxRows/maxItemsPerRow со счётчиком more, текст ≤32 символов, элемент несёт {ref, i, t} и больше ничего. bytes — размер самого ответа, чтобы было видно цену контекста.

Многоосевого pathfinding в tv_goto намеренно нет: снапшот уже сказал, на какой оси цель, и «дойти до X» — это два tv_goto, а не N проб. Неверная ветка 2D-поиска не бесплатна и не откатывается — вход в плитку стартует плеер и шлёт аналитику.

tv_record — записать путь пультом, получить кейс

Кейс сегодня пишется руками, а знание «как дойти до этого экрана» живёт в голове у того, кто дошёл. tv_record переворачивает это: человек проходит путь настоящим пультом, MCP пишет и компилирует.

tv_record {"action": "start"}                      # перезапускает приложение, зажигает «● REC»
   … человек ходит пультом …
tv_record {"action": "status"}                     # сколько клавиш реально дошло до страницы
tv_record {"action": "stop", "title": "Лонгтап на плитке"}    # компилирует и ПОКАЗЫВАЕТ, на диск не пишет
   … человек читает кейс: сохранить / поправить / выбросить …
tv_record {"action": "write"}                      # вот теперь файл

start сам перезапускает приложение. Скомпилированный кейс всегда открывается шагом {"launch": {"relaunch": true}} — значит запись, начатая посреди сессии, даёт кейс, чей первый шаг противоречит всем остальным: реплей стартует с каталога, а запись стартовала тремя экранами глубже, и кейс красный по причине, не имеющей отношения к приложению. Запись с холодного старта делает эти два состояния одним и тем же. relaunch: false — для пути, в который свежий запуск не приводит; тогда кейс несёт предупреждение об этом, потому что это свойство кейса, а не сессии.

stop ничего не пишет на диск. Скомпилированный кейс — это черновик: шаги выведены из того, что человек нажал, и отличить настоящий путь от неверного поворота может только он. Поэтому stop возвращает steps инлайном (то, что сразу скармливается в tv_sequence) и markdown — ровно тот файл, который был бы записан, плюс wouldWriteTo и exists. Показываете кейс человеку, спрашиваете — и только action: "write" создаёт файл.

«Поправить» — это тоже write. write принимает steps и сохраняет их вместо скомпилированных: выкинуть неверный поворот или дописать expect можно, не сочиняя markdown руками. Чек-лист при этом сохраняется и помечается как относящийся к исходной записи — он компилировался против других шагов.

Почему поллинг, а не Runtime.addBinding. Нормальный канал page→host — Chrome 51+, а парк начинается с Chrome 38 и WebKit 538. Поллинг здесь не деградация, а единственный режим, который есть на всех движках: одна реализация и ни одной непротестированной быстрой ветки.

Что делает компилятор (и почему именно так):

  • Серия одинаковых нажатий → один goto, но только пока фокус двигался на каждом нажатии. Встал на полпути — человек перелетел край списка; лишние нажатия выбрасываются с предупреждением. Записать чужой перелёт в кейс — это кейс, зелёный по неверной причине.

  • Физический автоповтор → {press, repeat}, а не longpress. Удержание DOWN на ТВ — это платформа, повторяющая клавишу, а синтетический лонгтап шлёт ровно один keydown и не прокрутит ничего. Удержание не-стрелки → {longpress} с реальной длительностью.

  • Наблюдения → ассерты: смена сцены даёт wait с таймаутом от замеренного (пол 5 с, потолок 30 с), появившийся/исчезнувший попап — expect по селектору, выведенному из его класса.

  • Простой выбрасывается целиком. sleep не эмитится никогдаcases/README.md запрещает его прямым текстом, а запечь в кейс чужое время на подумать хуже всего.

  • Сеть — только по whitelist record.watch из app-профиля, и только то, что в записи действительно случилось: ассерт на запрос, которого сценарий не делал, красен на первом же реплее по причине, не имеющей отношения к приложению. bodyContains не выводится автоматически — записанное тело несёт токены и id, такой ассерт зелёный один раз и красный всегда потом; вместо него строка в чек-листе.

  • Первым шагом всегда {"launch": {"relaunch": true}} — правило №1 из cases/README.md.

assert: minimal (только клавиши — кейс, зелёный при сломанном приложении), normal по умолчанию (сцены и попапы), rich (плюс ассерты на движение видео — хрупкость с первого дня, если она не нужна).

Реплей не запускается сам. На живом ТВ он стартует плеер и шлёт аналитику — это не побочный эффект остановки записи. Прогон через tv_sequence — отдельное действие по явной команде.

Коллизия имён — вопрос человеку, а не решение за него. stop заранее говорит exists: true, если по этому пути уже что-то лежит; write в такой файл не пишет и молча не суффиксует — возвращает {"written": false, "conflict": "<path>"}, а скомпилированный кейс держится в сессии до следующего start. Дальше — write с явным path либо overwrite: true.

Записи по умолчанию идут в cases/recorded/ и этот каталог в .gitignore: запись несёт селекторы, названия разделов и urlPattern'ы аналитики конкретного приложения. Публикация — осознанный ручной перенос. Переопределяется path или TV_DEBUG_CASES_DIR.

REC-бейдж (overlay: false отключает) — элемент с зарезервированным классом __tvdbg-rec, исключённый из попап-сканов, снапшота и дедупликации наблюдений, и переустанавливаемый вместе с рекордером при реаттаче. Человек с пультом должен видеть, что запись идёт, иначе каждый прогон начинается с вопроса «а оно вообще пишет?». В скриншоты бейдж попадёт — про это есть строка в чек-листе.

Деградация по движкам:

Движок

Что не так

Что возвращаем

весь парк

нет addBinding

поллинг — единственный путь, одна реализация

Chrome 38 (webOS 3), WebKit 538 (webOS 2)

нет Event.isTrusted

trusted: null у каждой клавиши и warning на start: синтетические нажатия других тулов попадут в запись как нажатия пульта. ⚠️ Chromium 47 (Tizen 3) isTrusted сообщает — порог Chrome 46 он проходит, и предупреждение там не выдаётся

webOS 2

доставка клавиш физического пульта в webview не гарантирована — часть кнопок съедает лаунчер

status отдаёт keysSeen; stop при нуле возвращает ok: false и причину, а не пустой кейс, похожий на успех

Tizen

скриншот виснет

шаг скриншота не эмитится, вердикт по tv_video_state — и это в чек-листе

любой

обрыв сокета

реаттач ловится по идентичности соединения, рекордер переустанавливается, в предупреждениях сказано, что события в дыре потеряны

Нового вида шага в tv_sequence намеренно нет: sequence — это агент за рулём, рекордер — человек за рулём; их смешение даёт кейс, записывающий сам себя.

tv_network — лог сети, тела, curl/HAR и ассерты

tv_console показывает только упавшие запросы. Успешный запрос с неправильным телом невидим ни одному кейсу — а это целый класс регрессов: аналитика потеряла поле, из параметров API выпал один, стат-событие ушло дважды. tv_network — про это.

tv_network {"action": "list", "urlPattern": "track", "method": "POST"}   # action по умолчанию
tv_network {"action": "body", "requestId": "1234.5"}                     # тело ответа
tv_network {"action": "curl", "requestId": "1234.5"}                     # команда для терминала/тикета
tv_network {"action": "har",  "path": "/tmp/case.har", "urlPattern": "api."}
tv_network {"action": "mark"}                                            # сдвинуть окно ассертов

list — фильтры urlPattern (подстрока или /regex/), method, status ("failed" | число | {"min":200,"max":299}), limit (по умолчанию 50, новейшие первыми). Запись: requestId, receivedAt, method, url (обрезан до 500), status, mimeType, resourceType, encodedDataLength, postData (обрезан до 1000, флаг postDataTruncated), failed + errorText, fromCache, redirectFrom / redirectedTo, inFlight. Плюс dropped — сколько вытеснено из кольцевого буфера: ассерт по вытесненному запросу провалился бы молча, поэтому счётчик едет в каждом ответе.

Ассерт в кейсе — шаг expectRequest (и условие {"request": {...}} в tv_wait_for):

{"networkMark": true}
{"menu": "Настройки"}
{"expectRequest": {"urlPattern": "track", "method": "POST",
                   "bodyContains": "event_id", "statusMax": 399, "timeoutMs": 8000}}
{"expectRequest": {"urlPattern": "stat.gif", "count": {"max": 1}, "timeoutMs": 3000}}
{"expectRequest": {"urlPattern": "ads", "absent": true, "timeoutMs": 3000}}

Окно матчинга — начало своего шага, как у остальных wait-условий. Но запрос — событие мгновенное, и тот, что улетел на предыдущем шаге, в окно уже не попадает: перед действием ставится {"networkMark": true}, и все expectRequest дальше считают от метки. Это главный практический момент тула.

absent: true и count.max ждут весь timeoutMs по определению: «ещё не пришло» и «не придёт» различимы только в конце окна, а дубль, прилетевший последним, — это ровно то, что ищут. Остальные формы возвращаются, как только матч есть.

Границы, каждая — свойство протокола, а не недоделка:

  • тела ответов не буферизуются на нашей стороне. getResponseBody читает буфер движка, и после навигации или релонча тела там нет. Поэтому action:"body" отвечает на «почему каталог пустой» сейчас и честно падает потом; повторить историю нельзя — ловить надо ассертом в момент кейса;

  • POST-тела несут токены и куки. В list тело режется до 1000 символов, целиком (до 64 КБ) хранится только ради curl/HAR и в отчёты не попадает. Гард на тело ответа — 256 КБ, на весь HAR — 50 МБ;

  • receivedAt — часы хоста, момент приёма события, а не CDP timestamp: монотонные часы движков разных поколений несравнимы ни между собой, ни с хостом, а wallTime в Chrome 38 нет. Для QA-ассертов скью приёма несуществен;

  • буфер сети — 1000 записей (у консоли 500): апп стреляет сетью на порядок чаще;

  • редирект переиспользует один requestId, поэтому каждый хоп пишется отдельной записью (redirectFrom / redirectedTo), а action:"body"/"curl" берут последний.

curl: Cookie, Authorization и *token*-заголовки заменяются на REDACTED, полный вариант — явным "raw": true. На движке без requestWillBeSentExtraInfo (Chromium <63 — весь парк старше tizen55) заголовки берутся из requestWillBeSent.request.headers, то есть это то, что знал апп, до того как движок навесил Cookie и UA; репро авторизованного запроса может не совпасть — приходит warning, а не тихое расхождение.

har: HAR 1.2 (creator tv-debug-mcp), открывается в DevTools → Network → Import, Charles, Insomnia — готовое вложение-пруф к багу. Заголовки пишутся как есть, без редактирования: HAR без Cookie ничего не воспроизводит. Отсюда правило — в публичный тикет такой файл не класть. Тела — best-effort и только «сейчас»: HAR в конце кейса будет с телами, снятый позже — метаданные, у таких entries comment: "body evicted", счётчик bodiesMissing в ответе. Тайминги — из response.timing; чего движок не дал, то -1 по спеке, а не выдуманное число.

Платформы: домен Network жив на всём парке (он и так включается на connect, cdp.js), getResponseBody — тоже. requestWillBeSentExtraInfo/responseReceivedExtraInfo (реальные wire-заголовки) — Chromium 63+, ниже curl предупреждает про куки.

tv_profile — CPU-профиль и метрики

CPU-профиль — единственный перф-домен, который жив на всём парке: Profiler.start/stop есть и в Chromium 69 (tizen55), и в Chrome 38 (webos3) — в отличие от Tracing. Метрики Performance.getMetrics требуют Chromium 60+, поэтому они едут прицепом и никогда не ценой профиля (см. «Метрики» ниже).

tv_profile {"action": "start"}          # опц. samplingIntervalUs, по умолчанию 1000
tv_goto    {"direction": "DOWN", …}     # то, что меряем
tv_profile {"action": "stop", "sourceMap": "…/app.js.map", "topN": 20}

stop отдаёт:

  • path — файл .cpuprofile. Открывается в Chrome DevTools → Performance → Load profile (кнопка ⤒). Сырой профиль в ответ тула не кладётся никогда — это сотни килобайт JSON;

  • summary.topFunctions — self time и % по функциям (аггрегат по одинаковым фреймам; total time рекурсивной функции считается один раз, а не на каждом уровне);

  • summary.topFiles — то же по файлам;

  • summary.special(program) / (garbage collector) / (idle) отдельно, в топ функций они не лезут;

  • metrics — diff Performance.getMetrics за окно записи (или null на движке без домена);

  • warning — если карта не прочиталась, если ни один топовый фрейм в ней не нашёлся, если формат легаси или если метрик на этом движке нет.

Self time = hitCount × средний интервал семплинга, где интервал выводится из самой записи (длительность / число хитов), а не из запрошенного samplingIntervalUs — старый движок вправе его проигнорировать.

Прод-сборка без sourceMap — это топ вида Xy/abc. Карту брать из той же сборки, что стоит на ТВ (<каталог сорсмапов сборки>/app.js.map); деминифицируются только топ-N фреймов, остальное DevTools разберёт сам по файлу.

Внутри tv_sequence — шагами profileStart/profileStop: сценарий держит операционный лок, отдельный tv_profile в него не влезет.

Форматы профиля различаются между поколениями движков и нормализуются оба: современный (nodes[], 0-based строки, микросекунды) и легаси Chrome 38 (head-дерево, 1-based строки, секунды). Строки в саммари всегда 1-based, как показывает DevTools. Файл легаси-формата современный DevTools может не открыть — об этом приходит warning, саммари при этом валидное.

tv_profile — метрики (heap, DOM, layout)

CPU-профиль показывает, где горит JS, и не видит ни память, ни layout. Performance.getMetrics — один дешёвый вызов, который отдаёт JSHeapUsedSize, JSHeapTotalSize, Nodes, Documents, JSEventListeners, LayoutCount, RecalcStyleCount и кумулятивные счётчики времени (LayoutDuration, RecalcStyleDuration, ScriptDuration, TaskDuration).

tv_profile {"action": "metrics"}                      # снимок здесь и сейчас
tv_profile {"action": "metrics", "collectGarbage": true}

start и stop снимают метрики сами, поэтому охота на утечку — это обычная запись:

tv_profile {"action": "start"}
tv_press   {"key": "DOWN", "repeat": 20}
tv_profile {"action": "stop", "collectGarbage": true}

stop вернёт

"metrics": {
  "windowSec": 12.4,
  "collectedGarbage": true,
  "values": {
    "Nodes":            {"before": 1200,     "after": 1650,     "diff": 450},
    "JSEventListeners": {"before": 340,      "after": 352,      "diff": 12},
    "JSHeapUsedSize":   {"before": 20000000, "after": 24500000, "diff": 4500000},
    "LayoutDuration":   {"before": 0.1,      "after": 0.4,      "diff": 0.3}
  }
}

Читать так: Nodes вырос на 450 после того, как навигация вернулась туда же — сцена не разбирает свой DOM. LayoutDuration — секунды layout-времени именно за окно записи.

Детали:

  • Отдаётся весь список метрик, какой прислал движок, без белых списков: набор в Chromium 69 и в свежем Chrome разный, а фильтр молча съел бы то, чего мы не ждали. Метрика, которую знает только один из двух снимков, остаётся в diff со стороной null — это тоже информация. Нечисловые значения проходят насквозь с diff: null;

  • windowSec — из Timestamp (монотонные часы движка), не из часов хоста: раунд-трипы CDP в окно не входят;

  • кумулятивные *Duration считаются с момента старта движка — смысл имеет только diff, не абсолют;

  • collectGarbage по умолчанию выключен. Форсированный GC — это пауза: внутри записи она искажает и профиль, и поведение слабого ТВ. Включать под охоту за утечкой, где несобранный мусор как раз и подделывает рост heap. Метод, которого на движке нет, даёт warning, а не ошибку;

  • снимок на start берётся до Profiler.start, на stopпосле Profiler.disable, чтобы сами вызовы метрик не попали в запись, которую они описывают.

Платформы: tizen55 (Chromium 69) ✓ и pc ✓ — полный набор из Performance.getMetrics. Движок без этого домена (Chromium ≤ 53: webos4, tizen3, webos3) не падает, а переключается на Memory.getDOMCounters: Nodes, Documents, JSEventListeners и Timestamp из performance.now() — именно те счётчики, на которых держится охота за утечкой DOM. В ответе — warning о том, что это фолбэк. Чего в нём нет намеренно: JSHeapUsedSize (единственный источник — квантованный по 100 КБ performance.memory, то есть стабильная ложь вместо честного отсутствия) и layout/style-счётчики (их пришлось бы выводить из счёта событий Tracing — другое измерение под тем же именем). Если и getDOMCounters нет, action:"metrics" честно падает с сообщением, а start/stop возвращают metrics: null плюс warning — потерять CPU-профиль из-за отсутствующих метрик нельзя.

В tv_sequence — шаг {"metrics": true}: им можно обрамить любой кусок сценария, не только тот, что покрыт записью профиля. Diff между двумя такими шагами считает вызывающий.

tv_heap — снапшоты кучи и diff

Метрики говорят, что выросло (JSHeapUsedSize, Nodes); снапшот кучи — кто это держит. Охота на утечку:

tv_heap {"action": "snapshot", "path": "/tmp/before.heapsnapshot"}
tv_menu {"item": "Настройки"}   # сценарий: то, после чего память не возвращается
tv_menu {"item": "История"}
tv_heap {"action": "snapshot", "path": "/tmp/after.heapsnapshot"}
tv_heap {"action": "diff", "before": "/tmp/before.heapsnapshot", "after": "/tmp/after.heapsnapshot"}

snapshot отдаёт path, bytes, chunks, durationMs и summary — это Summary view в числах: totalNodes, totalSize (shallow), detachedCount и topConstructors (count + shallow size). diffdelta по тоталам плюс topGrowth / topShrink: deltaCount, deltaBytes, countBefore, countAfter по каждому конструктору, ровно как Comparison view.

Границы, они же причина хранить файл:

  • retained size (доминаторы) и retainer-пути не считаются. Для «кто держит эту ноду» — открыть сохранённый файл в Chrome DevTools → Memory → Load. Тул отвечает на «что выросло», DevTools — на «за что зацепилось»;

  • парсится только nodes + strings; edges (в разы больше) не читается — на нём и стоит ретейнер-граф;

  • снапшот > 500 МБ не парсится вообще: JSON.parse такого файла стоит гигабайты RAM в Node. Ответ — summary.ok:false + warning, файл при этом целый и открывается в DevTools;

  • detached-ноды ловятся двумя способами: по имени (Detached HTMLDivElement) и по колонке detachedness (есть с ~Chromium 80). Флагнутая, но не переименованная нода попадает в тот же бакет Detached …, чтобы diff видел рост одной строкой.

Снапшот пишется на диск потоком, по чанкам HeapProfiler.addHeapSnapshotChunk (37 МБ = ~365 чанков): держать кучу ТВ целиком ещё и в памяти MCP незачем. Оборванный снапшот (таймаут, разрыв сокета) удаляется — половина файла это невалидный JSON, который не откроет ни DevTools, ни парсер; в ошибке сказано, что файл удалён.

Снапшот отвергается во время записи CPU-профиля: это полный GC и длинная пауза V8, внутри записи она измеряла бы саму себя. Сначала tv_profile action:"stop".

action:"diff" — чисто файловая операция: device не нужен, ТВ может быть выключен. Кэша нет, оба файла парсятся заново — кэш по пути соврал бы на перезаписанном снапшоте.

Платформы: pc ✓, tizen55 ✓, webos3 (Chrome 38) ✓ — HeapProfiler жив даже там (37 МБ / 406k нод / 13 с на живом LG 49UJ639V, diff после сценария показал +8.3 МБ и +1859 detached). Оговорка Chrome 38: у нативных нод self_size = 0, поэтому detachedSize там всегда 0 — считать надо detachedCount.

С tv_sequence намеренно не интегрирован: снапшот на слабом ТВ — это десятки секунд, тяжёлый шаг внутри сценария размыл бы тайминги остальных шагов. Порядок «снапшот → сценарий → снапшот → diff» точности окна не теряет.

Требования к устройствам

Три независимых чек-листа: ТВ Samsung, ТВ LG, браузер на ноуте. Каждый кончается командой, которая отвечает «готово / не готово» до того, как MCP скажет «устройство недоступно».

Tizen (Samsung)

  1. Developer Mode на ТВ: Apps → набрать 12345 на пульте → Developer mode: On → вписать IP машины, с которой будете подключаться → перезагрузить ТВ. Обновление прошивки его выключает.

  2. Tizen Studio CLI в PATH — нужны sdb и tizen: ~/tizen-studio/tools и ~/tizen-studio/tools/ide/bin.

  3. Подключение: sdb connect <ip>:26101 (порт по умолчанию, в devices.json переопределяется полем sdbPort).

  4. Author-сертификат Samsung, которым подписан .wgt. Билд, подписанный другим сертификатом, поверх старого не встаёт — tv_install {"uninstallFirst": true} сносит и ставит заново; это и есть лечение «Author certificate not match».

Проверка: sdb devices — устройство должно быть в состоянии device. unauthorized значит, что на ТВ не подтвердили подключение или Developer Mode слетел.

webOS (LG)

  1. Developer Mode: поставить приложение Developer Mode из LG Content Store, войти аккаунтом с developer.lge.com, включить Dev Mode. Ключ живёт ограниченное время, в приложении есть продление; протухший ключ снаружи выглядит как «устройство не отвечает».

  2. ares-cli: npm i -g @webosose/ares-cli.

  3. Завести устройство: ares-setup-device. ⚠️ В devices.json в поле device идёт имя из ares, а не IP — адресация у webOS-адаптера именная.

Проверка: ares-device-info -d <name> отдаёт модель и версию webOS; ares-setup-device --list показывает всё заведённое.

PC (браузерный режим)

  1. Chrome на машине. Путь по умолчанию — macOS-овый, переопределяется TV_DEBUG_CHROME или полем chromePath устройства.

  2. Dev-сервер приложения поднимает пользователь. MCP только проверяет, что url отвечает; он не запускает и не гасит чужой сервер.

Проверка: npm run check:browser — полный прогон против встроенной фикстуры, ТВ не нужен.

Парк устройств

devices.json (или путь в TV_DEBUG_CONFIG) — он в .gitignore, заводится копией devices.example.json. Файл перечитывается по mtime — правка подхватывается без рестарта MCP; дубли id и портов отвергаются с внятной ошибкой.

{
  "defaultDevice": "tizen",
  "devices": [
    {"id": "tizen", "platform": "tizen", "app": "myapp", "appId": "AbCdEfGhIj.myapp",
     "host": "192.168.1.10", "sdbPort": 26101, "localPort": 9955},
    {"id": "webos", "platform": "webos", "app": "myapp", "appId": "com.example.myapp", "device": "webos7"},
    {"id": "vidaa", "platform": "vidaa", "app": "myapp", "host": "192.168.1.13", "port": 9226},
    {"id": "pc-dev", "platform": "pc", "app": "myapp", "url": "http://localhost:1337"},
    {"id": "pc-dev-parity", "platform": "pc", "app": "myapp", "url": "http://localhost:1337",
     "inputMode": "synthetic"}
  ]
}

Поля устройства:

Поле

Для кого

Что задаёт

id

все

имя устройства в тулах и в TV_DEBUG_DEVICE. Уникально — дубли отвергаются на загрузке конфига

platform

все

tizen | webos | vidaa | pc

app

все

id app-профиля: читается apps/<app>.json. Без него доступна только дженерик-часть тулов

name, engine

все

человекочитаемые подписи, видны в выводе tv_devices

appId

tizen, webos

id приложения на устройстве (AbCdEfGhIj.myapp, com.example.myapp)

host

tizen, vidaa

IP телевизора. У vidaa это весь адрес: инспектор слушает на самом ТВ (в отличие от webOS, где device — имя из ares)

port

vidaa

порт DevTools-инспектора на ТВ. Не указан — узкий автоскан 9222–9230. На VIDAA 9 (50A53FEVS) это 9226, на старых прошивках встречался 9223. Dev-режим включается пультом: Home×3 → Up×2 → Right-Left-Right-Left-Right. ⚠️ Инспектор без авторизации — любой в LAN может подключиться; только для dev-девайса. appId не нужен: апп hosted, sideload/kill по сети недоступны (relaunch = перезагрузка страницы)

sdbPort

tizen

порт sdb, по умолчанию 26101

cliTarget

tizen

цель для tizen install -t, чтобы билд поехал в нужный ТВ на парке. Можно не указывать — выводится из третьей колонки sdb devices

localPort

tizen

локальный порт под sdb forward. Не указан — берётся свободный; два устройства с одним и тем же пином отвергаются, иначе они перекрёстно склеились бы

device

webos

имя устройства из ares-setup-device --list (не IP)

url

pc

адрес dev-сервера, например http://localhost:1337

chromePath

pc

бинарь Chrome именно для этого устройства; перебивает TV_DEBUG_CHROME

chromeArgs

pc

дополнительные аргументы к Chrome поверх обязательных

profileDir

pc

использовать этот каталог профиля вместо одноразового. Тогда профиль считается чужим и на dispose не удаляется — так живёт залогиненный Chrome, который не хочется логинить заново каждый прогон

inputMode

pc

trusted (по умолчанию) | synthetic — см. «Trusted vs synthetic». На ТВ клавиши всегда синтетические, поле там не читается

App-профиль

apps/<id>.json, привязка полем "app". Здесь живёт всё знание о приложении — чем помечен фокус, как выглядит сцена, где меню. Это то, что делает MCP переносимым: для другого приложения заводится второй файл, а не форк. Рабочий пример — apps/fixture.json (профиль встроенной фикстуры).

{
  "focus": ["._active"],
  "scene": {"container": "._scene", "strip": "layer__container|fullscreen"},
  "popup": ["[class*=popup]", "[class*=context-menu]"],
  "menu": {"openKey": "LEFT", "exitKey": "BACK",
           "root": ".menu__primary", "item": ".menu__primary .menu-cell",
           "title": ".menu-cell__title"},
  "tile": ".video-tile, .media-tile",
  "bootReady": {"selector": ".video-tile", "timeoutMs": 40000},
  "elements": {"catalog.tile": ".video-tile", "player.play": {"testid": "play-button"}},
  "scenes": {"catalog": "s-catalog", "player": "s-player"},
  "checks": {"homeSection": "Main", "popup": ".context-menu"}
}

Два неочевидных момента, ради которых профиль вообще существует:

  • Фреймворк может вешать класс фокуса на всю цепочку scene → container → list → tile, поэтому сфокусированный виджет — это самый глубокий match, а не первый. Первый — это сцена, и по нему навигация выглядит неподвижной.

  • root/item пришивайте к первому уровню меню. Вложенный раздел легко рисует свои строки теми же классами, и одна из них может называться как раздел верхнего уровня — тогда матч по всему меню выбирает вложенную строку и рапортует успех, пока апп никуда не уходил. По той же причине есть exitKey: внутри раздела клавиша открытия меню может не возвращать в сайдбар, надо сначала выйти по BACK.

Необязательный блок checks читают приёмочные скрипты (test/phase1-check.mjs), чтобы не быть прибитыми к одному приложению: homeSection — раздел, в который возвращаемся после захода в меню, popup — как выглядит контекстное меню тайла.

bootReady — вердикт приезжает с launch

bootReady дожидается tv_launch сам, сразу после аттача (только на свежем старте и на reload — аттач к живому приложению, которое стоит в плеере, не должен ждать плитку каталога). В ответе — attached.bootReady: {ok, elapsedMs, condition}, из кейсов уходит открывающий шаг {"wait": …}, повторяющий профиль.

Приложение, которое так и не загрузилось, вызов не валит: аттач-то удался, а это находка — бросок отнял бы tv_console/tv_network ровно тогда, когда они нужны. Приходит ok: false + warning. Отключается waitBoot: false (например, чтобы посмотреть на сам процесс загрузки). Условие проверяется со stableMs: 300, потому что «селектор виден» ≠ «контент отрисован».

Именованные элементы и сцены

Кейс, который пишет {"element": "catalog.tile"}, переживает правку вёрстки; кейс с .video-tile--v2 — нет, и один и тот же селектор расползается по десятку файлов. Реестр живёт в профиле:

"elements": {
  "catalog.tile":  ".demo-tile",
  "menu.settings": {"selector": ".demo-menu-item", "text": "Settings"},
  "player.play":   {"testid": "play-button"}
},
"scenes": {"catalog": "s-fixture", "player": "s-player"}

Строка — сокращение для selector. Имена принимают tv_goto (element), tv_wait_for (element / elementGone / sceneName) и те же шаги внутри tv_sequence.

Три правила, каждое — из грабель:

  • Разрешение возвращается эхом: в ответе resolvedFrom: {"element": "catalog.tile", "selector": ".demo-tile"}. Красный кейс обязан назвать селектор, который реально проверялся, иначе индирекция стоит дороже, чем экономит.

  • Опечатка падает громко и со списком известных имён — тот же контракт, что у tv_menu без блока menu. Молчаливый промах, притворившийся таймаутом, — худший вид отладки.

  • Текстовый квалификатор не теряется. menu.settings — это «.demo-menu-item и текст Settings»; выродиться в «любой .demo-menu-item» такое условие не имеет права, поэтому оно едет в предикат целиком. Элемент, заданный только текстом, wait-условием стать отказывается (CSS-селектора у него нет) — для этого есть focusText.

Мердж elements/scenesпоключевой, в отличие от focus/popup («непустой список побеждает целиком»): реестр имён аддитивен, и профиль, определивший один элемент, не должен терять остальные.

Браузерный режим (platform: "pc")

Тот же набор тулов против локального Chrome. Быстро, и скриншоты реально работают — на Tizen они виснут.

  • Chrome — наш: свой временный --user-data-dir, --remote-debugging-port=0 (порт читается из DevToolsActivePort, а не прибит к 9333), гасится и подчищается на dispose. К обычному браузеру пользователя MCP не цепляется.

  • Dev-сервер — ваш: MCP проверяет, что url отвечает, и не запускает и не гасит его. Запускать npm start в проекте приложения.

  • --disable-web-security обязателен: приложение, чей бутстрап ходит за токеном на другой origin, без него умирает на CORS и не стартует.

  • Network.setCacheDisabled(true) обязателен: dev-сервер отдаёт ES-модули, и переиспользованный браузер молча гоняет вчерашний код.

  • Тротлинг фоновых окон выключен (--disable-background-timer-throttling и два соседних флага). Как только живо больше одного pc-устройства, все окна кроме последнего Chrome считает фоновыми и режет им таймеры — а приложение под тестом на таймерах и держится (контракт лонгтапа — это setTimeout). У ТВ такой оптимизации нет, поэтому тротленный прогон — не «более строгий», а другой эксперимент.

Trusted vs synthetic — почему это два разных эксперимента

ТВ

Браузер по умолчанию

Браузер inputMode: "synthetic"

Механизм

page-side KeyboardEvent

Input.dispatchKeyEvent

page-side KeyboardEvent

isTrusted

нет

да

нет

Куда летит

document

реально сфокусированный элемент

document

Дефолтные действия браузера

нет

да

нет

Кейс может быть зелёным в браузере и красным на ТВ (ветка TV-keyCode не задействована) — и наоборот (Backspace уводит браузер назад). Поэтому: режим пишется в каждый вердикт, тихого фолбэка между режимами нет, а навигационные кейсы прогоняются ещё и на pc-dev-parity перед выводом «на ТВ будет так же».

Ключевые находки on-device (Tizen 5.5, sdb 4.2.36)

  • Debug-запуск: sdb -s <serial> shell 0 debug <appId> без аргумента-таймаута. С таймаутом launchpad отвечает closed. Инспектор на device-порту переживает закрытие sdb-канала, поэтому канал закрывается сразу после разбора порта.

  • Надёжный killsdb shell 0 was_kill <appId>. kill_app на retail-шелле молча no-op.

  • attach работает только через живой инспектор: второй debug по уже отлаживаемому аппу отвечает closed. Порт берётся из памяти сессии или из правила sdb forward --list, которое переживает рестарт MCP; поэтому forward намеренно не снимается на dispose.

  • Скриншот Page.captureScreenshot виснет (secure/overlay plane, HDCP) — тул отдаёт ok:false с пометкой. Для плейбека — tv_video_state + взгляд на ТВ.

  • localStorage переживает debug-релонч на 5.5 (проверено: маркер на месте после was_kill + свежего debug).

  • Загрузка каталога — 3.6–6.1 с, а не «22 секунды на всякий случай»: tv_wait_for быстрее и детерминированнее слепой паузы.

  • relaunch в браузерном режиме переиспользует ту же throwaway-профиль-директорию, а Chrome оставляет в ней DevToolsActivePort от прошлого запуска. Файл сносится перед спавном — иначе адаптер отдаёт порт, на котором уже никто не слушает (no inspectable page at http://127.0.0.1:…).

  • Весь page-side JS — строго ES5: Array.prototype.find появился в Chrome 45, а webOS 3 — это Chrome 38, и одна такая строчка роняла tv_video_state ровно на самом старом устройстве парка.

Как это устроено

Claude Code ── stdio ── server.js
                         ├── config.js      devices.json (перечитка по mtime + валидация)
                         ├── appprofile.js  apps/<app>.json — знания о приложении
                         ├── adapters/
                         │    tizen.js      sdb -s: install/was_kill/debug/forward
                         │    webos.js      ares: close→launch→inspect
                         │    pc.js         свой Chrome + navigate + setCacheDisabled
                         │    spawn-until-match.js  общий супервизор CLI-детей
                         ├── input/
                         │    synthetic.js  page-side KeyboardEvent (ТВ + parity)
                         │    trusted.js    Input.dispatchKeyEvent (браузер)
                         ├── cdp.js         CDP по WebSocket, единый путь дисконнекта
                         ├── keymaps.js     KeySpec {code, key, domCode} по платформам
                         ├── inject.js      page-side ES5: key dispatch, focus, video-state
                         ├── state.js       page-side ES5: снимок состояния и фокуса
                         ├── snapshot.js    page-side ES5: ряды, рефы, соседи по геометрии
                         ├── record-inject.js page-side ES5: слушатель пульта, дренаж, REC-бейдж
                         ├── recorder.js    таймлайн и компилятор кейса (чистые функции)
                         ├── wait.js        поллинг условий (общий для wait/goto/sequence)
                         ├── network.js     лог запросов: фильтры, curl, HAR
                         ├── profile.js     CPU-профиль: оба формата, саммари, sourcemap
                         ├── heap.js        .heapsnapshot: свод по конструкторам и diff
                         ├── ports.js       свободный локальный порт под forward
                         └── session.js     живая сессия: два лока, авто-реконнект, навигация

Устойчивость: упавший ТВ, выдернутый сокет или отсутствующий sdb валят один вызов тула, а не процесс MCP. ensureConnected сериализован — параллельные вызовы не запускают апп дважды.

Проверено

Прогон

Что

npm run check:offline

237/237 — честный статус офлайн-устройства, перечитка конфига без рестарта, отказ при дублях id, выживание без sdb; парсер CPU-профиля на фикстурах обоих форматов (совпадающие числа, спец-узлы отдельно, рекурсия не удваивается) и деминификация топа с деградацией до warning; парсер .heapsnapshot (свод по конструкторам, detached по имени и по колонке detachedness, diff роста/убыли, движок без detachedness, битый файл) и tv_heap action:"diff" вообще без устройства; сетевой лог — жизненный цикл записи на событиях, скормленных сессии без сокета (редирект двумя хопами, отказ, ранний extra-info, вытеснение из буфера со счётчиком), фильтры, генератор curl (секреты, экранирование, warning'и) и сборка HAR, плюс семантика expectRequest (absent и count.max ждут всё окно); легаси-путь evaluate без awaitPromise целиком на заглушенном _evaluateRaw (синхронное выражение — один раунд-трип, промис досетлливается на хосте, statement откатывается на необёрнутый вызов, зависший промис честно истекает и убирает за собой слот) и AVPlay-ветка видео-зондов в vm против фейкового webapis.avplay, включая негативный контроль «поток не открылся — found, но не advancing»; разрешение именованных элементов (шорткат-строка и объект, поключевой мердж, resolvedFrom в эхе, опечатка падает со списком известных имён, текстовый квалификатор не теряется); билдеры снапшота и рекордера как ES5 плюс жизненный цикл рефа в vm (протухший, вылетевший из DOM, отпущенный); и весь компилятор кейса на рукописных таймлайнах — схлопывание в goto и его гард на перелёте, автоповтор против лонгтапа, wait с выведенным таймаутом, простой без единого шага и без sleep, сеть только по whitelist, markdown round-trip, конверсия часов

node test/phase0-check.mjs

18/18 на Samsung UE50TU8510 — launch, движение фокуса, ES5-проба видео, limit:1, attach из другого процесса с сохранением состояния, выживание при обрыве сокета

node test/phase1-check.mjs

20/20 на Samsung UE49MU6103 (Tizen 3.0) — wait_for вместо сна, структурный фокус, goto до цели и его границы, заход в раздел меню и возврат обратно, кейс лонгтапа целиком; bootReady приезжает вместе с launch, tv_goto {element} ходит по именам из профиля устройства и эхом отдаёт селектор, tv_snapshot собрал 4 ряда из 20 элементов за 1544 байта, tv_goto {ref} дошёл по neighbours, реф прошлого поколения отвергнут. Селекторы берутся из app-профиля устройства, поэтому прогон не привязан к конкретному приложению

npm run check:webos2

19/19 на LG 40UF771V (webOS 2.2 / WebKit 538.2) — движок без CDP: аттач через /pagelist.json, page-side throw доезжает ошибкой (wasThrown, а не тихий undefined), фокус двигается через createEvent-фолбэк, tv_console ловит Console.messageAdded, скриншот отказывает честно и сессия его переживает, tv_sequence проходит целиком; рекордер ставится и на этом движке — дренаж видит клавиши, stop компилирует кейс, бейдж за собой убран. Устройство задаётся TV_DEBUG_DEVICE, по умолчанию webos2

npm run check:tizen3

25/25 на Samsung UE49MU6103 (Tizen 3.0 / Chromium 47, protocol 1.1) — движок без awaitPromise: промисное выражение вернуло значение, reject приехал ошибкой, throw не сломался; tv_video_state снял вердикт с webapis.avplay (PLAYING, advancedBy 1.218 с, h264 1280×720) и tv_wait_for {videoAdvancing:true} прошёл; action:"metrics" отдал DOM-счётчики вместо отказа; второй tv_screenshot отказал мгновенно; консоль и сеть живы; рекордер собрал клавиши и наблюдения и скомпилировал кейс, а предупреждение про isTrusted сверено с тем, что движок реально отвечает (Chromium 47 его сообщает — порог Chrome 46 он проходит). Устройство задаётся TV_DEBUG_DEVICE, по умолчанию tizen3

npm run check:browser

125/125 в Chrome — capabilities, отказ tv_install, свой Chrome на порту 0, навигация, реальный скриншот, кейс лонгтапа в trusted и synthetic, CPU-профиль (именованная busy-функция видна в топе, двойной start и сиротский stop отвергнуты, профилирование шагами сценария), tv_heap (файл на диске, подсаженная утечка TvDebugLeakItem видна в diff по имени вместе с detached-нодами, снапшот во время записи профиля отвергнут), tv_network (expectRequest по телу реального XHR, негативный ассерт, упавший запрос с errorText, чтение тела ответа, сгенерированный curl исполняется шеллом и доносит тело до сервера, HAR парсится как 1.2), bootReady (зелёный вердикт с launch, недостижимое условие даёт ok:false и всё равно аттачится, waitBoot:false пропускает ожидание), именованные элементы и сцены сквозь тулы, tv_snapshot (ряды, neighbours, tv_goto {ref} ровно за 3 нажатия, отказ по протухшему рефу, жёсткий бюджет bytes < 2000), и главная приёмка рекордера: start сам перезапустил приложение и вернул фокус в начало списка, запись → компиляция → зелёный реплей через tv_sequence, stop не создал файла и отдал ровно тот markdown, который потом лёг на диск байт в байт, правка шагов через action:"write" сохранилась вместе с пометкой в чек-листе, пустой override отвергнут, коллизия имён названа, REC-бейдж появляется и исчезает и не попадает в снапшот; уборка за собой

tv_heap on-device

LG 49UJ639V (webOS 3.9 / Chrome 38): снапшот 37 МБ / 406k нод / 1271 detached за 13 с; после сценария diff показал +8.3 МБ, +170k нод, +1859 detached с разбивкой по конструкторам. Целевой webos7 на момент прогона был недоступен

tv_network on-device

LG 49UJ639V (webOS 3.9 / Chrome 38): expectRequest по телу реального стат-запроса зелёный на живой навигации, красная ветка падает по таймауту с причиной, absent выжидает всё окно; body читает ответ, сгенерированный curl воспроизводится в терминале (200), HAR на 361 запись собрался с 360 телами и настоящими таймингами

Что этот движок умеет и чего нет, видно по прогону выше: postData приходит прямо в requestWillBeSent, редиректы и getResponseBody работают, а *ExtraInfo нет (Chromium <63) — заголовки в логе до-движковые, без Cookie, о чём curl предупреждает. resourceType на Chrome 38 врёт (главный документ пришёл как Image), фильтровать надо по URL.

webOS-адаптер (close → launch → inspect, честный freshLaunch) прогнан on-device на LG 49UJ639V; остальные LG из ares-setup-device --list бывают недоступны (connection timed out) — это про сеть, не про адаптер.

Диалект Runtime.evaluate определяется по протоколу, а не по движку, поэтому двухзвонковый сэмпл берут все до-M54 движки. Проверено on-device на LG (webOS 4 / Chromium 53): awaitPromise: true там тоже отдаёт {}, то есть промисное выражение возвращало пустой объект и tv_video_state на этом устройстве был тихо сломан — теперь отдаёт полный набор полей. Слоты сэмпла ведут себя так же, как на webOS 2 (два токена сосуществуют, потерянный отвечает sampleLost, на странице ничего не остаётся). Tizen 3 (Chromium 47) — из той же протокольной эпохи.

Промис на таком движке больше не теряется нигде, а не только в видео-зонде: evaluate сам досетлливает его на хосте — выражение оборачивается так, что результат промиса ложится JSON-строкой в персональный слот на window, а хост опрашивает слот до значения или таймаута. Синхронное выражение при этом стоит ровно один раунд-трип, как раньше; statement (throw new Error(...)) в обёртку не влезает и откатывается на необёрнутый вызов, поэтому page-side throw по-прежнему доезжает ошибкой. Регресс on-device на LG (webOS 4 / Chromium 53), 19/19: промисное tv_evaluate вернуло значение (было {}), reject приехал ошибкой, throw не сломался, фокус/tv_state/tv_sequence живы, скриншот снимается обоими вызовами, а action:"metrics" — который на этом движке раньше просто падал — отдал Nodes/Documents/JSEventListeners/Timestamp из Memory.getDOMCounters с честным warning и без выдуманного heap.

npm run check:tizen3 (test/tizen3-check.mjs, устройство через TV_DEBUG_DEVICE) — приёмка того же набора на Chromium 47, 19/19 on-device: пре-M54-движок, промис и его reject, throw, AVPlay-зонд на играющем потоке (если апп до плеера не доехал — шаг помечается SKIP и просит перезапуск с TV_DEBUG_PLAYING=1), metrics-фолбэк, мгновенный отказ второго скриншота, консоль и сеть.

Две ловушки этого движка, всплывшие на приёмке: UA у Tizen-вебвью вообще без токена Chrome/ (SMART-TV; LINUX; Tizen 3.0 … AppleWebKit/538.1) — сравнивать версию Chromium по UA там нечего; и getCurrentStreamInfo().extra_info отдаёт строки (Width: "1280", Bit_rate: "2986443"), поэтому зонд приводит их к числам — иначе сравнение битрейта с порогом молча сравнивало бы строки.

tv_video_state on-device на webOS 2: awaitPromise: true на этом движке отдаёт {} — промис не дожидается, поэтому промисное выражение там бесполезно и сэмпл идёт двумя вызовами с паузой на стороне хоста. На играющем видео — advancing: true, advancedBy: 2 за паузу 2000 мс, 1920×800. Слот сэмпла ключуется по номеру вызова: два перекрывающихся сэмпла вернули независимые результаты (advancedBy 21.52 и 11.48 от своих баз), потерянный слот отвечает sampleLost, а не выдуманным сэмплом, и на странице не остаётся ничего. Лока здесь намеренно нет: tv_sequence уже держит операционный лок на шаге {"videoState":true}, а он не реентрантный.

test/smoke.mjs — ad-hoc прогон произвольного списка вызовов; test/harness.mjs — общий stdio-клиент для всех проверок и хелпер appTargets, который вытаскивает селекторы из app-профиля.

check:browser дополнительно прогоняется против вашего живого dev-сервера, если задать обе переменные:

TV_DEV_URL=http://localhost:1337 TV_DEV_APP=myapp npm run check:browser

Демо-кейсы

cases/fixture-smoke.md — кейс против встроенной фикстуры, исполним сразу после клона, без ТВ и без dev-сервера. Формат и правила, выведенные из реальных прогонов, — в cases/README.md.

Статику фикстуры под этот кейс поднимает человек и оставляет работать:

python3 -m http.server 8080 --bind 127.0.0.1 --directory test/fixture

Устройство pc-fixture с этим адресом и профилем apps/fixture.json уже есть в devices.example.json. Не путать с npm run check:browser: приёмочный скрипт поднимает свою статику на свободном порту сам и пишет себе одноразовый devices.json — ему ничего заранее запускать не надо.

Дальше

  • webOS on-device прогон (в т.ч. webOS 3 = Chrome 38: ES5-инъекция, работоспособность скриншота и легаси-формат CPU-профиля — парсер написан по спецификации Chrome 38 и проверен на фикстуре, но не на живом LG).

  • Прогон tv_profile на ТВ с sourceMap от прод-сборки (карта Closure парсится и позиции разрешаются — проверено офлайн).

  • tv_network on-device: приёмка «инструмент отвечает на исходный вопрос» — переход по разделам и зелёный expectRequest по аналитике на здоровой сборке; на webOS 3 (Chrome 38) факт-чек протокола: postData в requestWillBeSent, getResponseBody, поведение по редиректам. Пока прогонялось только в браузере.

  • Остальные перф-инструменты (FPS, Tracing) — отдельным заходом, они не покрывают весь парк.

  • Sampling heap profiler (HeapProfiler.startSampling/stopSampling) — кто аллоцирует; tv_heap отвечает на другой вопрос (кто держит уже живую память).

  • Авто-повтор удержанной d-pad-клавиши (holdRepeatMs): сейчас durationMs шлёт один keydown, что верно для лонгтапа, но не воспроизводит скролл ленты зажатой стрелкой. Обход списков закрывает tv_goto, а tv_record не заминает разницу — физический автоповтор он компилирует в {press, repeat}, а не в лонгтап.

  • Доставка клавиш физического пульта на webOS 2 проверена только синтетикой: рекордер там ставится и дренаж клавиши видит, но часть кнопок LG съедает лаунчер, и ответ даст только человек с пультом в руках — на то и tv_record action:"status" с keysSeen.

  • Параллельный прогон одного кейса на N ТВ (адресация -s для этого уже есть).

  • Allure TestOps (чтение кейсов) + allurectl (заливка результатов).

  • WS-пульт (SSAP / Samsung remote) для системных кейсов HOME/suspend, которые page-level синтетика не покрывает.

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Ediand11/tv-debug-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server