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 — важно для офлайн-запуска).

Related MCP server: open_browser_use

Установка в 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": "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 | pc

app

все

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

name, engine

все

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

appId

tizen, webos

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

host

tizen

IP телевизора

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 синтетика не покрывает.

Install Server
A
license - permissive license
A
quality
B
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

View all related MCP servers

Related MCP Connectors

  • MCP server for understanding Javascript internals from ECMAScript specification.

  • MCP server for Appcircle mobile CI/CD platform.

  • MCP server for Klever blockchain smart contract development.

View all MCP Connectors

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