dsh-desktop-kit 2.2.0 → 2.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +113 -27
  3. package/client.js +3121 -32
  4. package/docs/AGENT-HARNESS.md +144 -0
  5. package/docs/ARCHITECTURE.md +38 -7
  6. package/docs/COMPATIBILITY.md +5 -3
  7. package/docs/DEVELOPMENT.md +56 -0
  8. package/docs/INSTALL.md +20 -11
  9. package/docs/LAPTOP-INSTALL.md +96 -0
  10. package/docs/ROADMAP-3.0.md +353 -0
  11. package/docs/ROADMAP.md +1 -1
  12. package/docs/SUBAGENTS.md +25 -0
  13. package/docs/TEST-0.2.2.md +6 -6
  14. package/docs/TEST-2.3.0.md +49 -0
  15. package/docs/TEST-2.7.2.md +46 -0
  16. package/docs/TOKENS.md +2 -2
  17. package/docs/TROUBLESHOOTING.md +27 -0
  18. package/docs/UI-QA.md +28 -0
  19. package/docs/images/peak-toast-peak.png +0 -0
  20. package/docs/images/peak-toast-soon.png +0 -0
  21. package/docs/preview/peak-toast.html +155 -0
  22. package/doctor.ps1 +122 -0
  23. package/index.js +18 -1
  24. package/install.ps1 +99 -0
  25. package/package.json +29 -6
  26. package/plugins/dsh-agent-extensions/README.md +99 -0
  27. package/plugins/dsh-agent-extensions/index.js +167 -0
  28. package/plugins/dsh-agent-extensions/lib/agents.js +76 -0
  29. package/plugins/dsh-agent-extensions/lib/checkpoints.js +103 -0
  30. package/plugins/dsh-agent-extensions/lib/commands.js +255 -0
  31. package/plugins/dsh-agent-extensions/lib/hooks.js +182 -0
  32. package/plugins/dsh-agent-extensions/lib/policy.js +84 -0
  33. package/plugins/dsh-agent-extensions/lib/project.js +144 -0
  34. package/plugins/dsh-agent-extensions/lib/scaffold.js +151 -0
  35. package/plugins/dsh-agent-extensions/package.json +9 -0
  36. package/plugins/dsh-browser-qa/README.md +221 -0
  37. package/plugins/dsh-browser-qa/cordis.patch.yml +4 -0
  38. package/plugins/dsh-browser-qa/index.js +165 -0
  39. package/plugins/dsh-browser-qa/lib/cdp.js +224 -0
  40. package/plugins/dsh-browser-qa/lib/chrome.js +466 -0
  41. package/plugins/dsh-browser-qa/lib/pages.js +552 -0
  42. package/plugins/dsh-browser-qa/lib/qa.js +827 -0
  43. package/plugins/dsh-browser-qa/lib/ui-gate.js +256 -0
  44. package/plugins/dsh-browser-qa/package.json +31 -0
  45. package/plugins/dsh-checkpoints/README.md +22 -0
  46. package/plugins/dsh-checkpoints/cordis.patch.yml +3 -0
  47. package/plugins/dsh-checkpoints/index.js +80 -0
  48. package/plugins/dsh-checkpoints/lib/checkpoints.js +111 -0
  49. package/plugins/dsh-checkpoints/package.json +25 -0
  50. package/plugins/dsh-github-review/README.md +131 -0
  51. package/plugins/dsh-github-review/cordis.patch.yml +3 -0
  52. package/plugins/dsh-github-review/index.js +536 -0
  53. package/plugins/dsh-github-review/lib/gh.js +494 -0
  54. package/plugins/dsh-github-review/lib/normalize.js +662 -0
  55. package/plugins/dsh-github-review/package.json +28 -0
  56. package/plugins/dsh-guardrails/README.md +264 -0
  57. package/plugins/dsh-guardrails/cordis.patch.yml +5 -0
  58. package/plugins/dsh-guardrails/index.js +389 -0
  59. package/plugins/dsh-guardrails/lib/hooks.js +262 -0
  60. package/plugins/dsh-guardrails/lib/modes.js +77 -0
  61. package/plugins/dsh-guardrails/lib/policy.js +592 -0
  62. package/plugins/dsh-guardrails/lib/scan.js +451 -0
  63. package/plugins/dsh-guardrails/lib/secrets.js +274 -0
  64. package/plugins/dsh-guardrails/package.json +26 -0
  65. package/plugins/dsh-mcp-manager/README.md +78 -0
  66. package/plugins/dsh-mcp-manager/client.js +373 -0
  67. package/plugins/dsh-mcp-manager/index.js +159 -0
  68. package/plugins/dsh-mcp-manager/lib/api.js +250 -0
  69. package/plugins/dsh-mcp-manager/lib/client.js +219 -0
  70. package/plugins/dsh-mcp-manager/lib/config.js +217 -0
  71. package/plugins/dsh-mcp-manager/lib/manager.js +146 -0
  72. package/plugins/dsh-mcp-manager/package.json +18 -0
  73. package/plugins/dsh-mcp-manager/screenshots/modal-390.png +0 -0
  74. package/plugins/dsh-mcp-manager/screenshots/settings-1440.png +0 -0
  75. package/plugins/dsh-mcp-manager/screenshots/settings-390.png +0 -0
  76. package/plugins/dsh-memory-bridge/README.md +161 -0
  77. package/plugins/dsh-memory-bridge/client.js +2264 -0
  78. package/plugins/dsh-memory-bridge/cordis.patch.yml +5 -0
  79. package/plugins/dsh-memory-bridge/docs/images/conflicts.png +0 -0
  80. package/plugins/dsh-memory-bridge/docs/images/hero.png +0 -0
  81. package/plugins/dsh-memory-bridge/docs/images/settings-dark.png +0 -0
  82. package/plugins/dsh-memory-bridge/docs/images/settings-light.png +0 -0
  83. package/plugins/dsh-memory-bridge/docs/images/settings-mobile.png +0 -0
  84. package/plugins/dsh-memory-bridge/docs/images/setup.png +0 -0
  85. package/plugins/dsh-memory-bridge/icon.svg +13 -0
  86. package/plugins/dsh-memory-bridge/index.js +378 -0
  87. package/plugins/dsh-memory-bridge/lib/bridge.js +636 -0
  88. package/plugins/dsh-memory-bridge/lib/github.js +473 -0
  89. package/plugins/dsh-memory-bridge/lib/merge.js +205 -0
  90. package/plugins/dsh-memory-bridge/lib/portable.js +314 -0
  91. package/plugins/dsh-memory-bridge/lib/state.js +565 -0
  92. package/plugins/dsh-memory-bridge/lib/sync.js +984 -0
  93. package/plugins/dsh-memory-bridge/locale/en.json +6 -0
  94. package/plugins/dsh-memory-bridge/locale/ru.json +6 -0
  95. package/plugins/dsh-memory-bridge/package.json +67 -0
  96. package/plugins/dsh-peak-guard/README.md +16 -3
  97. package/plugins/dsh-peak-guard/client.js +223 -8
  98. package/plugins/dsh-peak-guard/package.json +1 -1
  99. package/plugins/dsh-plan-mode/README.md +24 -0
  100. package/plugins/dsh-plan-mode/cordis.patch.yml +3 -0
  101. package/plugins/dsh-plan-mode/index.js +103 -0
  102. package/plugins/dsh-plan-mode/lib/plan.js +89 -0
  103. package/plugins/dsh-plan-mode/package.json +18 -0
  104. package/plugins/dsh-profile-transfer/client.js +277 -17
  105. package/plugins/dsh-profile-transfer/index.js +10 -0
  106. package/plugins/dsh-profile-transfer/package.json +3 -1
  107. package/plugins/dsh-profile-transfer/transfer.js +101 -2
  108. package/plugins/dsh-project-memory/index.js +92 -2
  109. package/plugins/dsh-project-memory/package.json +1 -1
  110. package/plugins/dsh-repo-map/README.md +26 -0
  111. package/plugins/dsh-repo-map/cordis.patch.yml +3 -0
  112. package/plugins/dsh-repo-map/index.js +99 -0
  113. package/plugins/dsh-repo-map/lib/repo-map.js +170 -0
  114. package/plugins/dsh-repo-map/package.json +25 -0
  115. package/plugins/dsh-skill-autopilot/index.js +135 -11
  116. package/skills/delivery-gate/SKILL.md +31 -0
  117. package/skills/git-and-ci/SKILL.md +5 -1
  118. package/skills/manifest.json +31 -6
  119. package/skills/requirements-and-planning/SKILL.md +3 -0
  120. package/skills/research-first/SKILL.md +35 -0
  121. package/skills/research-first/references/query-patterns.md +20 -0
  122. package/skills/subagent-playbook/SKILL.md +36 -0
  123. package/skills/subagent-playbook/references/prompts.md +39 -0
  124. package/skills/ui-craft/SKILL.md +43 -0
  125. package/skills/visual-qa/SKILL.md +29 -0
  126. package/skills/visual-qa/references/commands.md +32 -0
  127. package/skills-baseline.json +246 -0
  128. package/uninstall.ps1 +74 -0
