dsh-desktop-kit 2.2.1 → 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 (125) hide show
  1. package/CHANGELOG.md +80 -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 +4 -2
  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 +5 -5
  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/index.js +18 -1
  23. package/package.json +26 -6
  24. package/plugins/dsh-agent-extensions/README.md +99 -0
  25. package/plugins/dsh-agent-extensions/index.js +167 -0
  26. package/plugins/dsh-agent-extensions/lib/agents.js +76 -0
  27. package/plugins/dsh-agent-extensions/lib/checkpoints.js +103 -0
  28. package/plugins/dsh-agent-extensions/lib/commands.js +255 -0
  29. package/plugins/dsh-agent-extensions/lib/hooks.js +182 -0
  30. package/plugins/dsh-agent-extensions/lib/policy.js +84 -0
  31. package/plugins/dsh-agent-extensions/lib/project.js +144 -0
  32. package/plugins/dsh-agent-extensions/lib/scaffold.js +151 -0
  33. package/plugins/dsh-agent-extensions/package.json +9 -0
  34. package/plugins/dsh-browser-qa/README.md +221 -0
  35. package/plugins/dsh-browser-qa/cordis.patch.yml +4 -0
  36. package/plugins/dsh-browser-qa/index.js +165 -0
  37. package/plugins/dsh-browser-qa/lib/cdp.js +224 -0
  38. package/plugins/dsh-browser-qa/lib/chrome.js +466 -0
  39. package/plugins/dsh-browser-qa/lib/pages.js +552 -0
  40. package/plugins/dsh-browser-qa/lib/qa.js +827 -0
  41. package/plugins/dsh-browser-qa/lib/ui-gate.js +256 -0
  42. package/plugins/dsh-browser-qa/package.json +31 -0
  43. package/plugins/dsh-checkpoints/README.md +22 -0
  44. package/plugins/dsh-checkpoints/cordis.patch.yml +3 -0
  45. package/plugins/dsh-checkpoints/index.js +80 -0
  46. package/plugins/dsh-checkpoints/lib/checkpoints.js +111 -0
  47. package/plugins/dsh-checkpoints/package.json +25 -0
  48. package/plugins/dsh-github-review/README.md +131 -0
  49. package/plugins/dsh-github-review/cordis.patch.yml +3 -0
  50. package/plugins/dsh-github-review/index.js +536 -0
  51. package/plugins/dsh-github-review/lib/gh.js +494 -0
  52. package/plugins/dsh-github-review/lib/normalize.js +662 -0
  53. package/plugins/dsh-github-review/package.json +28 -0
  54. package/plugins/dsh-guardrails/README.md +264 -0
  55. package/plugins/dsh-guardrails/cordis.patch.yml +5 -0
  56. package/plugins/dsh-guardrails/index.js +389 -0
  57. package/plugins/dsh-guardrails/lib/hooks.js +262 -0
  58. package/plugins/dsh-guardrails/lib/modes.js +77 -0
  59. package/plugins/dsh-guardrails/lib/policy.js +592 -0
  60. package/plugins/dsh-guardrails/lib/scan.js +451 -0
  61. package/plugins/dsh-guardrails/lib/secrets.js +274 -0
  62. package/plugins/dsh-guardrails/package.json +26 -0
  63. package/plugins/dsh-mcp-manager/README.md +78 -0
  64. package/plugins/dsh-mcp-manager/client.js +373 -0
  65. package/plugins/dsh-mcp-manager/index.js +159 -0
  66. package/plugins/dsh-mcp-manager/lib/api.js +250 -0
  67. package/plugins/dsh-mcp-manager/lib/client.js +219 -0
  68. package/plugins/dsh-mcp-manager/lib/config.js +217 -0
  69. package/plugins/dsh-mcp-manager/lib/manager.js +146 -0
  70. package/plugins/dsh-mcp-manager/package.json +18 -0
  71. package/plugins/dsh-mcp-manager/screenshots/modal-390.png +0 -0
  72. package/plugins/dsh-mcp-manager/screenshots/settings-1440.png +0 -0
  73. package/plugins/dsh-mcp-manager/screenshots/settings-390.png +0 -0
  74. package/plugins/dsh-memory-bridge/README.md +161 -0
  75. package/plugins/dsh-memory-bridge/client.js +2264 -0
  76. package/plugins/dsh-memory-bridge/cordis.patch.yml +5 -0
  77. package/plugins/dsh-memory-bridge/docs/images/conflicts.png +0 -0
  78. package/plugins/dsh-memory-bridge/docs/images/hero.png +0 -0
  79. package/plugins/dsh-memory-bridge/docs/images/settings-dark.png +0 -0
  80. package/plugins/dsh-memory-bridge/docs/images/settings-light.png +0 -0
  81. package/plugins/dsh-memory-bridge/docs/images/settings-mobile.png +0 -0
  82. package/plugins/dsh-memory-bridge/docs/images/setup.png +0 -0
  83. package/plugins/dsh-memory-bridge/icon.svg +13 -0
  84. package/plugins/dsh-memory-bridge/index.js +378 -0
  85. package/plugins/dsh-memory-bridge/lib/bridge.js +636 -0
  86. package/plugins/dsh-memory-bridge/lib/github.js +473 -0
  87. package/plugins/dsh-memory-bridge/lib/merge.js +205 -0
  88. package/plugins/dsh-memory-bridge/lib/portable.js +314 -0
  89. package/plugins/dsh-memory-bridge/lib/state.js +565 -0
  90. package/plugins/dsh-memory-bridge/lib/sync.js +984 -0
  91. package/plugins/dsh-memory-bridge/locale/en.json +6 -0
  92. package/plugins/dsh-memory-bridge/locale/ru.json +6 -0
  93. package/plugins/dsh-memory-bridge/package.json +67 -0
  94. package/plugins/dsh-peak-guard/README.md +16 -3
  95. package/plugins/dsh-peak-guard/client.js +223 -8
  96. package/plugins/dsh-peak-guard/package.json +1 -1
  97. package/plugins/dsh-plan-mode/README.md +24 -0
  98. package/plugins/dsh-plan-mode/cordis.patch.yml +3 -0
  99. package/plugins/dsh-plan-mode/index.js +103 -0
  100. package/plugins/dsh-plan-mode/lib/plan.js +89 -0
  101. package/plugins/dsh-plan-mode/package.json +18 -0
  102. package/plugins/dsh-profile-transfer/client.js +277 -17
  103. package/plugins/dsh-profile-transfer/index.js +10 -0
  104. package/plugins/dsh-profile-transfer/package.json +3 -1
  105. package/plugins/dsh-profile-transfer/transfer.js +101 -2
  106. package/plugins/dsh-project-memory/index.js +92 -2
  107. package/plugins/dsh-project-memory/package.json +1 -1
  108. package/plugins/dsh-repo-map/README.md +26 -0
  109. package/plugins/dsh-repo-map/cordis.patch.yml +3 -0
  110. package/plugins/dsh-repo-map/index.js +99 -0
  111. package/plugins/dsh-repo-map/lib/repo-map.js +170 -0
  112. package/plugins/dsh-repo-map/package.json +25 -0
  113. package/plugins/dsh-skill-autopilot/index.js +135 -11
  114. package/skills/delivery-gate/SKILL.md +31 -0
  115. package/skills/git-and-ci/SKILL.md +5 -1
  116. package/skills/manifest.json +31 -6
  117. package/skills/requirements-and-planning/SKILL.md +3 -0
  118. package/skills/research-first/SKILL.md +35 -0
  119. package/skills/research-first/references/query-patterns.md +20 -0
  120. package/skills/subagent-playbook/SKILL.md +36 -0
  121. package/skills/subagent-playbook/references/prompts.md +39 -0
  122. package/skills/ui-craft/SKILL.md +43 -0
  123. package/skills/visual-qa/SKILL.md +29 -0
  124. package/skills/visual-qa/references/commands.md +32 -0
  125. package/skills-baseline.json +219 -0
