tv-debug-mcp
An MCP server for semi-manual QA of TV apps: an agent drives a real Smart TV (Tizen/webOS) or local Chrome over raw CDP via remote keys, while a human confirms what only eyes can check.
Discover the park (
tv_devices): list configured devices and which operations each actually supports.Deploy builds (
tv_install): install.wgt/.ipk, optionally uninstalling first to fix "Author certificate not match".Start sessions (
tv_launch): debug-launch + CDP attach with fresh start /reload/relaunch/attachmodes.Drive the remote (
tv_press): named or raw keyCodes, long-press viadurationMs, bursts viarepeat+intervalMs; returns the focused element after each press.Navigate deterministically (
tv_goto,tv_menu): press a direction until the focused element matches text/selector/testid, with edge and wrap-around detection; open the app menu and select a section by name.Read state (
tv_state,tv_evaluate): structured snapshot of url, scenes, focus, popups, counts; plus an arbitrary ES5 JS escape hatch with promise awaiting.Wait on conditions instead of sleeping (
tv_wait_for): text, scene, selector, focusText, expression,videoAdvancing, with optionalstableMsto avoid half-rendered frames.Verify playback (
tv_video_state): two-samplecurrentTimecheck plus readyState, size, MediaError — reliable when screenshots come back black.Capture and inspect (
tv_screenshot,tv_console): PNG frames (with the Tizen secure-plane caveat) and buffered console/exceptions/failed requests with filtering and dropped-count.Run whole cases (
tv_sequence): many steps in one call under the device lock, with per-step verdicts andstopOnFail.Profile performance (
tv_profile): CPU profile with source-map de-minification, plusPerformance.getMetricsbefore/after diffs (heap, DOM nodes, listeners, layout).Hunt leaks (
tv_heap): take.heapsnapshotfiles and diff two of them by constructor growth/shrink and detached-node counts.Supports a legacy park down to Chrome 38 via a single bare-CDP code path; an app-profile file supplies selectors/scenes/menu knowledge, and a failed device fails one tool call, not the process.
Note: the given schema exposes 15 tools; the README also documents
tv_snapshot,tv_record,tv_networkandtv_resources, which are absent here.
Allows testing Smart TV apps on Samsung Tizen devices via Chrome DevTools Protocol, providing tools for device management, app control, navigation, and debugging.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@tv-debug-mcp Press OK and check video state."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
tv-debug-mcp
MCP-сервер для полуручного прогона QA-кейсов на реальных Smart TV (Tizen / webOS) и на локальном Chrome — через Chrome DevTools Protocol. Агент управляет приложением: навигация пультом, лонгтап с точными таймингами, переходы по меню, чтение консоли и состояния плеера. Человек подтверждает то, что можно проверить только глазами.
Закрывает боль ручного тестирования сложных кейсов (лонгтап, перемещения, меню) на всём парке устройств, включая старые.
Быстрый старт
git clone git@gitlab.corp.mail.ru:vkvideo/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.
Чтобы гонять своё приложение, а не фикстуру:
в
devices.jsonописать устройство (platform,appId,hostдля ТВ илиurlдля браузера) — поля и их проверки описаны в «Парк устройств»;завести
apps/<id>.jsonс селекторами приложения и сослаться на него полем"app"— см. «App-профиль», готовый пример лежит вapps/fixture.json;для ТВ — Developer Mode на устройстве и подключённый
sdb/ares.
Node ≥ 18. Зависимости: @modelcontextprotocol/sdk, ws, source-map-js (чистый JS-порт source-map 0.6, без wasm — важно для офлайн-запуска).
Related MCP server: paparazzi
Установка в 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 и берут.
Переменные окружения
Переменная | Что задаёт | Если не задана |
| путь к |
|
| бинарь Chrome для |
|
| устройство для платформенных приёмок ( |
|
| куда |
|
|
| выключено |
| дополнительный прогон | прогон только против встроенной фикстуры |
| куда падают артефакты ( | системный временный каталог |
Зачем не 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+, ноль зависимостей на устройстве. Тот же набор тулов работает и против браузера на ноуте.
Инструменты (19)
Тул | Что делает |
| Парк из |
| Установка билда ( |
| Debug-запуск + attach по CDP. Режимы: свежий старт / |
| Клавиша пульта. |
| Снимок: url, заголовок, видимые сцены, фокус (текст, путь, индекс/всего), попапы, счётчики. По умолчанию текстом, |
| Раскладка экрана одним вызовом: ряды вокруг фокуса, их элементы с рефами |
| Записать путь физическим пультом и скомпилировать в готовый |
| Ожидание условия вместо |
| Жать направление, пока сфокусированный элемент не совпадёт с целью (имя из профиля, текст, селектор, testid). Ограничен |
| Войти в меню приложения и выбрать раздел по имени; без имени — открыть и вернуть список разделов |
| Весь кейс одним вызовом под device-lock. Зелёный навигационный шаг — одна строка |
| PNG кадра. В браузере работает всегда; на Tizen деградирует с пометкой (secure/overlay plane). Движок, который вообще не отдаёт кадр, ловится один раз: первый вызов выжигает таймаут, все следующие в этой сессии отказывают мгновенно |
| Консоль / исключения / упавшие запросы с момента launch, с дедупом: повтор несёт |
| Полный лог запросов с момента launch: url, метод, статус, тело POST. Чтение тела ответа по |
| Программный снимок |
| Произвольный JS в странице (escape hatch). На старых ТВ — только ES5. Ответ режется на 16 КБ ( |
| Запись JS CPU-профиля ( |
| Снапшот кучи на устройстве ( |
| webOS и Tizen. CPU % и память всего ТВ и процесса приложения (RSS), снятые на самом ТВ: на webOS через |
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.
Подробные справки тулов — MCP-ресурсы
Описания в tools/list умышленно короткие (весь список ≤ 10 КБ: часть клиентов шлёт его модели на каждом ходу). Полная справка каждого тула — действия, формы шагов, формы ответов — лежит ресурсом tv-debug://docs/<tool> (например tv-debug://docs/tv_sequence, tv-debug://docs/tv_network); агент читает её один раз через resources/read, когда нужна.
Ответы отдаются компактным JSON без отступов. Навигационные тулы несут свою цену: ms (время) и evals (число CDP-вызовов) — по ним видно, тормозит шаг или ТВ. TV_DEBUG_TIMING=1 дополнительно пишет в stderr каждый CDP-вызов с его временем.
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_press → tv_state → tv_press → tv_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):
profile— блокsnapshotизapps/<id>.json(row/item/label), либоtile/menu.item, если блока нет. Точно, и ряды могут нести подписи.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с таймаутом3×от замеренного (пол 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, исключённый из попап-сканов, снапшота и дедупликации наблюдений, и переустанавливаемый вместе с рекордером при реаттаче. Человек с пультом должен видеть, что запись идёт, иначе каждый прогон начинается с вопроса «а оно вообще пишет?». В скриншоты бейдж попадёт — про это есть строка в чек-листе.
Деградация по движкам:
Движок | Что не так | Что возвращаем |
весь парк | нет | поллинг — единственный путь, одна реализация |
Chrome 38 (webOS 3), WebKit 538 (webOS 2) | нет |
|
webOS 2 | доставка клавиш физического пульта в webview не гарантирована — часть кнопок съедает лаунчер |
|
Tizen | скриншот виснет | шаг скриншота не эмитится, вердикт по |
любой | обрыв сокета | реаттач ловится по идентичности соединения, рекордер переустанавливается, в предупреждениях сказано, что события в дыре потеряны |
Нового вида шага в 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 (по умолчанию 25, новейшие первыми). Запись: 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— часы хоста, момент приёма события, а не CDPtimestamp: монотонные часы движков разных поколений несравнимы ни между собой, ни с хостом, а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— diffPerformance.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, чтобы сами вызовы метрик не попали в запись, которую они описывают.
pressure — сбросить кэши перед замером
GC освобождает только JS-объекты. Декодированные картинки, GPU-текстуры и прочие кэши рендерера Chromium отпускает по сигналу нехватки памяти. Без этого кэш, который ушёл бы под реальным давлением, в tv_resources выглядит как утечка.
tv_resources {"action": "start"}
tv_sequence {"steps": [...]} # сценарий ×N
tv_profile {"action": "metrics", "pressure": true} # GC + critical pressure + 8 с
tv_resources {"action": "stop"} # смотреть last у rssMb / gpuMbpressure: true работает в metrics, start и stop (и в шаге tv_sequence {"metrics": {"pressure": true}}): HeapProfiler.collectGarbage, затем Memory.simulatePressureNotification {level: "critical"}, затем 8 с ожидания, потом снимок. В ответе stop — metrics.collectedGarbage: true. Рост, который пережил pressure, — кандидат в утечку. Рост, который после него ушёл, — кэш. Движок без simulatePressureNotification даёт warning, а не ошибку.
На Tizen 5.5 каждый второй такой сигнал подряд закрывает сокет инспектора. Приложение при этом живёт, pid тот же. Снимок метрик всегда берётся на свежей сессии (реаттач делается сам). Сокет иногда закрывается и позже, уже после ответа. Тогда следующий вызов, попавший на этот момент, один раз падает с CDP connection closed, а следующий за ним снова переподключается сам.
Счётчики *Count и *Duration (LayoutCount, TaskDuration…) живут в рамках CDP-сессии и после реаттача начинаются с нуля. Поэтому если pressure в stop пересоздал сессию, у этих счётчиков в diff стоит null (before/after остаются) и приходит warning. Абсолютные значения (Nodes, JSHeapUsedSize) и windowSec честные и в этом случае.
Платформы: 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). diff — delta по тоталам плюс 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» точности окна не теряет.
tv_resources — CPU и память на самом ТВ (webOS, Tizen)
tv_profile и tv_heap видят JS-кучу и DOM. Процесс рендерера держит больше: декодированные картинки, текстуры слоёв, буферы <video>. А убивает приложение система, глядя на свою свободную память. tv_resources снимает именно это, прямо на ТВ:
tv_resources {"action": "start"} # фоновые сэмплеры, ждёт первый замер
tv_sequence {"steps": [...]} # сценарий: скролл, плеер, 20 заходов в раздел
tv_resources {"action": "read"} # промежуточная сводка, замер идёт дальше
tv_resources {"action": "stop"} # финальная сводка, CSV остаются на дискеПод капотом два фоновых ares-device: -r -t N -s system.csv (весь ТВ) и -r -id <appId> -t N -s app.csv (приложение). Каждый держит одну dev-mode SSH-сессию и раз в intervalSec (1..60, по умолчанию 1) читает /proc/stat, /proc/<pid>/stat, free -k. pid приложения ares берёт из applicationManager/dev/running (webprocessid у web-аппа). Один удалённый цикл вместо двух детей не собрать без своего SSH-клиента: ares-shell -r отдаёт первый чанк вывода и отключается.
Ответ read/stop: system: {cpuPct, memAvailableMb, memUsedMb, swapUsedMb, memTotalMb} и app: {cpuPct, rssMb, pids}, у каждой серии {min, max, avg, first, last, delta, maxAt}.
Как читать:
CPU % — доля всех ядер (100 = заняты все), как считает ares. Разрешение одна секунда: фризы и long tasks ищутся
tv_profile, не здесь;ровная
JSHeapUsedSizeизtv_profileпри растущемapp.rssMb.delta— нативная память (картинки, видео), а не JS-утечка;больше одного pid в
app.pids— приложение перезапустилось или его убили посреди окна;нет замеров приложения — оно не запущено или его нет в
dev/running(ares сопоставляет appId с pid только оттуда). Системные цифры при этом есть;сэмплер процессов читает все
/proc/<pid>/statна каждом тике, это тоже нагрузка на ТВ. Она попадает в системный CPU, не в CPU приложения.
start не требует tv_launch и не трогает CDP-сессию. Недоступный ТВ или протухший dev-mode отказывают на start с текстом ошибки ares. Упавший посреди окна сэмплер попадает в warnings. appId (по умолчанию из devices.json) и path (каталог для CSV, по умолчанию TMPDIR) принимаются, но в схеме тула их нет, чтобы tools/list оставался в пределах 10 КБ.
Tizen
Монитора ресурсов, как у LG, на Tizen нет, но приложение может читать свой /proc через tizen.filesystem.openFile (Tizen 5.0+). На этом и строится Tizen-ветка: таймер на хосте раз в intervalSec (по умолчанию 2) делает один Runtime.evaluate по CDP-сессии приложения. Страница отдаёт сырой текст файлов, парсит хост:
файл | что из него берётся |
|
|
|
|
| CPU приложения и всего ТВ: |
|
|
| GPU-память по pid, КБ |
Ответ в той же форме, что на webOS, плюс app: {pssMb, pssAnonMb, gpuMb, swapMb} и system.cmaFreeMb. memUsedMb здесь равен MemTotal − MemAvailable. CSV нет: замеры хранятся в памяти MCP.
Отличия от webOS:
нужен
tv_launch: без CDP-сессииstartотказывает и просит запустить приложение. Сам семплер никогда не переподключается и не перезапускает приложение, иначе он поднимал бы заново приложение, чью смерть меряет. Пока сессии нет, тики падают и считаются вwarnings, пока любой другой вызов не сделает реаттач.appIdна Tizen игнорируется:/proc/self— это то приложение, к которому подключена сессия;чтение файлов идёт на главном потоке приложения (несколько десятков КБ текста за тик) и попадает в CPU приложения. Поэтому по умолчанию 2 с, а не 1;
на Tizen 5.5
smaps_rollupнет, а сумма по/proc/self/smaps— это ~8 МБ текста за чтение, на таймере такое не гоняют. ПоэтомуpssMb: nullи предупреждение, RSS и GPU при этом есть;GPU в
smapsпроцесса не попадает вообще. Рост текстур и видео на Tizen виден вgpuMb, а не в RSS;перезапуск приложения меняет
/proc/self: вapp.pidsпоявится второй pid. Тики, упавшие посреди окна (оборвалась сессия, ТВ выключили), считаются вwarnings;время в
window/maxAtберётся с хоста, а не с ТВ;ниже Tizen 5.0
openFileнет, иstartотказывает с этим текстом. Староеresolve().readAsTextна/procотдаёт пустую строку, XHR наfile:///procпадает.
Платформы: webOS и Tizen. webOS прогнан на живых webOS 7 и webOS 3.9 (Chrome 38): системные серии и RSS/CPU приложения на обоих, pid web-аппа через dev/running резолвится и на 3.9. Парсеры дополнительно проверены офлайн на CSV, который пишет csv-writer из ares 3.2.1. Tizen прогнан на Tizen 5.5 (Chromium 69): RSS, GPU, CMA и CPU снимаются, PSS — нет (smaps_rollup отсутствует). Наличие smaps_rollup на Tizen 8.0 проверено руками, сам тул там не прогонялся.
Требования к устройствам
Три независимых чек-листа: ТВ Samsung, ТВ LG, браузер на ноуте. Каждый кончается командой, которая отвечает «готово / не готово» до того, как MCP скажет «устройство недоступно».
Tizen (Samsung)
Developer Mode на ТВ: Apps → набрать
12345на пульте → Developer mode: On → вписать IP машины, с которой будете подключаться → перезагрузить ТВ. Обновление прошивки его выключает.Tizen Studio CLI в PATH — нужны
sdbиtizen:~/tizen-studio/toolsи~/tizen-studio/tools/ide/bin.Подключение:
sdb connect <ip>:26101(порт по умолчанию, вdevices.jsonпереопределяется полемsdbPort).Author-сертификат Samsung, которым подписан
.wgt. Билд, подписанный другим сертификатом, поверх старого не встаёт —tv_install {"uninstallFirst": true}сносит и ставит заново; это и есть лечение «Author certificate not match».
Проверка: sdb devices — устройство должно быть в состоянии device. unauthorized значит, что на ТВ не подтвердили подключение или Developer Mode слетел.
webOS (LG)
Developer Mode: поставить приложение Developer Mode из LG Content Store, войти аккаунтом с developer.lge.com, включить Dev Mode. Ключ живёт ограниченное время, в приложении есть продление; протухший ключ снаружи выглядит как «устройство не отвечает».
ares-cli:
npm i -g @webosose/ares-cli.Завести устройство:
ares-setup-device. ⚠️ Вdevices.jsonв полеdeviceидёт имя из ares, а не IP — адресация у webOS-адаптера именная.
Проверка: ares-device-info -d <name> отдаёт модель и версию webOS; ares-setup-device --list показывает всё заведённое.
PC (браузерный режим)
Chrome на машине. Путь по умолчанию — macOS-овый, переопределяется
TV_DEBUG_CHROMEили полемchromePathустройства.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 app-профиля: читается |
| все | человекочитаемые подписи, видны в выводе |
| tizen, webos | id приложения на устройстве ( |
| tizen, vidaa | IP телевизора. У vidaa это весь адрес: инспектор слушает на самом ТВ (в отличие от webOS, где |
| vidaa | порт DevTools-инспектора на ТВ. Не указан — узкий автоскан 9222–9230. На VIDAA 9 (50A53FEVS) это 9226, на старых прошивках встречался 9223. Dev-режим включается пультом: |
| tizen | порт sdb, по умолчанию |
| tizen | цель для |
| tizen | локальный порт под |
| webos | имя устройства из |
| pc | адрес dev-сервера, например |
| pc | бинарь Chrome именно для этого устройства; перебивает |
| pc | дополнительные аргументы к Chrome поверх обязательных |
| pc | использовать этот каталог профиля вместо одноразового. Тогда профиль считается чужим и на dispose не удаляется — так живёт залогиненный Chrome, который не хочется логинить заново каждый прогон |
| pc |
|
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"},
"settle": {"quietMs": 150, "changeTimeoutMs": 1200},
"checks": {"homeSection": "Main", "popup": ".context-menu"}
}settle — пороги «нажатие отработало»: changeTimeoutMs — сколько ждать, что фокус вообще сдвинется, quietMs — сколько он должен стоять на месте после. Тишина DOM (MutationObserver, class + childList) — подсказка, не условие: если приложение мутирует DOM непрерывно (оверлей со счётчиком), ожидание ограничено 3×quietMs неподвижного фокуса. Без блока — платформенные дефолты (pc 80/800, vidaa 120/1000, ТВ 150/1200). tv_goto и открытие tv_menu на «фокус не сдвинулся» жмут ещё раз через 300 мс, прежде чем считать это краем списка — ТВ-приложения изредка роняют клавишу во время анимации.
Два неочевидных момента, ради которых профиль вообще существует:
Фреймворк может вешать класс фокуса на всю цепочку scene → container → list → tile, поэтому сфокусированный виджет — это самый глубокий match, а не первый. Первый — это сцена, и по нему навигация выглядит неподвижной.
root/itemпришивайте к первому уровню меню. Вложенный раздел легко рисует свои строки теми же классами, и одна из них может называться как раздел верхнего уровня — тогда матч по всему меню выбирает вложенную строку и рапортует успех, пока апп никуда не уходил. По той же причине естьexitKey: внутри раздела клавиша открытия меню может не возвращать в сайдбар, надо сначала выйти по BACK.
Необязательный блок checks читают приёмочные скрипты (test/phase1-check.mjs), чтобы не быть прибитыми к одному приложению: homeSection — раздел, в который возвращаемся после захода в меню, popup — как выглядит контекстное меню тайла.
bootReady — вердикт приезжает с launch
bootReady дожидается tv_launch сам, сразу после аттача (только на свежем старте и на reload — аттач к живому приложению, которое стоит в плеере, не должен ждать плитку каталога). В ответе — 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 — почему это два разных эксперимента
ТВ | Браузер по умолчанию | Браузер | |
Механизм | page-side |
| page-side |
| нет | да | нет |
Куда летит |
| реально сфокусированный элемент |
|
Дефолтные действия браузера | нет | да | нет |
Кейс может быть зелёным в браузере и красным на ТВ (ветка 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-канала, поэтому канал закрывается сразу после разбора порта.Надёжный kill —
sdb 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 сериализован — параллельные вызовы не запускают апп дважды.
Проверено
Прогон | Что |
| 237/237 — честный статус офлайн-устройства, перечитка конфига без рестарта, отказ при дублях id, выживание без |
| 18/18 на Samsung UE50TU8510 — launch, движение фокуса, ES5-проба видео, |
| 20/20 на Samsung UE49MU6103 (Tizen 3.0) — |
| 19/19 на LG 40UF771V (webOS 2.2 / WebKit 538.2) — движок без CDP: аттач через |
| 25/25 на Samsung UE49MU6103 (Tizen 3.0 / Chromium 47, protocol 1.1) — движок без |
| 125/125 в Chrome — capabilities, отказ |
| LG 49UJ639V (webOS 3.9 / Chrome 38): снапшот 37 МБ / 406k нод / 1271 detached за 13 с; после сценария diff показал +8.3 МБ, +170k нод, +1859 detached с разбивкой по конструкторам. Целевой webos7 на момент прогона был недоступен |
| LG 49UJ639V (webOS 3.9 / Chrome 38): |
Что этот движок умеет и чего нет, видно по прогону выше: 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_networkon-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 синтетика не покрывает.
Available Tools
15 toolstv_consoleA
Console output, uncaught exceptions and failed network requests buffered since launch. Filter by substring and/or level. Reports how many entries were dropped from the ring buffer.
Inputs: limit, device, filter, levels.
tv_devicesA
List configured TVs, their reachability (sdb/ares) and which operations each supports. Start here to see the park.
Inputs: no named parameters.
tv_evaluateA
Run arbitrary JavaScript in the app page and return the value (escape hatch). Use for custom assertions, reading app state, or restoring localStorage after a debug relaunch. Old TVs are Chrome 38 — keep the expression ES5.
Inputs: expression (required), device, awaitPromise.
tv_gotoA
Press a direction repeatedly until the FOCUSED element matches a target (text / CSS selector / testid). Bounded by maxSteps, a deadline, and two structural stops: focus that stopped moving (edge of a list) and focus that wrapped around to a position already visited. Use this instead of guessing "press DOWN 7 times".
Inputs: direction (required), text, device, testid, +3 more.
tv_heapA
Take a heap snapshot on the device and/or compare two of them — the tool for "the heap grew and never came back". Leak hunt: tv_heap action:"snapshot" (before) -> do the scenario (tv_press / tv_menu / tv_sequence) -> tv_heap action:"snapshot" (after) -> tv_heap action:"diff" with the two paths. A snapshot writes a .heapsnapshot file (open it in Chrome DevTools -> Memory -> Load) and returns the Summary view in numbers: total nodes and shallow size, how many DETACHED DOM nodes are still retained, and the top-N constructors by shallow size. diff returns the deltas — which constructors gained objects and bytes (topGrowth) and which lost them (topShrink), like the DevTools Comparison view. Retainer paths ("who holds this") and retained/dominator sizes are deliberately NOT computed: load the saved files in DevTools for those. A snapshot forces a full GC and pauses V8 for a long time (it can take a minute on a TV), so it is refused while a tv_profile recording is running. Needs the HeapProfiler domain — fine on tizen55/webos7/pc, best-effort on webOS 3. diff is a pure file operation: no device needed.
Inputs: action (required), path, topN, after, +3 more.
tv_installA
Install an app package on a TV (.wgt for Tizen, .ipk for webOS). Provide an absolute path. Set uninstallFirst:true when an app signed with a different certificate is already installed.
Inputs: path (required), device, uninstallFirst.
tv_launchA
Debug-launch the app and attach over CDP. Establishes the session used by all other tools. By default it kills any running instance first for a deterministic fresh start. reload:true reloads the page in place (same process, keeps localStorage). relaunch:true forces a fresh kill+launch. attach:true reuses the inspector of an app already running in debug, keeping its state.
Inputs: attach, device, reload, relaunch.
tv_pressA
Send a remote key. Names: UP/DOWN/LEFT/RIGHT/ENTER/BACK/MENU/RED/GREEN/YELLOW/BLUE/PLAY/PAUSE/PAGE_UP/... (or a raw numeric keyCode). durationMs holds the key (long-press); repeat+intervalMs sends a burst (e.g. move several tiles). Returns the focused element after the press.
Inputs: key (required), device, repeat, durationMs, +1 more.
tv_profileA
Record a JS CPU profile on the device, and/or read memory & layout metrics. action:"start" begins sampling, then do the thing you want to measure (tv_press / tv_goto / a scroll), then action:"stop" writes a .cpuprofile file (open it in Chrome DevTools -> Performance -> Load profile) and returns a top-N summary of self time by function and by file. start and stop each also take a Performance.getMetrics reading, so stop reports before/after/diff per metric (JSHeapUsedSize, Nodes, JSEventListeners, LayoutCount, RecalcStyleCount, cumulative Duration counters) — that is how you catch growth the CPU profile cannot see. action:"metrics" is just that reading, with no recording. On a minified production build pass sourceMap (the app.js.map of THAT build) to get readable names. The CPU profile works on the whole park (Profiler exists down to Chrome 38); metrics need Chromium 60+ (tizen55, pc) — on webOS 3 action:"metrics" fails with a clear message, while start/stop still return the profile with metrics:null and a warning.
Inputs: action (required), path, topN, device, +3 more.
tv_screenshotA
Capture the app frame via CDP and save a PNG. NOTE: on Samsung/Tizen the secure video/overlay plane often makes captureScreenshot hang or return black — for playback verdicts prefer tv_video_state and a human glance at the physical TV. UI screens (menus, focus, tiles) usually capture fine.
Inputs: path, device, timeoutMs.
tv_sequenceA
Run a whole case body in ONE call, with a verdict, elapsed time and result per step. Steps are objects, one key each: {"launch":{"relaunch":true}} (start from a known state) | {"press":"RIGHT","repeat":2} | {"longpress":"ENTER","durationMs":1500} | {"goto":{"direction":"DOWN","text":"..."}} | {"menu":"Settings"} | {"wait":{"scene":"player"},"timeoutMs":30000} | {"expect":{"selector":"[class*=popup]"}} | {"eval":"ES5 expression"} | {"sleep":1000} | {"videoState":true} | {"state":true} | {"profileStart":true} | {"profileStop":{"path":"…"}} | {"metrics":true}. expect and wait take the same conditions as tv_wait_for; a failing one fails the step. Runs under the device lock so nothing interleaves.
Inputs: steps (required), device, stopOnFail.
tv_stateA
Structured snapshot of the app right now: url, title, visible scenes, the focused element (text, class, path, index/total among its siblings), visible popups and element counts. Read-only — use it to assert a step without pressing anything.
Inputs: device.
tv_video_stateA
Programmatic snapshot: whether currentTime is advancing (two samples), readyState, size, muted, src and MediaError code. The reliable way to confirm playback when a screenshot would be black.
Inputs: device, sampleGapMs.
tv_wait_forA
Wait until a condition holds, instead of sleeping. Give exactly one condition. stableMs additionally requires it to keep holding, which avoids acting on a half-rendered frame. Returns the elapsed time and the final state.
Inputs: text, scene, device, selector, +7 more.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 15 tools
Each tool targets a clearly distinct aspect of the TV app debugging workflow: state inspection, waiting, input, navigation, video, profiling, heap, etc. There is no meaningful overlap; even similar tools like tv_state and tv_video_state differ in scope (whole app vs. media playback).
All tools use the 'tv_' prefix and snake_case naming, with a consistent verb-oriented pattern for actions (install, launch, press, goto, evaluate) and noun-style for state/snapshots. The naming convention is uniform and predictable across the entire set.
15 tools is at the upper edge of the 'well-scoped' range but every tool serves a distinct purpose for TV debugging, covering setup, interaction, verification, and performance analysis. None are redundant, and the count feels appropriate for the domain.
The surface covers the full workflow: discover devices, install, launch, interact, inspect state, capture screenshots/video, wait for conditions, run sequences, evaluate JS, and profile/heap. Minor gaps exist (e.g., no dedicated network traffic tool, no uninstall without reinstall), but agents can work around these with the provided tools.
Maintenance
Related MCP Connectors
MCP server for Mint — AI-powered QA that runs your app in a real browser on every PR.
MCP server for understanding Javascript internals from ECMAScript specification.
MCP compatibility and transport verification server.
MCP compatibility and transport verification server.
Related MCP Servers
- FlicenseNot gradedqualityCmaintenanceModular testing and automation MCP servers for Android devices, desktop browsers, canvas games, and visual regression.-
- AlicenseNot gradedqualityDmaintenanceMCP server that connects to your browser to capture screenshots, inspect console logs, network requests, and more via Chrome DevTools Protocol.33 npm2MIT
- AlicenseAqualityDmaintenanceMCP server for controlling Chromium/Chrome via Chrome DevTools Protocol. Supports cross-platform automation, auto-launch, and automatic reconnection.251MIT
- AlicenseAqualityDmaintenanceMCP server for debugging Electron apps: discover, launch, and control apps via Chrome DevTools Protocol and Node inspector, enabling JavaScript evaluation, network/console monitoring, screenshots, profiling, and breakpoints.441MIT