@@ -0,0 +1,221 @@
1
+ # dsh-browser-qa
2
+
3
+ Headless-проверка вёрстки для DSH-агента: скриншоты и адаптивный аудит
4
+ горизонтального переполнения и ошибок консоли через протокол Chrome DevTools.
5
+ Без Playwright и без внешних зависимостей — только встроенные модули Node.js.
6
+
7
+ ## Зачем
8
+
9
+ Агент может сам проверить страницу перед сдачей: «нет ли горизонтального
10
+ скролла на 390/768/1440», «какие элементы вылезают», «что пишет консоль»,
11
+ «как это выглядит». Работает и с локальными HTML-файлами, и с http(s)-страницами.
12
+
13
+ ## Требования
14
+
15
+ - **Chrome или Chromium-браузер**. На Windows плагин ищет:
16
+ `CHROME_PATH` → `%ProgramFiles%\Google\Chrome\Application\chrome.exe` →
17
+ `%ProgramFiles(x86)%\...` → `%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe` →
18
+ Chrome Canary → Edge (`msedge.exe`) как Chromium-запасной вариант.
19
+ Путь можно задать явно: `CHROME_PATH=C:\путь\chrome.exe` (переменная окружения
20
+ процесса DSH).
21
+ - **Node 22+** — нужен встроенный `WebSocket` (в Node 24 он стабилен). DSH
22
+ Desktop на Electron 44 запускает Node 24, отдельная установка Node не нужна.
23
+ Если WebSocket недоступен, `browser_qa action=status` честно сообщит об этом.
24
+ - Работает и на macOS/Linux (стандартные пути Chrome/Chromium), но основные
25
+ пути и проверки — под Windows.
26
+
27
+ ## Инструмент `browser_qa`
28
+
29
+ Регистрируется один инструмент с тремя действиями. Параметры:
30
+
31
+ | Параметр | Тип | По умолчанию | Назначение |
32
+ |---|---|---|---|
33
+ | `action` | `"shot"` \| `"audit"` \| `"status"` | — (обязателен) | Что сделать |
34
+ | `url` | string | — | `http(s)`-адрес, `file://` или путь к локальному HTML (относительный — от cwd сессии) |
35
+ | `width` | integer | `1440` | Ширина viewport для `shot`, CSS px (200..3840) |
36
+ | `height` | integer | `900` | Высота viewport, CSS px (200..2160) |
37
+ | `widths` | integer[] | `[390, 768, 1440]` | Ширины для `audit`, CSS px (до 6 значений) |
38
+ | `outDir` | string | `<cwd>/.dsh/browser-qa` | Куда писать PNG; для `audit` — включает скриншоты |
39
+ | `fullPage` | boolean | `false` | Снимать всю страницу, а не viewport (высота обрезается на 16384 px) |
40
+ | `timeoutMs` | integer | `30000` | Таймаут загрузки страницы на одну ширину (3000..120000) |
41
+
42
+ ### `status`
43
+
44
+ Находит браузер и возвращает путь, тип (Chrome/Edge), версию (через
45
+ `/json/version`), версию Node и наличие WebSocket. Ничего не сохраняет.
46
+ Пробный запуск Chrome занимает ~1–2 с и кэшируется на 5 минут.
47
+
48
+ ### `shot`
49
+
50
+ Открывает страницу, выставляет viewport, сохраняет PNG и возвращает
51
+ `output.path`, `output.width`, `output.height`, `output.bytes`, а также
52
+ краткую сводку overflow и ошибок консоли. По умолчанию PNG кладётся в
53
+ `<cwd>/.dsh/browser-qa/`.
54
+
55
+ ### `audit`
56
+
57
+ Для каждой ширины из `widths` заново открывает страницу (так корректно
58
+ срабатывают media queries и JS-перестройка), затем измеряет:
59
+
60
+ - `document.documentElement.scrollWidth` против `clientWidth` (допуск 1 px) —
61
+ `horizontalOverflow.hasOverflow`, `overflowPx`;
62
+ - элементы, чей `getBoundingClientRect().right` выходит за viewport
63
+ (`overflowingElements`: селектор, тег, id, классы, rect, насколько вылез,
64
+ текст); максимум 25 в отчёте, всего просмотрено до 5000 элементов;
65
+ - ошибки консоли: `Runtime.consoleAPICalled` с `type=error/assert`,
66
+ `Runtime.exceptionThrown` и `Log.entryAdded` уровня `error` (включая
67
+ CSP-нарушения и сетевые ошибки); дедупликация, максимум 30;
68
+ - **разметка и доступность** (`markup`): дубли `id`, `<img>` без `alt`,
69
+ поля без `label`/`aria-*`, ссылки и кнопки без доступного имени,
70
+ пропуски уровней заголовков, несколько `h1`, отсутствие `lang`/`title`/
71
+ `meta viewport`, положительный `tabindex`; до 60 issues на ширину;
72
+ - дополнительно: viewport, title, финальный URL, `loadTimedOut`, время загрузки.
73
+
74
+ Сводка: `summary.widthsChecked`, `widthsWithOverflow`, `overflowingElements`,
75
+ `consoleErrors`, `markupIssues`, `widthsWithMarkupIssues`, `failedWidths`,
76
+ `screenshots`.
77
+
78
+ **Скриншоты в audit — опционально:** они сохраняются по одной штуке на ширину
79
+ только если передан `outDir`. Без `outDir` audit работает быстрее и не пишет
80
+ файлов. `fullPage` действует и на audit, и на shot.
81
+
82
+ ## UI gate
83
+
84
+ Плагин также ставит жёсткий gate на хуках:
85
+
86
+ - `fs/write-intent` / `fs/edit-intent` запоминают правки `.html`, `.css`,
87
+ `.scss`, `.jsx`, `.tsx`, `.vue`, `.svelte`, `.astro`, `.mdx`;
88
+ - `tools/result` читает итог `browser_qa audit` (`overflow`, console, markup);
89
+ - на `agent/turn-stopping` грязная сессия получает `agent.steer(...)`:
90
+ «запусти `browser_qa audit` на 390/768/1440, исправь overflow/console/markup
91
+ и проверь снова»;
92
+ - ход закрывается только после **чистого** audit (0 overflow, 0 console,
93
+ 0 markup) или после 4 напоминаний — предохранитель от бесконечного цикла.
94
+
95
+ Это не запрет записи: агент может править, но не может молча завершить ход
96
+ после UI-правки без проверки.
97
+
98
+ ## Примеры вызовов
99
+
100
+ ```json
101
+ { "action": "status" }
102
+ ```
103
+
104
+ ```json
105
+ { "action": "shot", "url": "C:\\project\\dist\\index.html", "width": 390, "height": 844 }
106
+ ```
107
+
108
+ ```json
109
+ {
110
+ "action": "audit",
111
+ "url": "https://example.com/pricing",
112
+ "widths": [390, 768, 1440],
113
+ "outDir": "qa/browser",
114
+ "timeoutMs": 45000
115
+ }
116
+ ```
117
+
118
+ ```json
119
+ { "action": "audit", "url": "C:\\project\\index.html", "widths": [390, 1440], "fullPage": true, "outDir": "qa/browser" }
120
+ ```
121
+
122
+ ## Куда сохраняются PNG
123
+
124
+ - `shot` без `outDir`: `<cwd-сессии>/.dsh/browser-qa/`.
125
+ - `audit` без `outDir`: ничего не сохраняется (только отчёт).
126
+ - `audit` с `outDir` (или `shot` с `outDir`): указанный каталог, абсолютный или
127
+ относительно cwd сессии; создаётся при необходимости.
128
+ - Имя файла: `<slug>-<ширина>x<высота>[-full]-<YYYYMMDD-HHmmss>-<hex>.png`,
129
+ например `localhost-390x844-20261109-205501-a1b2c3.png`. `slug` берётся из
130
+ хоста и пути URL или из имени локального файла.
131
+ - Размеры PNG — ровно CSS-пиксели viewport (`deviceScaleFactor=1`), поэтому
132
+ 390-px скриншот получается 390 px шириной (а не 500, как при
133
+ `chrome --screenshot` на Windows).
134
+
135
+ ## Ограничения
136
+
137
+ - **CSP.** Плагин не обходит Content-Security-Policy страницы: если CSP
138
+ запрещает собственные inline-скрипты/шрифты/картинки сайта, в отчёте будут
139
+ соответствующие console errors. Сам замер выполняется через
140
+ `Runtime.evaluate` DevTools и CSP страницы не блокируется.
141
+ - **Авторизация.** Профиль браузера временный, cookies нет. Приватные
142
+ страницы, за логином или с SSO, вернут редирект на страницу входа — это
143
+ ожидаемо. Для проверки таких страниц нужен публичный staging/preview URL.
144
+ - **`file://`.** Локальные HTML открываются (флаг
145
+ `--allow-file-access-from-files` включён), но Chrome ограничивает часть
146
+ возможностей file-страниц: ES-модули и `fetch` к локальным файлам могут
147
+ блокироваться CORS, часть API недоступна. Относительные `src`/`href`
148
+ обычно работают — `file://`, `../../`, внешние CDN подключаются.
149
+ - **SPA и асинхронность.** Плагин ждёт `load`, `document.readyState=complete`
150
+ и шрифты (`document.fonts.ready`, не дольше ~1.75 с), затем снимает замер.
151
+ Бесконечные таймеры, ленивые секции и контент, появляющийся через 5+ секунд
152
+ после load, могут не попасть в кадр — это не полноценный E2E-раннер.
153
+ - **Интерактивность.** Нет кликов, скроллинга, ввода и навигации по шагам.
154
+ Только загрузка страницы и измерение.
155
+ - **Псевдоэлементы, `transform`, `position: fixed`.** Overflow считается по
156
+ реальным DOM-элементам; `::before/::after` не перечисляются, а элементы
157
+ внутри контейнеров с `overflow-x != visible` (карусели, таблицы со скроллом)
158
+ считаются «содержимыми» и в список вылезающих не попадают.
159
+ - **`fullPage`.** Ограничен 16384 px по высоте и ширине (лимит поверхности
160
+ Chrome); при превышении `output.clipped=true`. Full-page снимок включает и
161
+ горизонтально переполненную область, поэтому может быть шире viewport.
162
+ - **Антибот/headless-детект.** Некоторые сайты (Cloudflare и т.п.) отдают
163
+ headless-браузеру заглушку или блокируют его — аудит покажет то, что увидел.
164
+ - **Windows.** Определение путей и очистка процессов рассчитаны в первую
165
+ очередь на Windows; на Linux/macOS поддерживаются базовые пути, но тесты
166
+ прогонялись на Windows.
167
+
168
+ ## Безопасность и уборка
169
+
170
+ - Каждый вызов запускает отдельный Chrome с временным `--user-data-dir` и
171
+ случайным `--remote-debugging-port=0`; порт слушает только `127.0.0.1` и
172
+ живёт внутри одного вызова.
173
+ - Профиль пользователя не затрагивается; cookies, история и расширения из
174
+ обычного Chrome не используются.
175
+ - После каждого вызова (включая ошибку и отмену) браузер убивается
176
+ (`taskkill /T /F` при необходимости), временный профиль удаляется с
177
+ ретраями; на случай аварийного завершения DSH есть `exit`-guard.
178
+ - Внешних сетевых запросов, кроме самой проверяемой страницы, нет; телеметрии
179
+ нет.
180
+
181
+ ## Тесты
182
+
183
+ ```bash
184
+ node tools/browser-qa-tests.mjs
185
+ ```
186
+
187
+ - Чистые хелперы (всегда): параметры инструмента, viewport/widths/timeouts,
188
+ нормализация URL и путей, имена файлов, PNG-заголовок, shaping overflow- и
189
+ console-отчётов, summary и текст.
190
+ - Реальный Chrome smoke (если Chrome/Edge найден; иначе SKIP):
191
+ временный HTML с намеренным overflow 1200 px и `console.error`; проверяются
192
+ overflow на 390/768, отсутствие overflow на 1440, найденный элемент
193
+ `#wide`, пойманная ошибка консоли, PNG на каждую ширину, точные размеры
194
+ `shot`, fullPage выше viewport, `status` с путём/версией, вызов через
195
+ зарегистрированный инструмент и отсутствие оставшихся временных профилей.
196
+
197
+ ## Устройство
198
+
199
+ ```
200
+ plugins/dsh-browser-qa/
201
+ index.js — регистрация инструмента browser_qa (inject: ["tools"])
202
+ lib/qa.js — чистые хелперы: URL/viewport/widths, overflow-probe,
203
+ console-события, отчёты, текст
204
+ lib/cdp.js — минимальный CDP-клиент на встроенном WebSocket
205
+ lib/chrome.js — поиск Chrome, запуск headless с temp-профилем,
206
+ /json/version|list, очистка процессов и профиля
207
+ lib/pages.js — shot/audit/status: навигация, viewport, скриншоты, сборка отчёта
208
+ cordis.patch.yml — host-патч для standalone-установки bundle
209
+ ```
210
+
211
+ ## Диагностика
212
+
213
+ - `Chrome/Edge not found` — установите Chrome или задайте `CHROME_PATH`
214
+ (полный путь к `chrome.exe`); `status` покажет список проверенных путей.
215
+ - `has no built-in WebSocket` — Node в рантайме DSH старше 22; нужен Node 22+
216
+ (в актуальном DSH Desktop уже Node 24).
217
+ - Таймауты на медленных страницах — увеличьте `timeoutMs` (до 120000).
218
+ - `loadTimedOut: true` в отчёте означает, что `load` не успел за таймаут, но
219
+ замер всё равно сделан по текущему DOM.
220
+ - Если временный профиль не удалился (`warnings` в ответе), повторите позже:
221
+ обычно антивирус/индексатор Windows держит файл не больше секунды.
@@ -0,0 +1,4 @@
1
+ # Host half of the browser-QA plugin.
2
+ - insert:
3
+ - id: dsh-browser-qa
4
+ name: dsh-browser-qa
@@ -0,0 +1,165 @@
1
+ /**
2
+ * dsh-browser-qa — headless-Chrome quality checks for DSH agents.
3
+ *
4
+ * One tool, three actions:
5
+ * status — detect Chrome/Edge and report its path/version;
6
+ * shot — open a URL or local HTML file, set a viewport, save a PNG;
7
+ * audit — at responsive widths measure horizontal overflow, list the
8
+ * overflowing elements and console errors, optionally save PNGs.
9
+ *
10
+ * Everything is Node-built-ins only: the DevTools protocol is spoken over the
11
+ * WebSocket client built into Node 22+/24, so there is no Playwright dependency.
12
+ */
13
+ import { auditPages, captureShot, statusReport } from "./lib/pages.js";
14
+ import { createUiGate } from "./lib/ui-gate.js";
15
+ import { errorMessage, normalizeViewport, parseTimeout, parseWidths } from "./lib/qa.js";
16
+
17
+ export const inject = ["tools"];
18
+
19
+ const TOOL_NAME = "browser_qa";
20
+ const TOOL_TIMEOUT_MS = 240_000;
21
+ const MAX_RENDER_CHARS = 24_000;
22
+
23
+ function sessionCwd(exec) {
24
+ const cwd = exec?.agent?.session?.header?.cwd;
25
+ return typeof cwd === "string" && cwd.length > 0 ? cwd : process.cwd();
26
+ }
27
+
28
+ function sessionSignal(exec) {
29
+ const signal = exec?.signal ?? exec?.abortSignal;
30
+ return signal && typeof signal.addEventListener === "function" ? signal : undefined;
31
+ }
32
+
33
+ function failure(prefix, error) {
34
+ const message = errorMessage(error);
35
+ const result = { ok: false, error: message, text: message.startsWith(prefix) ? message : `${prefix}: ${message}` };
36
+ if (error && typeof error.code === "string") result.code = error.code;
37
+ if (error && Array.isArray(error.candidates)) result.candidates = error.candidates;
38
+ return result;
39
+ }
40
+
41
+ function missingUrl(action) {
42
+ return {
43
+ ok: false,
44
+ error: "url is required",
45
+ text: `browser_qa ${action}: нужен параметр url — http(s)-адрес, file:// или путь к локальному HTML-файлу`,
46
+ };
47
+ }
48
+
49
+ function makeToolDefinition() {
50
+ return {
51
+ name: TOOL_NAME,
52
+ description:
53
+ "Headless-Chrome quality checks without Playwright. status: detect Chrome and its version. shot: open an http(s) URL or local HTML file, set the viewport, save a PNG and return its path/size. audit: load the page at responsive widths (default 390/768/1440) and report horizontal overflow (documentElement.scrollWidth vs clientWidth), the overflowing elements, console errors, markup/accessibility issues (missing alt, unlabeled fields, nameless links/buttons, duplicate ids, heading order, lang/title/viewport, positive tabindex), and optionally one PNG per width (pass outDir). Uses the Chrome DevTools protocol over a temporary profile; no external network calls beyond the page itself.",
54
+ parameters: {
55
+ type: "object",
56
+ additionalProperties: false,
57
+ properties: {
58
+ action: {
59
+ type: "string",
60
+ enum: ["shot", "audit", "status"],
61
+ description: "shot: one viewport PNG | audit: overflow/console report at responsive widths | status: Chrome path/version",
62
+ },
63
+ url: {
64
+ type: "string",
65
+ description: "http(s) URL, file:// URL or local HTML file path (relative paths resolve from the Session cwd).",
66
+ },
67
+ width: { type: "integer", description: "Viewport width for shot, in CSS px (default 1440, range 200..3840)." },
68
+ height: { type: "integer", description: "Viewport height, in CSS px (default 900, range 200..2160)." },
69
+ widths: {
70
+ type: "array",
71
+ items: { type: "integer" },
72
+ description: "Audit widths in CSS px (default [390, 768, 1440], at most 6 values).",
73
+ },
74
+ outDir: {
75
+ type: "string",
76
+ description:
77
+ "Directory for PNGs. shot default: <cwd>/.dsh/browser-qa. audit saves one PNG per width only when outDir is given.",
78
+ },
79
+ fullPage: { type: "boolean", description: "Capture the full page instead of the viewport (height capped at 16384 px)." },
80
+ timeoutMs: { type: "integer", description: "Page load timeout per width in ms (default 30000, range 3000..120000)." },
81
+ },
82
+ required: ["action"],
83
+ },
84
+ timeoutMs: TOOL_TIMEOUT_MS,
85
+ output: {
86
+ schema: { type: "object", additionalProperties: true },
87
+ render(_args, value) {
88
+ const text =
89
+ value !== null && typeof value === "object" && typeof value.text === "string"
90
+ ? value.text
91
+ : JSON.stringify(value);
92
+ return [{ type: "text", text: text.length <= MAX_RENDER_CHARS ? text : `${text.slice(0, MAX_RENDER_CHARS)}\n…` }];
93
+ },
94
+ },
95
+ async execute(args, exec) {
96
+ const action = String(args?.action ?? "").trim().toLowerCase();
97
+ const cwd = sessionCwd(exec);
98
+ const signal = sessionSignal(exec);
99
+ try {
100
+ if (action === "status") {
101
+ const status = await statusReport({ cwd });
102
+ if (status.ok !== true) {
103
+ status.error = status.chrome?.probeError ?? "Chrome is not available";
104
+ }
105
+ return status;
106
+ }
107
+ if (action === "shot") {
108
+ if (typeof args?.url !== "string" || args.url.trim().length === 0) return missingUrl("shot");
109
+ const viewport = normalizeViewport(args);
110
+ return await captureShot({
111
+ target: args.url,
112
+ width: viewport.width,
113
+ height: viewport.height,
114
+ fullPage: args?.fullPage === true,
115
+ outDir: args?.outDir,
116
+ timeoutMs: parseTimeout(args?.timeoutMs),
117
+ cwd,
118
+ signal,
119
+ });
120
+ }
121
+ if (action === "audit") {
122
+ if (typeof args?.url !== "string" || args.url.trim().length === 0) return missingUrl("audit");
123
+ const widths = parseWidths(args?.widths);
124
+ const viewport = normalizeViewport(args);
125
+ const hasOutDir = typeof args?.outDir === "string" && args.outDir.trim().length > 0;
126
+ return await auditPages({
127
+ target: args.url,
128
+ widths,
129
+ height: viewport.height,
130
+ fullPage: args?.fullPage === true,
131
+ outDir: hasOutDir ? args.outDir : null,
132
+ screenshots: hasOutDir,
133
+ timeoutMs: parseTimeout(args?.timeoutMs),
134
+ cwd,
135
+ signal,
136
+ });
137
+ }
138
+ return {
139
+ ok: false,
140
+ error: `unknown action: ${action || "(empty)"}`,
141
+ text: `browser_qa: неизвестное действие «${action || "(пусто)"}». Доступно: shot, audit, status.`,
142
+ };
143
+ } catch (error) {
144
+ return failure("browser_qa", error);
145
+ }
146
+ },
147
+ };
148
+ }
149
+
150
+ export function apply(ctx) {
151
+ const tool = makeToolDefinition();
152
+ const register = () => ctx.tools.register(tool);
153
+ if (typeof ctx.effect === "function") {
154
+ ctx.effect(register, "dsh-browser-qa: tool");
155
+ } else {
156
+ register();
157
+ }
158
+ try {
159
+ const gate = createUiGate(ctx);
160
+ if (typeof ctx.effect === "function") ctx.effect(() => () => gate.dispose(), "dsh-browser-qa: ui gate");
161
+ } catch (error) {
162
+ ctx.logger?.warn?.(`[browser-qa] ui gate skipped: ${errorMessage(error)}`);
163
+ }
164
+ ctx.logger?.info?.("[browser-qa] tool ready");
165
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Minimal Chrome DevTools Protocol client.
3
+ *
4
+ * Uses the WebSocket client built into Node.js (22+/24) so the plugin has no
5
+ * npm dependencies. Only request/response plus named events are implemented —
6
+ * enough for Page, Runtime, Log and Emulation domains used by browser_qa.
7
+ */
8
+ export class Cdp {
9
+ constructor(webSocketUrl, options = {}) {
10
+ if (typeof webSocketUrl !== "string" || webSocketUrl.length === 0) {
11
+ throw new Error("CDP websocket URL is empty");
12
+ }
13
+ if (typeof WebSocket !== "function") {
14
+ throw new Error("This Node.js runtime has no built-in WebSocket (Node 22+ is required)");
15
+ }
16
+ this.webSocketUrl = webSocketUrl;
17
+ this.ws = null;
18
+ this.nextId = 0;
19
+ this.pending = new Map();
20
+ this.listeners = new Map();
21
+ this.closeWaiters = new Set();
22
+ this.closed = false;
23
+ this.sendTimeoutMs = options.sendTimeoutMs ?? 15_000;
24
+ }
25
+
26
+ async connect(timeoutMs = 10_000) {
27
+ const ws = new WebSocket(this.webSocketUrl);
28
+ this.ws = ws;
29
+ try {
30
+ await new Promise((resolve, reject) => {
31
+ let settled = false;
32
+ const timer = setTimeout(() => {
33
+ if (settled) return;
34
+ settled = true;
35
+ cleanup();
36
+ reject(new Error("CDP websocket connect timeout"));
37
+ }, timeoutMs);
38
+ if (typeof timer.unref === "function") timer.unref();
39
+ const cleanup = () => {
40
+ clearTimeout(timer);
41
+ ws.removeEventListener("open", onOpen);
42
+ ws.removeEventListener("error", onError);
43
+ ws.removeEventListener("close", onClose);
44
+ };
45
+ const onOpen = () => {
46
+ if (settled) return;
47
+ settled = true;
48
+ cleanup();
49
+ resolve();
50
+ };
51
+ const onError = () => {
52
+ if (settled) return;
53
+ settled = true;
54
+ cleanup();
55
+ reject(new Error("CDP websocket connection error"));
56
+ };
57
+ const onClose = () => {
58
+ if (settled) return;
59
+ settled = true;
60
+ cleanup();
61
+ reject(new Error("CDP websocket closed before it opened"));
62
+ };
63
+ ws.addEventListener("open", onOpen);
64
+ ws.addEventListener("error", onError);
65
+ ws.addEventListener("close", onClose);
66
+ });
67
+ } catch (error) {
68
+ try {
69
+ ws.close();
70
+ } catch {
71
+ /* already closed */
72
+ }
73
+ this.ws = null;
74
+ this.closed = true;
75
+ throw error;
76
+ }
77
+ this.closed = false;
78
+ ws.addEventListener("message", (event) => this.handleMessage(event.data));
79
+ ws.addEventListener("close", () => this.handleClose());
80
+ ws.addEventListener("error", () => {
81
+ /* a close event follows; pending calls will be rejected there */
82
+ });
83
+ return this;
84
+ }
85
+
86
+ isClosed() {
87
+ return this.closed || this.ws === null;
88
+ }
89
+
90
+ handleMessage(raw) {
91
+ let text;
92
+ if (typeof raw === "string") text = raw;
93
+ else if (raw instanceof ArrayBuffer) text = Buffer.from(raw).toString("utf8");
94
+ else if (ArrayBuffer.isView(raw)) text = Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString("utf8");
95
+ else text = String(raw ?? "");
96
+ let message;
97
+ try {
98
+ message = JSON.parse(text);
99
+ } catch {
100
+ return;
101
+ }
102
+ if (message === null || typeof message !== "object") return;
103
+ if (message.id !== undefined) {
104
+ const pending = this.pending.get(message.id);
105
+ if (pending === undefined) return;
106
+ this.pending.delete(message.id);
107
+ clearTimeout(pending.timer);
108
+ if (message.error) pending.reject(new Error(message.error.message || "CDP request failed"));
109
+ else pending.resolve(message.result);
110
+ return;
111
+ }
112
+ if (typeof message.method !== "string") return;
113
+ const listeners = this.listeners.get(message.method);
114
+ if (!listeners || listeners.size === 0) return;
115
+ for (const listener of [...listeners]) {
116
+ try {
117
+ listener(message.params ?? {});
118
+ } catch {
119
+ /* a listener must never break the protocol loop */
120
+ }
121
+ }
122
+ }
123
+
124
+ handleClose() {
125
+ if (this.closed) return;
126
+ this.closed = true;
127
+ this.ws = null;
128
+ const error = new Error("CDP connection closed");
129
+ for (const pending of this.pending.values()) {
130
+ clearTimeout(pending.timer);
131
+ pending.reject(error);
132
+ }
133
+ this.pending.clear();
134
+ for (const fail of [...this.closeWaiters]) {
135
+ try {
136
+ fail(error);
137
+ } catch {
138
+ /* ignore */
139
+ }
140
+ }
141
+ this.closeWaiters.clear();
142
+ }
143
+
144
+ send(method, params = {}, timeoutMs = this.sendTimeoutMs) {
145
+ if (this.isClosed()) return Promise.reject(new Error(`CDP connection is closed (${method})`));
146
+ const id = (this.nextId += 1);
147
+ return new Promise((resolve, reject) => {
148
+ const timer = setTimeout(() => {
149
+ this.pending.delete(id);
150
+ reject(new Error(`CDP timeout: ${method} (${timeoutMs} ms)`));
151
+ }, timeoutMs);
152
+ if (typeof timer.unref === "function") timer.unref();
153
+ this.pending.set(id, { resolve, reject, timer, method });
154
+ try {
155
+ this.ws.send(JSON.stringify({ id, method, params }));
156
+ } catch (error) {
157
+ clearTimeout(timer);
158
+ this.pending.delete(id);
159
+ reject(error instanceof Error ? error : new Error(String(error)));
160
+ }
161
+ });
162
+ }
163
+
164
+ /** Subscribe to a CDP event; returns an unsubscribe function. */
165
+ on(method, listener) {
166
+ if (typeof listener !== "function") throw new Error("CDP listener must be a function");
167
+ if (!this.listeners.has(method)) this.listeners.set(method, new Set());
168
+ this.listeners.get(method).add(listener);
169
+ return () => {
170
+ const listeners = this.listeners.get(method);
171
+ if (!listeners) return;
172
+ listeners.delete(listener);
173
+ if (listeners.size === 0) this.listeners.delete(method);
174
+ };
175
+ }
176
+
177
+ /** Resolve on the next event, reject on timeout or connection close. */
178
+ once(method, timeoutMs = 15_000) {
179
+ return new Promise((resolve, reject) => {
180
+ let settled = false;
181
+ let off = null;
182
+ const finish = (callback, value) => {
183
+ if (settled) return;
184
+ settled = true;
185
+ clearTimeout(timer);
186
+ if (off) off();
187
+ this.closeWaiters.delete(fail);
188
+ callback(value);
189
+ };
190
+ const fail = (error) => finish(reject, error);
191
+ const timer = setTimeout(() => finish(reject, new Error(`CDP event timeout: ${method} (${timeoutMs} ms)`)), timeoutMs);
192
+ if (typeof timer.unref === "function") timer.unref();
193
+ this.closeWaiters.add(fail);
194
+ off = this.on(method, (params) => finish(resolve, params));
195
+ });
196
+ }
197
+
198
+ close() {
199
+ if (this.closed) return;
200
+ this.closed = true;
201
+ const error = new Error("CDP client closed");
202
+ for (const pending of this.pending.values()) {
203
+ clearTimeout(pending.timer);
204
+ pending.reject(error);
205
+ }
206
+ this.pending.clear();
207
+ for (const fail of [...this.closeWaiters]) {
208
+ try {
209
+ fail(error);
210
+ } catch {
211
+ /* ignore */
212
+ }
213
+ }
214
+ this.closeWaiters.clear();
215
+ this.listeners.clear();
216
+ const ws = this.ws;
217
+ this.ws = null;
218
+ try {
219
+ ws?.close();
220
+ } catch {
221
+ /* already closed */
222
+ }
223
+ }
224
+ }