@@ -0,0 +1,827 @@
1
+ /**
2
+ * dsh-browser-qa — pure helpers shared by the host tool and the tests.
3
+ *
4
+ * This module deliberately has no Chrome/DevTools side effects: target
5
+ * normalisation, viewport/width parsing, overflow-report shaping, console-error
6
+ * shaping and text formatting are all plain functions so they can be tested
7
+ * without launching a browser.
8
+ */
9
+ import crypto from "node:crypto";
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { fileURLToPath, pathToFileURL } from "node:url";
13
+
14
+ export const DEFAULT_WIDTHS = Object.freeze([390, 768, 1440]);
15
+ export const DEFAULT_VIEWPORT = Object.freeze({ width: 1440, height: 900 });
16
+
17
+ export const LIMITS = Object.freeze({
18
+ minWidth: 200,
19
+ maxWidth: 3840,
20
+ minHeight: 200,
21
+ maxHeight: 2160,
22
+ maxWidths: 6,
23
+ minTimeoutMs: 3_000,
24
+ maxTimeoutMs: 120_000,
25
+ defaultTimeoutMs: 30_000,
26
+ maxOffenders: 25,
27
+ maxConsoleErrors: 30,
28
+ maxTextLength: 4_000,
29
+ });
30
+
31
+ /** Chrome refuses to rasterise surfaces above this size; full-page shots clip. */
32
+ export const MAX_SCREENSHOT_HEIGHT = 16_384;
33
+
34
+ const ALLOWED_PROTOCOLS = new Set(["http:", "https:", "file:", "about:"]);
35
+
36
+ /* ------------------------------------------------------------------ */
37
+ /* numbers, viewports, widths, timeouts */
38
+ /* ------------------------------------------------------------------ */
39
+
40
+ export function clampInt(value, min, max, fallback) {
41
+ const number = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
42
+ if (!Number.isFinite(number)) return fallback;
43
+ return Math.min(max, Math.max(min, Math.round(number)));
44
+ }
45
+
46
+ export function normalizeViewport(args = {}) {
47
+ return {
48
+ width: clampInt(args?.width, LIMITS.minWidth, LIMITS.maxWidth, DEFAULT_VIEWPORT.width),
49
+ height: clampInt(args?.height, LIMITS.minHeight, LIMITS.maxHeight, DEFAULT_VIEWPORT.height),
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Audit widths: integers clamped to 200..3840, de-duplicated, at most six.
55
+ * Invalid/empty input falls back to the responsive defaults 390/768/1440.
56
+ */
57
+ export function parseWidths(widths, fallback = DEFAULT_WIDTHS) {
58
+ const source = Array.isArray(widths) ? widths : [];
59
+ const seen = new Set();
60
+ const result = [];
61
+ for (const value of source) {
62
+ if (result.length >= LIMITS.maxWidths) break;
63
+ const width = clampInt(value, LIMITS.minWidth, LIMITS.maxWidth, null);
64
+ if (width === null || seen.has(width)) continue;
65
+ seen.add(width);
66
+ result.push(width);
67
+ }
68
+ return result.length > 0 ? result : [...fallback];
69
+ }
70
+
71
+ export function parseTimeout(value, fallback = LIMITS.defaultTimeoutMs) {
72
+ return clampInt(value, LIMITS.minTimeoutMs, LIMITS.maxTimeoutMs, fallback);
73
+ }
74
+
75
+ /* ------------------------------------------------------------------ */
76
+ /* targets and output naming */
77
+ /* ------------------------------------------------------------------ */
78
+
79
+ function isWindowsAbsolute(raw) {
80
+ return /^[a-zA-Z]:[\\/]/u.test(raw) || raw.startsWith("\\\\");
81
+ }
82
+
83
+ /**
84
+ * Accept an http(s) URL, a file:// URL, an absolute local HTML path, or a path
85
+ * relative to `cwd`. Throws with a readable message for unsupported schemes and
86
+ * missing files.
87
+ */
88
+ export function normalizeTarget(input, options = {}) {
89
+ const cwd = typeof options.cwd === "string" && options.cwd.length > 0 ? options.cwd : process.cwd();
90
+ const raw = String(input ?? "").trim();
91
+ if (raw.length === 0) {
92
+ throw new Error("url is required: pass an http(s) URL, file:// URL or a local HTML file path");
93
+ }
94
+ const windowsAbsolute = isWindowsAbsolute(raw);
95
+ const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/u.test(raw);
96
+ if (windowsAbsolute || !hasScheme) {
97
+ const file = path.resolve(cwd, raw);
98
+ return { input: raw, kind: "file", url: pathToFileURL(file).href, file };
99
+ }
100
+ let parsed;
101
+ try {
102
+ parsed = new URL(raw);
103
+ } catch {
104
+ throw new Error(`invalid URL: ${raw}`);
105
+ }
106
+ if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {
107
+ throw new Error(`unsupported URL scheme "${parsed.protocol}": use http(s), file:// or about:blank`);
108
+ }
109
+ if (parsed.protocol === "about:") {
110
+ if (parsed.href !== "about:blank") throw new Error(`unsupported about: URL "${parsed.href}": only about:blank is allowed`);
111
+ return { input: raw, kind: "about", url: "about:blank" };
112
+ }
113
+ if (parsed.protocol === "file:") {
114
+ let file;
115
+ try {
116
+ file = fileURLToPath(parsed);
117
+ } catch {
118
+ throw new Error(`unsupported file URL (network shares are not supported): ${raw}`);
119
+ }
120
+ return { input: raw, kind: "file", url: parsed.href, file };
121
+ }
122
+ return { input: raw, kind: "http", url: parsed.href };
123
+ }
124
+
125
+ /** True when the target points at an existing file; used by validate/status paths. */
126
+ export function targetExists(target) {
127
+ const file = typeof target === "string" ? target : target?.file;
128
+ if (typeof file !== "string" || file.length === 0) return true;
129
+ try {
130
+ return fs.statSync(file).isFile();
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ /** Assert that a local target really exists; http(s) targets need no check. */
137
+ export function validateTarget(target) {
138
+ if (target?.kind === "file" && !targetExists(target)) {
139
+ throw new Error(`local file not found: ${target.file}`);
140
+ }
141
+ return target;
142
+ }
143
+
144
+ function windowsReservedName(slug) {
145
+ return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/iu.test(slug);
146
+ }
147
+
148
+ /**
149
+ * Filesystem-safe, human-readable slug for screenshot names. Keeps dots so
150
+ * hostnames stay readable, collapses separators, caps at 60 chars.
151
+ */
152
+ export function sanitizeSlug(value) {
153
+ let slug = String(value ?? "")
154
+ .trim()
155
+ .toLowerCase()
156
+ .replace(/[^\p{L}\p{N}._-]+/gu, "-")
157
+ .replace(/-{2,}/gu, "-")
158
+ .replace(/^[-._]+|[-._]+$/gu, "")
159
+ .slice(0, 60);
160
+ if (slug.length === 0) return "page";
161
+ if (windowsReservedName(slug)) slug = `page-${slug}`;
162
+ return slug;
163
+ }
164
+
165
+ export function slugForTarget(target) {
166
+ const raw = typeof target === "string" ? target : target?.url ?? "";
167
+ try {
168
+ const parsed = new URL(raw);
169
+ if (parsed.protocol === "file:") {
170
+ return sanitizeSlug(path.basename(fileURLToPath(parsed)).replace(/\.[^.]+$/u, ""));
171
+ }
172
+ return sanitizeSlug(`${parsed.host}${parsed.pathname}`);
173
+ } catch {
174
+ return sanitizeSlug(raw);
175
+ }
176
+ }
177
+
178
+ function pad(number, length = 2) {
179
+ return String(number).padStart(length, "0");
180
+ }
181
+
182
+ /** Local timestamp `YYYYMMDD-HHmmss` used in screenshot names. */
183
+ export function formatTimestamp(date = new Date()) {
184
+ const value = date instanceof Date && !Number.isNaN(date.getTime()) ? date : new Date();
185
+ return `${value.getFullYear()}${pad(value.getMonth() + 1)}${pad(value.getDate())}-${pad(value.getHours())}${pad(value.getMinutes())}${pad(value.getSeconds())}`;
186
+ }
187
+
188
+ export function screenshotFileName(options = {}) {
189
+ const { target, width, height, fullPage = false, at = new Date(), random } = options;
190
+ const nonce = String(random ?? crypto.randomBytes(3).toString("hex"))
191
+ .toLowerCase()
192
+ .replace(/[^a-z0-9]/gu, "")
193
+ .slice(0, 8);
194
+ return `${slugForTarget(target)}-${clampInt(width, 1, 99_999, DEFAULT_VIEWPORT.width)}x${clampInt(height, 1, 99_999, DEFAULT_VIEWPORT.height)}${fullPage ? "-full" : ""}-${formatTimestamp(at)}-${nonce || "0000"}.png`;
195
+ }
196
+
197
+ /** Where PNGs go: explicit outDir, otherwise `<cwd>/.dsh/browser-qa`. */
198
+ export function resolveOutDir(outDir, cwd = process.cwd()) {
199
+ const base = typeof cwd === "string" && cwd.length > 0 ? cwd : process.cwd();
200
+ if (typeof outDir === "string" && outDir.trim().length > 0) return path.resolve(base, outDir.trim());
201
+ return path.join(path.resolve(base), ".dsh", "browser-qa");
202
+ }
203
+
204
+ /** Read PNG dimensions from the IHDR header (no image library needed). */
205
+ export function pngSize(buffer) {
206
+ const data = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer ?? []);
207
+ if (data.length < 24 || data[0] !== 0x89 || data[1] !== 0x50 || data[2] !== 0x4e || data[3] !== 0x47) {
208
+ throw new Error("not a PNG image");
209
+ }
210
+ return { width: data.readUInt32BE(16), height: data.readUInt32BE(20) };
211
+ }
212
+
213
+ /* ------------------------------------------------------------------ */
214
+ /* in-page overflow probe */
215
+ /* ------------------------------------------------------------------ */
216
+
217
+ /* Serialised with Function#toString and evaluated through Runtime.evaluate —
218
+ keep it dependency-free and self-contained. */
219
+ function overflowProbe() {
220
+ const TOLERANCE = 1;
221
+ const MAX_OFFENDERS = 40;
222
+ const MAX_SCANNED = 5_000;
223
+ const docEl = document.documentElement;
224
+ const innerWidth = window.innerWidth;
225
+ const clientWidth = docEl.clientWidth;
226
+ const body = document.body;
227
+ const bodyScrollWidth = body ? body.scrollWidth : 0;
228
+ const scrollWidth = Math.max(docEl.scrollWidth, bodyScrollWidth);
229
+
230
+ const cssEscape = (value) => {
231
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(value);
232
+ return String(value).replace(/[^a-zA-Z0-9_-]/g, (character) => "\\" + character);
233
+ };
234
+
235
+ const selectorFor = (element) => {
236
+ const parts = [];
237
+ let node = element;
238
+ for (let depth = 0; node && node.nodeType === 1 && depth < 4; depth += 1) {
239
+ if (node.id) {
240
+ parts.unshift(`#${cssEscape(node.id)}`);
241
+ break;
242
+ }
243
+ const tag = node.tagName.toLowerCase();
244
+ const className = typeof node.className === "string" ? node.className.trim() : "";
245
+ const classes = className ? className.split(/\s+/).filter(Boolean).slice(0, 2) : [];
246
+ let part = tag + (classes.length > 0 ? "." + classes.map(cssEscape).join(".") : "");
247
+ const parent = node.parentElement;
248
+ if (parent) {
249
+ const sameTag = Array.prototype.filter.call(parent.children, (child) => child.tagName === node.tagName);
250
+ if (sameTag.length > 1) part += `:nth-of-type(${sameTag.indexOf(node) + 1})`;
251
+ }
252
+ parts.unshift(part);
253
+ node = node.parentElement;
254
+ }
255
+ return parts.join(" > ");
256
+ };
257
+
258
+ const clippedHorizontally = (element) => {
259
+ let parent = element.parentElement;
260
+ while (parent && parent !== docEl) {
261
+ const style = getComputedStyle(parent);
262
+ if (style.overflowX !== "visible") return true;
263
+ parent = parent.parentElement;
264
+ }
265
+ return false;
266
+ };
267
+
268
+ const offenders = [];
269
+ let total = 0;
270
+ let scanned = 0;
271
+ const candidates = body ? [body, ...Array.prototype.slice.call(body.querySelectorAll("*"), 0, MAX_SCANNED - 1)] : [];
272
+ for (const element of candidates) {
273
+ scanned += 1;
274
+ if (element === body && element.children.length === 0 && !element.textContent) continue;
275
+ const style = getComputedStyle(element);
276
+ if (style.display === "none" || style.visibility === "hidden") continue;
277
+ const rect = element.getBoundingClientRect();
278
+ if (rect.width <= 0 && rect.height <= 0) continue;
279
+ const overflowRightPx = rect.right - innerWidth;
280
+ const overflowLeftPx = -rect.left;
281
+ if (overflowRightPx <= TOLERANCE && overflowLeftPx <= TOLERANCE) continue;
282
+ if (clippedHorizontally(element)) continue;
283
+ total += 1;
284
+ if (offenders.length >= MAX_OFFENDERS) continue;
285
+ offenders.push({
286
+ tag: element.tagName.toLowerCase(),
287
+ id: element.id || "",
288
+ classes: (typeof element.className === "string" ? element.className : "").trim().slice(0, 160),
289
+ selector: selectorFor(element).slice(0, 220),
290
+ rect: {
291
+ x: Math.round(rect.x),
292
+ y: Math.round(rect.y),
293
+ width: Math.round(rect.width),
294
+ height: Math.round(rect.height),
295
+ right: Math.round(rect.right),
296
+ },
297
+ overflowRightPx: Math.round(Math.max(0, overflowRightPx)),
298
+ overflowLeftPx: Math.round(Math.max(0, overflowLeftPx)),
299
+ text: (element.textContent || "").replace(/\s+/gu, " ").trim().slice(0, 120),
300
+ });
301
+ }
302
+ offenders.sort(
303
+ (left, right) =>
304
+ right.overflowRightPx + right.overflowLeftPx - (left.overflowRightPx + left.overflowLeftPx),
305
+ );
306
+ return JSON.stringify({
307
+ url: location.href,
308
+ title: document.title || "",
309
+ readyState: document.readyState,
310
+ viewport: { innerWidth, innerHeight: window.innerHeight, devicePixelRatio: window.devicePixelRatio || 1 },
311
+ document: { scrollWidth, clientWidth, scrollHeight: docEl.scrollHeight },
312
+ bodyScrollWidth,
313
+ scannedElements: scanned,
314
+ overflowingTotal: total,
315
+ offenders,
316
+ });
317
+ }
318
+
319
+ export const OVERFLOW_EXPRESSION = `(${overflowProbe.toString()})()`;
320
+
321
+ /**
322
+ * Markup/accessibility probe: duplicate ids, missing alt, unlabeled controls,
323
+ * nameless links/buttons, heading order, lang/title/viewport and positive
324
+ * tabindex. Runs in the page; returns a bounded JSON string.
325
+ */
326
+ function markupProbe() {
327
+ const issues = [];
328
+ const counts = { elements: 0, images: 0, links: 0, buttons: 0, fields: 0, headings: 0 };
329
+ const add = (rule, severity, element, message) => {
330
+ if (issues.length >= 60) return;
331
+ let selector = "";
332
+ try {
333
+ if (element && element.id) {
334
+ selector = "#" + CSS.escape(element.id);
335
+ } else if (element && element.tagName) {
336
+ const parts = [];
337
+ let node = element;
338
+ for (let depth = 0; node && node.nodeType === 1 && depth < 4; depth += 1) {
339
+ let part = node.tagName.toLowerCase();
340
+ if (node.classList && node.classList.length > 0) {
341
+ part += "." + Array.from(node.classList).slice(0, 2).map((name) => CSS.escape(name)).join(".");
342
+ }
343
+ parts.unshift(part);
344
+ node = node.parentElement;
345
+ }
346
+ selector = parts.join(" > ");
347
+ }
348
+ } catch {
349
+ selector = "";
350
+ }
351
+ issues.push({
352
+ rule,
353
+ severity,
354
+ selector: selector.slice(0, 180),
355
+ message: String(message).slice(0, 180),
356
+ });
357
+ };
358
+ const visible = (element) => {
359
+ if (!element || element.nodeType !== 1) return false;
360
+ if (element.hidden === true || element.getAttribute("aria-hidden") === "true") return false;
361
+ const style = getComputedStyle(element);
362
+ if (style.display === "none" || style.visibility === "hidden") return false;
363
+ return element.getClientRects().length > 0 || style.position === "fixed";
364
+ };
365
+ const textOf = (element) => String((element && (element.innerText || element.textContent)) || "").replace(/\s+/gu, " ").trim();
366
+ const accessibleName = (element) => {
367
+ const aria = String(element.getAttribute("aria-label") || "").trim();
368
+ if (aria.length > 0) return aria;
369
+ const ids = String(element.getAttribute("aria-labelledby") || "").split(/\s+/u).filter(Boolean);
370
+ for (const id of ids) {
371
+ const node = document.getElementById(id);
372
+ if (node && textOf(node).length > 0) return textOf(node);
373
+ }
374
+ const title = String(element.getAttribute("title") || "").trim();
375
+ if (title.length > 0) return title;
376
+ const text = textOf(element);
377
+ if (text.length > 0) return text;
378
+ const image = element.querySelector && element.querySelector("img[alt]");
379
+ return image && String(image.getAttribute("alt") || "").trim().length > 0 ? String(image.getAttribute("alt")).trim() : "";
380
+ };
381
+
382
+ counts.elements = document.querySelectorAll("*").length;
383
+ if (!String(document.documentElement.getAttribute("lang") || "").trim()) {
384
+ add("html-lang", "serious", document.documentElement, "У <html> нет lang — скринридер не знает язык страницы");
385
+ }
386
+ if (textOf(document.head?.querySelector("title") || document.querySelector("title")).length === 0) {
387
+ add("document-title", "warning", document.documentElement, "Пустой <title> — плохо для вкладки, SEO и скринридера");
388
+ }
389
+ if (!document.querySelector('meta[name="viewport"]')) {
390
+ add("meta-viewport", "warning", document.documentElement, "Нет meta viewport — мобильный layout может масштабироваться неверно");
391
+ }
392
+
393
+ const ids = new Map();
394
+ for (const element of document.querySelectorAll("[id]")) {
395
+ const id = element.id;
396
+ if (id) ids.set(id, (ids.get(id) || 0) + 1);
397
+ }
398
+ for (const [id, count] of ids) {
399
+ if (count > 1) {
400
+ add("duplicate-id", "serious", document.getElementById(id), `id="${id}" встречается ${count} раз`);
401
+ }
402
+ }
403
+
404
+ for (const image of Array.from(document.images)) {
405
+ counts.images += 1;
406
+ if (!image.hasAttribute("alt")) {
407
+ add("image-alt", "serious", image, "У <img> нет alt; для декоративного используйте alt=\"\"");
408
+ }
409
+ }
410
+
411
+ for (const link of Array.from(document.querySelectorAll("a[href]"))) {
412
+ if (!visible(link)) continue;
413
+ counts.links += 1;
414
+ if (accessibleName(link).length === 0) {
415
+ add("link-name", "serious", link, "Ссылка без доступного имени: добавьте текст, aria-label или alt у картинки");
416
+ }
417
+ }
418
+
419
+ for (const button of Array.from(document.querySelectorAll('button, [role="button"]'))) {
420
+ if (!visible(button)) continue;
421
+ counts.buttons += 1;
422
+ if (accessibleName(button).length === 0) {
423
+ add("button-name", "serious", button, "Кнопка без доступного имени");
424
+ }
425
+ }
426
+
427
+ for (const field of Array.from(document.querySelectorAll("input, select, textarea"))) {
428
+ if (field.type === "hidden" || field.getAttribute("aria-hidden") === "true" || !visible(field)) continue;
429
+ counts.fields += 1;
430
+ const labelled = Boolean((field.labels && field.labels.length > 0) || field.getAttribute("aria-label") || field.getAttribute("aria-labelledby") || field.getAttribute("title"));
431
+ if (!labelled) {
432
+ add("field-label", "serious", field, "Поле без label/aria-label — непонятно, что вводить");
433
+ }
434
+ }
435
+
436
+ const headings = Array.from(document.querySelectorAll("h1, h2, h3, h4, h5, h6")).filter(visible);
437
+ counts.headings = headings.length;
438
+ let previous = 0;
439
+ for (const heading of headings) {
440
+ const level = Number(heading.tagName.slice(1));
441
+ if (previous > 0 && level > previous + 1) {
442
+ add("heading-order", "moderate", heading, `Пропущен уровень заголовка: h${previous} → h${level}`);
443
+ }
444
+ previous = level;
445
+ }
446
+ const h1Count = headings.filter((heading) => heading.tagName === "H1").length;
447
+ if (h1Count > 1) add("heading-h1", "warning", headings.find((heading) => heading.tagName === "H1"), `Несколько h1 (${h1Count}) — выберите один главный`);
448
+
449
+ for (const element of Array.from(document.querySelectorAll("[tabindex]"))) {
450
+ const value = Number(element.getAttribute("tabindex"));
451
+ if (Number.isFinite(value) && value > 0 && visible(element)) {
452
+ add("positive-tabindex", "moderate", element, `tabindex="${value}" ломает естественный порядок фокуса`);
453
+ }
454
+ }
455
+
456
+ return JSON.stringify({
457
+ url: location.href,
458
+ title: document.title || "",
459
+ lang: document.documentElement.getAttribute("lang") || "",
460
+ counts,
461
+ issues,
462
+ total: issues.length,
463
+ });
464
+ }
465
+
466
+ export const MARKUP_EXPRESSION = `(${markupProbe.toString()})()`;
467
+
468
+ /** Waits for web fonts and lets the layout settle, bounded to ~1.75 s. */
469
+ export const SETTLE_EXPRESSION = `(async () => {
470
+ try {
471
+ if (document.fonts && document.fonts.ready && document.fonts.status !== "loaded") {
472
+ await Promise.race([document.fonts.ready, new Promise((resolve) => setTimeout(resolve, 1500))]);
473
+ }
474
+ } catch {}
475
+ await new Promise((resolve) => setTimeout(resolve, 250));
476
+ return true;
477
+ })()`;
478
+
479
+ export const PAGE_INFO_EXPRESSION =
480
+ 'JSON.stringify({ url: location.href, title: document.title || "", readyState: document.readyState })';
481
+
482
+ /* ------------------------------------------------------------------ */
483
+ /* reports */
484
+ /* ------------------------------------------------------------------ */
485
+
486
+ function finiteInt(value, fallback = 0) {
487
+ const number = Number(value);
488
+ return Number.isFinite(number) ? Math.round(number) : fallback;
489
+ }
490
+
491
+ function boundedText(value, max) {
492
+ return String(value ?? "").replace(/\s+/gu, " ").trim().slice(0, max);
493
+ }
494
+
495
+ export function shapeOffender(raw = {}) {
496
+ const rect = raw?.rect ?? {};
497
+ return {
498
+ tag: boundedText(raw?.tag, 40).toLowerCase() || "unknown",
499
+ id: boundedText(raw?.id, 80),
500
+ classes: boundedText(raw?.classes, 160),
501
+ selector: boundedText(raw?.selector, 220) || "unknown",
502
+ rect: {
503
+ x: finiteInt(rect.x),
504
+ y: finiteInt(rect.y),
505
+ width: finiteInt(rect.width),
506
+ height: finiteInt(rect.height),
507
+ right: finiteInt(rect.right),
508
+ },
509
+ overflowRightPx: Math.max(0, finiteInt(raw?.overflowRightPx)),
510
+ overflowLeftPx: Math.max(0, finiteInt(raw?.overflowLeftPx)),
511
+ text: boundedText(raw?.text, 120),
512
+ };
513
+ }
514
+
515
+ /**
516
+ * Turn the raw in-page measurement into the bounded audit block:
517
+ * `documentElement.scrollWidth` vs `clientWidth`, 1 px tolerance, at most 25
518
+ * listed elements.
519
+ */
520
+ export function shapeOverflowReport(raw = {}, options = {}) {
521
+ const maxOffenders = options.maxOffenders ?? LIMITS.maxOffenders;
522
+ const viewportRaw = raw?.viewport ?? {};
523
+ const documentRaw = raw?.document ?? {};
524
+ const innerWidth = finiteInt(viewportRaw.innerWidth, DEFAULT_VIEWPORT.width);
525
+ const innerHeight = finiteInt(viewportRaw.innerHeight, DEFAULT_VIEWPORT.height);
526
+ const clientWidth = finiteInt(documentRaw.clientWidth, innerWidth) || innerWidth;
527
+ const scrollWidth = Math.max(
528
+ finiteInt(documentRaw.scrollWidth, 0),
529
+ finiteInt(raw?.bodyScrollWidth, 0),
530
+ clientWidth,
531
+ innerWidth,
532
+ );
533
+ const overflowPx = Math.max(0, scrollWidth - clientWidth);
534
+ const offendersRaw = Array.isArray(raw?.offenders) ? raw.offenders : [];
535
+ const overflowingElements = offendersRaw.slice(0, maxOffenders).map(shapeOffender);
536
+ const overflowingTotal = Math.max(
537
+ finiteInt(raw?.overflowingTotal, 0),
538
+ offendersRaw.length,
539
+ overflowingElements.length,
540
+ );
541
+ return {
542
+ url: boundedText(raw?.url, 500),
543
+ title: boundedText(raw?.title, 200),
544
+ readyState: boundedText(raw?.readyState, 20),
545
+ viewport: {
546
+ innerWidth,
547
+ innerHeight,
548
+ devicePixelRatio: Number.isFinite(Number(viewportRaw.devicePixelRatio)) ? Number(viewportRaw.devicePixelRatio) : 1,
549
+ },
550
+ document: { scrollWidth, clientWidth, scrollHeight: finiteInt(documentRaw.scrollHeight, 0) },
551
+ horizontalOverflow: { hasOverflow: overflowPx > 1, overflowPx, scrollWidth, clientWidth, innerWidth },
552
+ overflowingElements,
553
+ overflowingTotal,
554
+ overflowingTruncated: overflowingTotal > overflowingElements.length,
555
+ };
556
+ }
557
+
558
+ /**
559
+ * Bound the in-page markup/accessibility probe. The full report keeps rule,
560
+ * severity and selector, so the agent can fix the exact element.
561
+ */
562
+ export function shapeMarkupReport(raw = {}) {
563
+ const maxIssues = 80;
564
+ const issuesRaw = Array.isArray(raw?.issues) ? raw.issues : [];
565
+ const issues = issuesRaw.slice(0, maxIssues).map((issue) => ({
566
+ rule: boundedText(issue?.rule, 40) || "unknown",
567
+ severity: ["serious", "moderate", "warning"].includes(issue?.severity) ? issue.severity : "warning",
568
+ selector: boundedText(issue?.selector, 180),
569
+ message: boundedText(issue?.message, 220),
570
+ }));
571
+ const total = Math.max(finiteInt(raw?.total, 0), issuesRaw.length, issues.length);
572
+ return {
573
+ url: boundedText(raw?.url, 500),
574
+ title: boundedText(raw?.title, 200),
575
+ lang: boundedText(raw?.lang, 20),
576
+ counts: {
577
+ elements: finiteInt(raw?.counts?.elements, 0),
578
+ images: finiteInt(raw?.counts?.images, 0),
579
+ links: finiteInt(raw?.counts?.links, 0),
580
+ buttons: finiteInt(raw?.counts?.buttons, 0),
581
+ fields: finiteInt(raw?.counts?.fields, 0),
582
+ headings: finiteInt(raw?.counts?.headings, 0),
583
+ },
584
+ issues,
585
+ total,
586
+ truncated: total > issues.length,
587
+ };
588
+ }
589
+
590
+ function consoleArgText(arg) {
591
+ if (arg === null || arg === undefined) return "";
592
+ if (typeof arg === "string") return arg;
593
+ if ("value" in arg) {
594
+ const value = arg.value;
595
+ if (typeof value === "string") return value;
596
+ if (value === null) return "null";
597
+ if (typeof value === "object") {
598
+ try {
599
+ return JSON.stringify(value);
600
+ } catch {
601
+ return "";
602
+ }
603
+ }
604
+ return String(value);
605
+ }
606
+ if (typeof arg.description === "string") return arg.description;
607
+ if (typeof arg.unserializableValue === "string") return arg.unserializableValue;
608
+ return "";
609
+ }
610
+
611
+ function firstStackFrame(stackTrace) {
612
+ const callFrames = Array.isArray(stackTrace?.callFrames) ? stackTrace.callFrames : [];
613
+ return callFrames.length > 0 ? callFrames[0] : null;
614
+ }
615
+
616
+ /** Normalise one of the three CDP console/error events, or return null. */
617
+ export function consoleEventFromCdp(method, params = {}) {
618
+ if (method === "Runtime.consoleAPICalled") {
619
+ const type = String(params?.type ?? "");
620
+ if (type !== "error" && type !== "assert") return null;
621
+ const args = Array.isArray(params?.args) ? params.args : [];
622
+ const text = args.map(consoleArgText).filter((part) => part.trim().length > 0).join(" ");
623
+ const frame = firstStackFrame(params?.stackTrace);
624
+ return {
625
+ kind: "console",
626
+ level: type,
627
+ text: boundedText(text || `console.${type}`, 500),
628
+ url: frame?.url ?? "",
629
+ line: Number.isFinite(Number(frame?.lineNumber)) ? Number(frame.lineNumber) : null,
630
+ column: Number.isFinite(Number(frame?.columnNumber)) ? Number(frame.columnNumber) : null,
631
+ };
632
+ }
633
+ if (method === "Runtime.exceptionThrown") {
634
+ const details = params?.exceptionDetails ?? {};
635
+ const description = details?.exception?.description ?? details?.text ?? "uncaught exception";
636
+ const frame = firstStackFrame(details?.stackTrace);
637
+ const url = frame?.url ?? details?.url ?? "";
638
+ const line = Number.isFinite(Number(frame?.lineNumber ?? details?.lineNumber)) ? Number(frame?.lineNumber ?? details?.lineNumber) : null;
639
+ const column = Number.isFinite(Number(frame?.columnNumber ?? details?.columnNumber)) ? Number(frame?.columnNumber ?? details?.columnNumber) : null;
640
+ return {
641
+ kind: "exception",
642
+ level: "error",
643
+ text: boundedText(String(description).split("\n")[0], 500),
644
+ url: String(url ?? ""),
645
+ line,
646
+ column,
647
+ };
648
+ }
649
+ if (method === "Log.entryAdded") {
650
+ const entry = params?.entry ?? {};
651
+ if (String(entry?.level ?? "") !== "error") return null;
652
+ const suffix = typeof entry.url === "string" && entry.url.length > 0 ? ` (${entry.url})` : "";
653
+ return {
654
+ kind: "log",
655
+ level: "error",
656
+ text: boundedText(`${entry.text ?? ""}${suffix}`, 500),
657
+ url: String(entry.url ?? ""),
658
+ line: Number.isFinite(Number(entry.lineNumber)) ? Number(entry.lineNumber) : null,
659
+ column: null,
660
+ source: String(entry.source ?? ""),
661
+ };
662
+ }
663
+ return null;
664
+ }
665
+
666
+ /** De-duplicate console errors and cap the list (default 30). */
667
+ export function shapeConsoleEvents(events = [], options = {}) {
668
+ const max = options.max ?? LIMITS.maxConsoleErrors;
669
+ const seen = new Set();
670
+ const result = [];
671
+ const source = Array.isArray(events) ? events : [];
672
+ for (const event of source) {
673
+ if (!event || typeof event !== "object") continue;
674
+ const text = boundedText(event.text, 500);
675
+ if (text.length === 0) continue;
676
+ const key = `${event.kind ?? "console"}|${text}|${event.url ?? ""}|${event.line ?? ""}`;
677
+ if (seen.has(key)) continue;
678
+ seen.add(key);
679
+ const shaped = {
680
+ kind: boundedText(event.kind, 20) || "console",
681
+ level: boundedText(event.level, 20) || "error",
682
+ text,
683
+ url: boundedText(event.url, 500),
684
+ line: Number.isFinite(Number(event.line)) ? Number(event.line) : null,
685
+ column: Number.isFinite(Number(event.column)) ? Number(event.column) : null,
686
+ };
687
+ if (typeof event.source === "string" && event.source.length > 0) shaped.source = boundedText(event.source, 30);
688
+ result.push(shaped);
689
+ if (result.length >= max) break;
690
+ }
691
+ return result;
692
+ }
693
+
694
+ export function summarizeAudit(reports = []) {
695
+ const list = Array.isArray(reports) ? reports : [];
696
+ const measured = list.filter((report) => report && report.error === undefined && report.skipped !== true);
697
+ const failed = list.filter((report) => report && typeof report.error === "string");
698
+ return {
699
+ widthsRequested: list.length,
700
+ widthsChecked: measured.length,
701
+ widthsWithOverflow: measured.filter((report) => report.horizontalOverflow?.hasOverflow === true).length,
702
+ overflowingElements: measured.reduce((sum, report) => sum + (Number(report.overflowingTotal) || 0), 0),
703
+ consoleErrors: measured.reduce(
704
+ (sum, report) => sum + (Array.isArray(report.consoleErrors) ? report.consoleErrors.length : 0),
705
+ 0,
706
+ ),
707
+ markupIssues: measured.reduce((sum, report) => sum + (Number(report.markup?.total) || 0), 0),
708
+ widthsWithMarkupIssues: measured.filter((report) => Number(report.markup?.total) > 0).length,
709
+ failedWidths: failed.length,
710
+ screenshots: measured.filter((report) => typeof report.screenshot?.path === "string").length,
711
+ };
712
+ }
713
+
714
+ /* ------------------------------------------------------------------ */
715
+ /* text rendering */
716
+ /* ------------------------------------------------------------------ */
717
+
718
+ export function truncateText(text, max = LIMITS.maxTextLength) {
719
+ const value = String(text ?? "");
720
+ return value.length <= max ? value : `${value.slice(0, max)}\n…`;
721
+ }
722
+
723
+ export function formatBytes(bytes) {
724
+ const value = Number(bytes);
725
+ if (!Number.isFinite(value) || value < 0) return "0 B";
726
+ if (value < 1024) return `${Math.round(value)} B`;
727
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
728
+ return `${(value / 1024 / 1024).toFixed(2)} MB`;
729
+ }
730
+
731
+ function chromeLabel(chrome) {
732
+ if (!chrome || typeof chrome !== "object") return "Chrome";
733
+ const product = typeof chrome.product === "string" && chrome.product.length > 0 ? chrome.product : "Chrome";
734
+ const version = typeof chrome.version === "string" && chrome.version.length > 0 ? ` ${chrome.version}` : "";
735
+ return `${product}${version}`;
736
+ }
737
+
738
+ export function formatShotText(result = {}) {
739
+ const output = result.output ?? {};
740
+ const overflow = result.horizontalOverflow ?? {};
741
+ const errors = Array.isArray(result.consoleErrors) ? result.consoleErrors.length : 0;
742
+ const lines = [
743
+ `browser_qa shot: ${result.target?.finalUrl ?? result.target?.url ?? ""}`,
744
+ `PNG: ${output.path ?? "—"} · ${output.width ?? "?"}x${output.height ?? "?"} · ${formatBytes(output.bytes)}${output.fullPage ? " · full page" : ""}${output.clipped ? " · clipped" : ""}`,
745
+ `${chromeLabel(result.chrome)} · viewport ${result.viewport?.width ?? "?"}x${result.viewport?.height ?? "?"} · overflow ${overflow.hasOverflow === true ? `${overflow.overflowPx}px` : "нет"} · console errors: ${errors}`,
746
+ ];
747
+ if (result.warnings && result.warnings.length > 0) lines.push(`Предупреждения: ${result.warnings.join("; ")}`);
748
+ return truncateText(lines.join("\n"));
749
+ }
750
+
751
+ export function formatAuditText(report = {}) {
752
+ const reports = Array.isArray(report.reports) ? report.reports : [];
753
+ const lines = [`browser_qa audit: ${report.target?.finalUrl ?? report.target?.url ?? ""}`];
754
+ lines.push(`${chromeLabel(report.chrome)} · ширины: ${reports.map((item) => item.width).join(", ")}`);
755
+ for (const item of reports) {
756
+ if (typeof item.error === "string") {
757
+ lines.push(`- ${item.width}px: ошибка — ${item.error}`);
758
+ continue;
759
+ }
760
+ if (item.skipped === true) {
761
+ lines.push(`- ${item.width}px: пропущено — ${item.reason ?? "нет времени"}`);
762
+ continue;
763
+ }
764
+ const overflow = item.horizontalOverflow ?? {};
765
+ const markup = item.markup ?? {};
766
+ const bits = [
767
+ overflow.hasOverflow === true ? `overflow ${overflow.overflowPx}px` : "overflow нет",
768
+ `элементов: ${item.overflowingTotal ?? 0}`,
769
+ `console errors: ${Array.isArray(item.consoleErrors) ? item.consoleErrors.length : 0}`,
770
+ `markup: ${Number(markup.total) || 0}`,
771
+ ];
772
+ if (item.loadTimedOut === true) bits.push("load timeout");
773
+ if (typeof item.screenshot?.path === "string") bits.push(`PNG: ${item.screenshot.path}`);
774
+ lines.push(`- ${item.width}px: ${bits.join(", ")}`);
775
+ if (Array.isArray(markup.issues) && markup.issues.length > 0) {
776
+ const top = markup.issues.slice(0, 3).map((issue) => `${issue.rule}${issue.selector ? ` (${issue.selector})` : ""}`);
777
+ lines.push(` markup: ${top.join("; ")}${markup.total > top.length ? `; ещё ${markup.total - top.length}` : ""}`);
778
+ }
779
+ if (typeof item.markupError === "string") lines.push(` markup: ошибка проверки — ${item.markupError}`);
780
+ }
781
+ const summary = report.summary ?? {};
782
+ lines.push(
783
+ `Итог: overflow на ${summary.widthsWithOverflow ?? 0} из ${summary.widthsChecked ?? 0} ширин · ` +
784
+ `console errors: ${summary.consoleErrors ?? 0} · markup: ${summary.markupIssues ?? 0}` +
785
+ (report.outDir ? ` · PNG: ${report.outDir}` : ""),
786
+ );
787
+ if (report.warnings && report.warnings.length > 0) lines.push(`Предупреждения: ${report.warnings.join("; ")}`);
788
+ return truncateText(lines.join("\n"));
789
+ }
790
+
791
+ export function formatStatusText(status = {}) {
792
+ const chrome = status.chrome ?? {};
793
+ if (chrome.found !== true) {
794
+ const checked = Array.isArray(status.candidates) ? status.candidates : [];
795
+ const list = checked.length > 0 ? `\nПроверено: ${checked.join(", ")}` : "";
796
+ return truncateText(
797
+ `browser_qa status: Chrome/Edge не найден. Установите Chrome или задайте CHROME_PATH (полный путь к chrome.exe).${list}`,
798
+ );
799
+ }
800
+ const version = chrome.version ? `${chrome.product ?? "Chrome"} ${chrome.version}` : "версия не определена";
801
+ const lines = [`browser_qa status: Chrome найден`, `Путь: ${chrome.path}`, `Версия: ${version}`];
802
+ if (chrome.kind) lines.push(`Тип: ${chrome.kind}${chrome.source ? ` (${chrome.source})` : ""}`);
803
+ if (chrome.probeError) lines.push(`Ошибка проверки: ${chrome.probeError}`);
804
+ lines.push(
805
+ `Node ${status.runtime?.node ?? ""} · WebSocket: ${status.runtime?.webSocket === true ? "есть" : "нет"} · PNG по умолчанию: ${status.defaultOutDir ?? ""}`,
806
+ );
807
+ return truncateText(lines.join("\n"));
808
+ }
809
+
810
+ /* ------------------------------------------------------------------ */
811
+ /* misc */
812
+ /* ------------------------------------------------------------------ */
813
+
814
+ export function errorMessage(error) {
815
+ return error instanceof Error ? error.message : String(error);
816
+ }
817
+
818
+ export function abortError() {
819
+ const error = new Error("browser_qa: операция отменена");
820
+ error.name = "AbortError";
821
+ error.code = "ABORTED";
822
+ return error;
823
+ }
824
+
825
+ export function isAbortError(error) {
826
+ return error?.name === "AbortError" || error?.code === "ABORTED";
827
+ }