Skip to main content
Glama
README.md
# tv-debug-mcp

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

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

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

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

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

```bash
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-профиль»](#app-профиль), готовый пример лежит в `apps/fixture.json`;
3. для ТВ — Developer Mode на устройстве и подключённый `sdb` / `ares`.

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

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

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

### Claude Code

```bash
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`:

```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`, а команда — **массив**, а не строка:

```json
{
  "$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`, панель настроек расширения):

```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"}
    }
  }
}
```

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

```bash
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_DEBUG_TIMING` | `1` — писать в stderr каждый CDP-вызов с его временем (диагностика «медленно тул или ТВ») | выключено |
| `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+, ноль зависимостей на устройстве. Тот же набор тулов работает и против браузера на ноуте.

## Инструменты (19)

| Тул | Что делает |
|---|---|
| `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-профиля и кладёт вердикт в `bootReady`; свежий запуск, не дошедший до `bootReady`, один раз перезапускается сам (`bootRetries: 1`). Один раз отдаёт факты об устройстве: `inputMode`, `rttMs` (цена одного round-trip), `legacyEval` |
| `tv_press` | Клавиша пульта. `durationMs` = лонгтап; `repeat`+`intervalMs` = серия; `settle:false` — без ожидания фокуса. Нажатие, ожидание фокуса и его чтение — **один** page-side вызов; ответ `{key, focus, changed, ms, evals}` |
| `tv_state` | Снимок: url, заголовок, видимые сцены, фокус (текст, путь, индекс/всего), попапы, счётчики. По умолчанию текстом, `format:"json"` — объектом |
| `tv_snapshot` | Раскладка экрана одним вызовом: ряды вокруг фокуса, их элементы с рефами `e1`, `e2`…, и `neighbours` — ближайший реф в каждую сторону. `tv_goto {ref}` ходит по ним точно. По умолчанию текстом (`r1*: [e1 "…"]* [e2 "…"]`), `format:"json"` — объектом |
| `tv_record` | Записать путь **физическим пультом** и скомпилировать в готовый `tv_sequence` + чек-лист. `start` перезапускает приложение, на экране горит «● REC», `stop` показывает кейс, файл создаёт `write` |
| `tv_wait_for` | Ожидание условия вместо `sleep`: `focusText` / `element` / `elementGone` / `sceneName` / `selector` / `selectorGone` / `scene` / `text` / `expression` / `videoAdvancing` / `request`. Снимок `tv_state` в хвосте — только по `withState:true` |
| `tv_goto` | Жать направление, пока **сфокусированный** элемент не совпадёт с целью (имя из профиля, текст, селектор, testid). Ограничен `maxSteps`, дедлайном и детектом «фокус встал» / «обернулись по кругу». `select: true` — нажать ENTER по прибытии. Шаг = один page-side вызов (нажатие + settle + матч); зелёный ответ — `trail: "DOWN×3"` + фокус, список нажатий только на красном |
| `tv_menu` | Войти в меню приложения и выбрать раздел по имени; без имени — открыть и вернуть список разделов |
| `tv_sequence` | Весь кейс одним вызовом под device-lock. Зелёный навигационный шаг — одна строка `brief`; чтения (`eval`, `state`, `snapshot`, `metrics`, `videoState`, `expectRequest`, профиль) и красные шаги несут полный `result`; `report:"full"` — всё |
| `tv_screenshot` | PNG кадра. В браузере работает всегда; на Tizen деградирует с пометкой (secure/overlay plane). Движок, который вообще не отдаёт кадр, ловится один раз: первый вызов выжигает таймаут, все следующие в этой сессии отказывают мгновенно |
| `tv_console` | Консоль / исключения / упавшие запросы с момента launch, **с дедупом**: повтор несёт `count` и `t`/`tLast` (секунды от подключения), url обрезан до `file:line`. Фильтр, уровни, счётчик отброшенного буфером |
| `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. Ответ режется на 16 КБ (`truncated`, `bytes`, подсказка сузить выражение) |
| `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_resources` | Только webOS. CPU % и память **всего ТВ и процесса приложения (RSS)**, снятые на самом ТВ через `ares-device --resource-monitor` (те же данные, что у LG Resource Monitor): `start` → действия → `read`/`stop`, в ответе min/max/avg/delta по каждой серии. CDP не нужен |

### tv_sequence — шаги

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

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

### Подробные справки тулов — MCP-ресурсы

Описания в `tools/list` умышленно короткие (весь список ≤ 10 КБ: часть клиентов шлёт его модели на каждом ходу). Полная справка каждого тула — действия, формы шагов, формы ответов — лежит ресурсом `tv-debug://docs/<tool>` (например `tv-debug://docs/tv_sequence`, `tv-debug://docs/tv_network`); агент читает её один раз через `resources/read`, когда нужна.

Ответы отдаются компактным JSON без отступов. Навигационные тулы несут свою цену: `ms` (время) и `evals` (число CDP-вызовов) — по ним видно, тормозит шаг или ТВ. `TV_DEBUG_TIMING=1` дополнительно пишет в stderr каждый CDP-вызов с его временем.

### tv_press — клавиши

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

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

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

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

```json
{"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` с таймаутом `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`, исключённый из попап-сканов, снапшота и дедупликации наблюдений, и переустанавливаемый вместе с рекордером при реаттаче. Человек с пультом должен видеть, что запись идёт, иначе каждый прогон начинается с вопроса «а оно вообще пишет?». В скриншоты бейдж попадёт — про это есть строка в чек-листе.

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

| Движок | Что не так | Что возвращаем |
|---|---|---|
| весь парк | нет `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` (по умолчанию 25, новейшие первыми). Запись: `requestId`, `receivedAt`, `method`, `url` (обрезан до 500), `status`, `mimeType`, `resourceType`, `encodedDataLength`, `postData` (обрезан до 1000, флаг `postDataTruncated`), `failed` + `errorText`, `fromCache`, `redirectFrom` / `redirectedTo`, `inFlight`. Плюс `dropped` — сколько вытеснено из кольцевого буфера: ассерт по вытесненному запросу провалился бы молча, поэтому счётчик едет в каждом ответе.

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

```json
{"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` вернёт

```json
"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» точности окна не теряет.

### tv_resources — CPU и память на самом ТВ (webOS)

`tv_profile` и `tv_heap` видят JS-кучу и DOM. Процесс рендерера держит больше: декодированные картинки, текстуры слоёв, буферы `<video>`. А убивает приложение система, глядя на свою свободную память. `tv_resources` снимает именно это, прямо на ТВ:

```
tv_resources {"action": "start"}              # фоновые сэмплеры, ждёт первый замер
tv_sequence  {"steps": [...]}                 # сценарий: скролл, плеер, 20 заходов в раздел
tv_resources {"action": "read"}               # промежуточная сводка, замер идёт дальше
tv_resources {"action": "stop"}               # финальная сводка, CSV остаются на диске
```

Под капотом два фоновых `ares-device`: `-r -t N -s system.csv` (весь ТВ) и `-r -id <appId> -t N -s app.csv` (приложение). Каждый держит одну dev-mode SSH-сессию и раз в `intervalSec` (1..60, по умолчанию 1) читает `/proc/stat`, `/proc/<pid>/stat`, `free -k`. pid приложения ares берёт из `applicationManager/dev/running` (`webprocessid` у web-аппа). Один удалённый цикл вместо двух детей не собрать без своего SSH-клиента: `ares-shell -r` отдаёт первый чанк вывода и отключается.

Ответ `read`/`stop`: `system: {cpuPct, memAvailableMb, memUsedMb, swapUsedMb, memTotalMb}` и `app: {cpuPct, rssMb, pids}`, у каждой серии `{min, max, avg, first, last, delta, maxAt}`.

Как читать:

- CPU % — доля **всех** ядер (100 = заняты все), как считает ares. Разрешение одна секунда: фризы и long tasks ищутся `tv_profile`, не здесь;
- ровная `JSHeapUsedSize` из `tv_profile` при растущем `app.rssMb.delta` — нативная память (картинки, видео), а не JS-утечка;
- больше одного pid в `app.pids` — приложение перезапустилось или его убили посреди окна;
- нет замеров приложения — оно не запущено или его нет в `dev/running` (ares сопоставляет appId с pid только оттуда). Системные цифры при этом есть;
- сэмплер процессов читает все `/proc/<pid>/stat` на каждом тике, это тоже нагрузка на ТВ. Она попадает в системный CPU, не в CPU приложения.

`start` не требует `tv_launch` и не трогает CDP-сессию. Недоступный ТВ или протухший dev-mode отказывают на `start` с текстом ошибки ares. Упавший посреди окна сэмплер попадает в `warnings`. `appId` (по умолчанию из `devices.json`) и `path` (каталог для CSV, по умолчанию TMPDIR) принимаются, но в схеме тула их нет, чтобы `tools/list` оставался в пределах 10 КБ.

**Платформы**: только webOS. Прогнан на живых webOS 7 и webOS 3.9 (Chrome 38): системные серии и RSS/CPU приложения на обоих, pid web-аппа через `dev/running` резолвится и на 3.9. Парсеры дополнительно проверены офлайн на CSV, который пишет csv-writer из ares 3.2.1.

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

Три независимых чек-листа: ТВ 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 и портов отвергаются с внятной ошибкой.

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

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

| Поле | Для кого | Что задаёт |
|---|---|---|
| `id` | все | имя устройства в тулах и в `TV_DEBUG_DEVICE`. Уникально — дубли отвергаются на загрузке конфига |
| `platform` | все | `tizen` \| `webos` \| `vidaa` \| `pc` |
| `app` | все | id app-профиля: читается `apps/<app>.json`. Без него доступна только дженерик-часть тулов |
| `name`, `engine` | все | человекочитаемые подписи, видны в выводе `tv_devices` |
| `appId` | tizen, webos | id приложения на устройстве (`AbCdEfGhIj.myapp`, `com.example.myapp`) |
| `host` | tizen, vidaa | IP телевизора. У vidaa это **весь адрес**: инспектор слушает на самом ТВ (в отличие от webOS, где `device` — имя из ares) |
| `port` | vidaa | порт DevTools-инспектора на ТВ. Не указан — узкий автоскан 9222–9230. На VIDAA 9 (50A53FEVS) это **9226**, на старых прошивках встречался 9223. Dev-режим включается пультом: `Home×3 → Up×2 → Right-Left-Right-Left-Right`. ⚠️ Инспектор без авторизации — любой в LAN может подключиться; только для dev-девайса. `appId` не нужен: апп hosted, sideload/kill по сети недоступны (`relaunch` = перезагрузка страницы) |
| `sdbPort` | tizen | порт sdb, по умолчанию `26101` |
| `cliTarget` | tizen | цель для `tizen install -t`, чтобы билд поехал в нужный ТВ на парке. Можно не указывать — выводится из третьей колонки `sdb devices` |
| `localPort` | tizen | локальный порт под `sdb forward`. Не указан — берётся свободный; два устройства с одним и тем же пином отвергаются, иначе они перекрёстно склеились бы |
| `device` | webos | имя устройства из `ares-setup-device --list` (не IP) |
| `url` | pc | адрес dev-сервера, например `http://localhost:1337` |
| `chromePath` | pc | бинарь Chrome именно для этого устройства; перебивает `TV_DEBUG_CHROME` |
| `chromeArgs` | pc | дополнительные аргументы к Chrome поверх обязательных |
| `profileDir` | pc | использовать **этот** каталог профиля вместо одноразового. Тогда профиль считается чужим и на dispose не удаляется — так живёт залогиненный Chrome, который не хочется логинить заново каждый прогон |
| `inputMode` | pc | `trusted` (по умолчанию) \| `synthetic` — см. [«Trusted vs synthetic»](#trusted-vs-synthetic--почему-это-два-разных-эксперимента). На ТВ клавиши всегда синтетические, поле там не читается |

## App-профиль

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

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

`settle` — пороги «нажатие отработало»: `changeTimeoutMs` — сколько ждать, что фокус вообще сдвинется, `quietMs` — сколько он должен стоять на месте после. Тишина DOM (`MutationObserver`, `class` + `childList`) — подсказка, не условие: если приложение мутирует DOM непрерывно (оверлей со счётчиком), ожидание ограничено 3×`quietMs` неподвижного фокуса. Без блока — платформенные дефолты (pc 80/800, vidaa 120/1000, ТВ 150/1200). `tv_goto` и открытие `tv_menu` на «фокус не сдвинулся» жмут ещё раз через 300 мс, прежде чем считать это краем списка — ТВ-приложения изредка роняют клавишу во время анимации.

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

- Фреймворк может вешать класс фокуса на **всю цепочку** scene → container → list → tile, поэтому сфокусированный виджет — это **самый глубокий** match, а не первый. Первый — это сцена, и по нему навигация выглядит неподвижной.
- `root`/`item` пришивайте к **первому уровню** меню. Вложенный раздел легко рисует свои строки теми же классами, и одна из них может называться как раздел верхнего уровня — тогда матч по всему меню выбирает вложенную строку и рапортует успех, пока апп никуда не уходил. По той же причине есть `exitKey`: внутри раздела клавиша открытия меню может не возвращать в сайдбар, надо сначала выйти по BACK.

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

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

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

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

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

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

```json
"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-канала, поэтому канал закрывается сразу после разбора порта.
- **Надёжный 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` сериализован — параллельные вызовы не запускают апп дважды.

## Проверено

| Прогон | Что |
|---|---|
| `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-сервера, если задать обе переменные:

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

## Демо-кейсы

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

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

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

TDQS

A4.4/5.0

Scored across 15 tools

Disambiguation5/5

Each tool targets a clearly distinct aspect of the TV app debugging workflow: state inspection, waiting, input, navigation, video, profiling, heap, etc. There is no meaningful overlap; even similar tools like tv_state and tv_video_state differ in scope (whole app vs. media playback).

Naming Consistency5/5

All tools use the 'tv_' prefix and snake_case naming, with a consistent verb-oriented pattern for actions (install, launch, press, goto, evaluate) and noun-style for state/snapshots. The naming convention is uniform and predictable across the entire set.

Tool Count5/5

15 tools is at the upper edge of the 'well-scoped' range but every tool serves a distinct purpose for TV debugging, covering setup, interaction, verification, and performance analysis. None are redundant, and the count feels appropriate for the domain.

Completeness4/5

The surface covers the full workflow: discover devices, install, launch, interact, inspect state, capture screenshots/video, wait for conditions, run sequences, evaluate JS, and profile/heap. Minor gaps exist (e.g., no dedicated network traffic tool, no uninstall without reinstall), but agents can work around these with the provided tools.

Maintenance

ActivityActive
ResponsivenessNo issues