tv-debug-mcp
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.
Чтобы гонять своё приложение, а не фикстуру:
в
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 — важно для офлайн-запуска).
Установка в 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+, ноль зависимостей на устройстве. Тот же набор тулов работает и против браузера на ноуте.
Инструменты (18)
Тул | Что делает |
| Парк из |
| Установка билда ( |
| Debug-запуск + attach по CDP. Режимы: свежий старт / |
| Клавиша пульта. |
| Структурный снимок: url, заголовок, видимые сцены, фокус (текст, класс, путь, индекс/всего), попапы, счётчики |
| Раскладка экрана одним вызовом: ряды вокруг фокуса, их элементы с рефами |
| Записать путь физическим пультом и скомпилировать в готовый |
| Ожидание условия вместо |
| Жать направление, пока сфокусированный элемент не совпадёт с целью (имя из профиля, текст, селектор, testid). Ограничен |
| Войти в меню приложения и выбрать раздел по имени; без имени — открыть и вернуть список разделов |
| Весь кейс одним вызовом: вердикт, время и результат по каждому шагу, под device-lock |
| PNG кадра. В браузере работает всегда; на Tizen деградирует с пометкой (secure/overlay plane). Движок, который вообще не отдаёт кадр, ловится один раз: первый вызов выжигает таймаут, все следующие в этой сессии отказывают мгновенно |
| Консоль / исключения / упавшие запросы с момента launch. Все уровни, фильтр, счётчик отброшенного буфером |
| Полный лог запросов с момента launch: url, метод, статус, тело POST. Чтение тела ответа по |
| Программный снимок |
| Произвольный JS в странице (escape hatch). На старых ТВ — только ES5 |
| Запись JS CPU-профиля ( |
| Снапшот кучи на устройстве ( |
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_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 (по умолчанию 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— часы хоста, момент приёма события, а не 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, чтобы сами вызовы метрик не попали в запись, которую они описывают.
Платформы: 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» точности окна не теряет.
Требования к устройствам
Три независимых чек-листа: ТВ 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"},
"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 — почему это два разных эксперимента
ТВ | Браузер по умолчанию | Браузер | |
Механизм | 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 синтетика не покрывает.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
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