dsh-desktop-kit 2.2.1 → 2.7.3
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.
- package/CHANGELOG.md +86 -0
- package/README.md +114 -27
- package/client.js +3270 -32
- package/docs/AGENT-HARNESS.md +144 -0
- package/docs/ARCHITECTURE.md +38 -7
- package/docs/COMPATIBILITY.md +4 -2
- package/docs/DEVELOPMENT.md +56 -0
- package/docs/INSTALL.md +20 -11
- package/docs/LAPTOP-INSTALL.md +96 -0
- package/docs/ROADMAP-3.0.md +353 -0
- package/docs/ROADMAP.md +1 -1
- package/docs/SUBAGENTS.md +25 -0
- package/docs/TEST-0.2.2.md +5 -5
- package/docs/TEST-2.3.0.md +49 -0
- package/docs/TEST-2.7.2.md +46 -0
- package/docs/TEST-2.7.3.md +20 -0
- package/docs/TOKENS.md +2 -2
- package/docs/TROUBLESHOOTING.md +27 -0
- package/docs/UI-QA.md +28 -0
- package/docs/images/peak-toast-peak.png +0 -0
- package/docs/images/peak-toast-soon.png +0 -0
- package/docs/preview/peak-toast.html +155 -0
- package/index.js +18 -1
- package/package.json +26 -6
- package/plugins/dsh-agent-extensions/README.md +107 -0
- package/plugins/dsh-agent-extensions/client.js +157 -0
- package/plugins/dsh-agent-extensions/index.js +174 -0
- package/plugins/dsh-agent-extensions/lib/agents.js +76 -0
- package/plugins/dsh-agent-extensions/lib/api.js +53 -0
- package/plugins/dsh-agent-extensions/lib/checkpoints.js +103 -0
- package/plugins/dsh-agent-extensions/lib/commands.js +255 -0
- package/plugins/dsh-agent-extensions/lib/hooks.js +182 -0
- package/plugins/dsh-agent-extensions/lib/policy.js +84 -0
- package/plugins/dsh-agent-extensions/lib/project.js +144 -0
- package/plugins/dsh-agent-extensions/lib/scaffold.js +151 -0
- package/plugins/dsh-agent-extensions/package.json +20 -0
- package/plugins/dsh-agent-extensions/screenshots/agents-1440.png +0 -0
- package/plugins/dsh-agent-extensions/screenshots/agents-390.png +0 -0
- package/plugins/dsh-browser-qa/README.md +221 -0
- package/plugins/dsh-browser-qa/cordis.patch.yml +4 -0
- package/plugins/dsh-browser-qa/index.js +165 -0
- package/plugins/dsh-browser-qa/lib/cdp.js +224 -0
- package/plugins/dsh-browser-qa/lib/chrome.js +466 -0
- package/plugins/dsh-browser-qa/lib/pages.js +552 -0
- package/plugins/dsh-browser-qa/lib/qa.js +827 -0
- package/plugins/dsh-browser-qa/lib/ui-gate.js +256 -0
- package/plugins/dsh-browser-qa/package.json +31 -0
- package/plugins/dsh-checkpoints/README.md +22 -0
- package/plugins/dsh-checkpoints/cordis.patch.yml +3 -0
- package/plugins/dsh-checkpoints/index.js +80 -0
- package/plugins/dsh-checkpoints/lib/checkpoints.js +111 -0
- package/plugins/dsh-checkpoints/package.json +25 -0
- package/plugins/dsh-github-review/README.md +131 -0
- package/plugins/dsh-github-review/cordis.patch.yml +3 -0
- package/plugins/dsh-github-review/index.js +536 -0
- package/plugins/dsh-github-review/lib/gh.js +494 -0
- package/plugins/dsh-github-review/lib/normalize.js +662 -0
- package/plugins/dsh-github-review/package.json +28 -0
- package/plugins/dsh-guardrails/README.md +264 -0
- package/plugins/dsh-guardrails/cordis.patch.yml +5 -0
- package/plugins/dsh-guardrails/index.js +389 -0
- package/plugins/dsh-guardrails/lib/hooks.js +262 -0
- package/plugins/dsh-guardrails/lib/modes.js +77 -0
- package/plugins/dsh-guardrails/lib/policy.js +592 -0
- package/plugins/dsh-guardrails/lib/scan.js +451 -0
- package/plugins/dsh-guardrails/lib/secrets.js +274 -0
- package/plugins/dsh-guardrails/package.json +26 -0
- package/plugins/dsh-mcp-manager/README.md +78 -0
- package/plugins/dsh-mcp-manager/client.js +373 -0
- package/plugins/dsh-mcp-manager/index.js +159 -0
- package/plugins/dsh-mcp-manager/lib/api.js +250 -0
- package/plugins/dsh-mcp-manager/lib/client.js +219 -0
- package/plugins/dsh-mcp-manager/lib/config.js +217 -0
- package/plugins/dsh-mcp-manager/lib/manager.js +146 -0
- package/plugins/dsh-mcp-manager/package.json +18 -0
- package/plugins/dsh-mcp-manager/screenshots/modal-390.png +0 -0
- package/plugins/dsh-mcp-manager/screenshots/settings-1440.png +0 -0
- package/plugins/dsh-mcp-manager/screenshots/settings-390.png +0 -0
- package/plugins/dsh-memory-bridge/README.md +161 -0
- package/plugins/dsh-memory-bridge/client.js +2264 -0
- package/plugins/dsh-memory-bridge/cordis.patch.yml +5 -0
- package/plugins/dsh-memory-bridge/docs/images/conflicts.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/hero.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/settings-dark.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/settings-light.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/settings-mobile.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/setup.png +0 -0
- package/plugins/dsh-memory-bridge/icon.svg +13 -0
- package/plugins/dsh-memory-bridge/index.js +378 -0
- package/plugins/dsh-memory-bridge/lib/bridge.js +636 -0
- package/plugins/dsh-memory-bridge/lib/github.js +473 -0
- package/plugins/dsh-memory-bridge/lib/merge.js +205 -0
- package/plugins/dsh-memory-bridge/lib/portable.js +314 -0
- package/plugins/dsh-memory-bridge/lib/state.js +565 -0
- package/plugins/dsh-memory-bridge/lib/sync.js +984 -0
- package/plugins/dsh-memory-bridge/locale/en.json +6 -0
- package/plugins/dsh-memory-bridge/locale/ru.json +6 -0
- package/plugins/dsh-memory-bridge/package.json +67 -0
- package/plugins/dsh-peak-guard/README.md +16 -3
- package/plugins/dsh-peak-guard/client.js +223 -8
- package/plugins/dsh-peak-guard/package.json +1 -1
- package/plugins/dsh-plan-mode/README.md +24 -0
- package/plugins/dsh-plan-mode/cordis.patch.yml +3 -0
- package/plugins/dsh-plan-mode/index.js +103 -0
- package/plugins/dsh-plan-mode/lib/plan.js +89 -0
- package/plugins/dsh-plan-mode/package.json +18 -0
- package/plugins/dsh-profile-transfer/client.js +277 -17
- package/plugins/dsh-profile-transfer/index.js +10 -0
- package/plugins/dsh-profile-transfer/package.json +3 -1
- package/plugins/dsh-profile-transfer/transfer.js +101 -2
- package/plugins/dsh-project-memory/index.js +92 -2
- package/plugins/dsh-project-memory/package.json +1 -1
- package/plugins/dsh-repo-map/README.md +26 -0
- package/plugins/dsh-repo-map/cordis.patch.yml +3 -0
- package/plugins/dsh-repo-map/index.js +99 -0
- package/plugins/dsh-repo-map/lib/repo-map.js +170 -0
- package/plugins/dsh-repo-map/package.json +25 -0
- package/plugins/dsh-skill-autopilot/index.js +135 -11
- package/skills/delivery-gate/SKILL.md +31 -0
- package/skills/git-and-ci/SKILL.md +5 -1
- package/skills/manifest.json +31 -6
- package/skills/requirements-and-planning/SKILL.md +3 -0
- package/skills/research-first/SKILL.md +35 -0
- package/skills/research-first/references/query-patterns.md +20 -0
- package/skills/subagent-playbook/SKILL.md +36 -0
- package/skills/subagent-playbook/references/prompts.md +39 -0
- package/skills/ui-craft/SKILL.md +43 -0
- package/skills/visual-qa/SKILL.md +29 -0
- package/skills/visual-qa/references/commands.md +32 -0
- package/skills-baseline.json +219 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page orchestration for browser_qa: one headless Chrome per tool call.
|
|
3
|
+
*
|
|
4
|
+
* `shot` opens one viewport and writes one PNG; `audit` reloads the page at
|
|
5
|
+
* every requested width and measures horizontal overflow plus console errors,
|
|
6
|
+
* optionally saving one PNG per width. All Chrome state is torn down in
|
|
7
|
+
* `finally`, including on abort/error.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import fsp from "node:fs/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
import { ChromeSession, detectChrome, probeChrome } from "./chrome.js";
|
|
14
|
+
import {
|
|
15
|
+
LIMITS,
|
|
16
|
+
MAX_SCREENSHOT_HEIGHT,
|
|
17
|
+
MARKUP_EXPRESSION,
|
|
18
|
+
OVERFLOW_EXPRESSION,
|
|
19
|
+
PAGE_INFO_EXPRESSION,
|
|
20
|
+
SETTLE_EXPRESSION,
|
|
21
|
+
abortError,
|
|
22
|
+
consoleEventFromCdp,
|
|
23
|
+
errorMessage,
|
|
24
|
+
formatAuditText,
|
|
25
|
+
formatShotText,
|
|
26
|
+
formatStatusText,
|
|
27
|
+
isAbortError,
|
|
28
|
+
normalizeTarget,
|
|
29
|
+
normalizeViewport,
|
|
30
|
+
parseTimeout,
|
|
31
|
+
parseWidths,
|
|
32
|
+
pngSize,
|
|
33
|
+
resolveOutDir,
|
|
34
|
+
screenshotFileName,
|
|
35
|
+
shapeConsoleEvents,
|
|
36
|
+
shapeMarkupReport,
|
|
37
|
+
shapeOverflowReport,
|
|
38
|
+
summarizeAudit,
|
|
39
|
+
} from "./qa.js";
|
|
40
|
+
|
|
41
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
42
|
+
const MAX_RAW_CONSOLE_EVENTS = 200;
|
|
43
|
+
const AUDIT_BUDGET_MS = 150_000;
|
|
44
|
+
|
|
45
|
+
class ConsoleCollector {
|
|
46
|
+
constructor(cdp) {
|
|
47
|
+
this.events = [];
|
|
48
|
+
this.dropped = 0;
|
|
49
|
+
this.unsubscribe = [
|
|
50
|
+
cdp.on("Runtime.consoleAPICalled", (params) => this.push("Runtime.consoleAPICalled", params)),
|
|
51
|
+
cdp.on("Runtime.exceptionThrown", (params) => this.push("Runtime.exceptionThrown", params)),
|
|
52
|
+
cdp.on("Log.entryAdded", (params) => this.push("Log.entryAdded", params)),
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
push(method, params) {
|
|
57
|
+
if (this.events.length >= MAX_RAW_CONSOLE_EVENTS) {
|
|
58
|
+
this.dropped += 1;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const event = consoleEventFromCdp(method, params);
|
|
62
|
+
if (event !== null) this.events.push(event);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
reset() {
|
|
66
|
+
this.events = [];
|
|
67
|
+
this.dropped = 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
list() {
|
|
71
|
+
return shapeConsoleEvents(this.events);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
dispose() {
|
|
75
|
+
for (const off of this.unsubscribe) {
|
|
76
|
+
try {
|
|
77
|
+
off();
|
|
78
|
+
} catch {
|
|
79
|
+
/* ignore */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
this.unsubscribe = [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
class PageSession {
|
|
87
|
+
constructor(cdp) {
|
|
88
|
+
this.cdp = cdp;
|
|
89
|
+
this.console = new ConsoleCollector(cdp);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async init() {
|
|
93
|
+
await this.cdp.send("Page.enable", {}, 10_000);
|
|
94
|
+
await this.cdp.send("Runtime.enable", {}, 10_000);
|
|
95
|
+
try {
|
|
96
|
+
await this.cdp.send("Log.enable", {}, 10_000);
|
|
97
|
+
} catch {
|
|
98
|
+
/* Log domain is optional */
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
await this.cdp.send("Page.setLifecycleEventsEnabled", { enabled: true }, 5_000);
|
|
102
|
+
} catch {
|
|
103
|
+
/* optional */
|
|
104
|
+
}
|
|
105
|
+
return this;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async evaluate(expression, options = {}) {
|
|
109
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
110
|
+
const awaitPromise = options.awaitPromise !== false;
|
|
111
|
+
const result = await this.cdp.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise }, timeoutMs);
|
|
112
|
+
if (result?.exceptionDetails) {
|
|
113
|
+
const details = result.exceptionDetails;
|
|
114
|
+
throw new Error(details.exception?.description ?? details.text ?? "page evaluation failed");
|
|
115
|
+
}
|
|
116
|
+
return result?.result?.value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async setViewport(width, height) {
|
|
120
|
+
await this.cdp.send(
|
|
121
|
+
"Emulation.setDeviceMetricsOverride",
|
|
122
|
+
{ width, height, deviceScaleFactor: 1, mobile: false, screenWidth: width, screenHeight: height },
|
|
123
|
+
10_000,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async navigate(targetUrl, options = {}) {
|
|
128
|
+
const timeoutMs = options.timeoutMs ?? LIMITS.defaultTimeoutMs;
|
|
129
|
+
const signal = options.signal;
|
|
130
|
+
if (signal?.aborted) throw abortError();
|
|
131
|
+
this.console.reset();
|
|
132
|
+
const startedAt = Date.now();
|
|
133
|
+
const deadline = startedAt + timeoutMs;
|
|
134
|
+
const loaded = this.cdp
|
|
135
|
+
.once("Page.loadEventFired", timeoutMs)
|
|
136
|
+
.then(() => true)
|
|
137
|
+
.catch(() => false);
|
|
138
|
+
let navigation;
|
|
139
|
+
try {
|
|
140
|
+
navigation = await this.cdp.send("Page.navigate", { url: targetUrl }, Math.min(15_000, timeoutMs));
|
|
141
|
+
} catch (error) {
|
|
142
|
+
await loaded;
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
if (typeof navigation?.errorText === "string" && navigation.errorText.length > 0) {
|
|
146
|
+
throw new Error(`navigation failed: ${navigation.errorText}`);
|
|
147
|
+
}
|
|
148
|
+
const loadEventFired = await loaded;
|
|
149
|
+
const readyDeadline = Math.min(deadline, Date.now() + 5_000);
|
|
150
|
+
let readyState = "";
|
|
151
|
+
while (Date.now() < readyDeadline) {
|
|
152
|
+
if (signal?.aborted) throw abortError();
|
|
153
|
+
try {
|
|
154
|
+
readyState = String((await this.evaluate("document.readyState", { timeoutMs: 2_000, awaitPromise: false })) ?? "");
|
|
155
|
+
} catch {
|
|
156
|
+
if (signal?.aborted) throw abortError();
|
|
157
|
+
/* page may be between documents */
|
|
158
|
+
}
|
|
159
|
+
if (readyState === "complete") break;
|
|
160
|
+
await sleep(100);
|
|
161
|
+
}
|
|
162
|
+
const settleBudget = Math.max(250, Math.min(2_000, deadline - Date.now()));
|
|
163
|
+
try {
|
|
164
|
+
await this.evaluate(SETTLE_EXPRESSION, { timeoutMs: settleBudget, awaitPromise: true });
|
|
165
|
+
} catch {
|
|
166
|
+
if (signal?.aborted) throw abortError();
|
|
167
|
+
/* fonts/settle is best effort */
|
|
168
|
+
}
|
|
169
|
+
const info = await this.pageInfo();
|
|
170
|
+
return {
|
|
171
|
+
finalUrl: info.url ?? targetUrl,
|
|
172
|
+
title: info.title ?? "",
|
|
173
|
+
readyState: readyState || info.readyState || "",
|
|
174
|
+
loadMs: Date.now() - startedAt,
|
|
175
|
+
loadTimedOut: loadEventFired !== true,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async pageInfo() {
|
|
180
|
+
try {
|
|
181
|
+
const raw = await this.evaluate(PAGE_INFO_EXPRESSION, { timeoutMs: 4_000, awaitPromise: false });
|
|
182
|
+
if (typeof raw === "string") {
|
|
183
|
+
const parsed = JSON.parse(raw);
|
|
184
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
/* page may have crashed or be navigating */
|
|
188
|
+
}
|
|
189
|
+
return {};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async overflowReport(options = {}) {
|
|
193
|
+
const raw = await this.evaluate(OVERFLOW_EXPRESSION, {
|
|
194
|
+
timeoutMs: options.timeoutMs ?? 10_000,
|
|
195
|
+
awaitPromise: false,
|
|
196
|
+
});
|
|
197
|
+
let parsed = {};
|
|
198
|
+
if (typeof raw === "string") {
|
|
199
|
+
try {
|
|
200
|
+
parsed = JSON.parse(raw);
|
|
201
|
+
} catch {
|
|
202
|
+
parsed = {};
|
|
203
|
+
}
|
|
204
|
+
} else if (raw && typeof raw === "object") {
|
|
205
|
+
parsed = raw;
|
|
206
|
+
}
|
|
207
|
+
return shapeOverflowReport(parsed);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async markupReport(options = {}) {
|
|
211
|
+
const raw = await this.evaluate(MARKUP_EXPRESSION, {
|
|
212
|
+
timeoutMs: options.timeoutMs ?? 10_000,
|
|
213
|
+
awaitPromise: false,
|
|
214
|
+
});
|
|
215
|
+
let parsed = {};
|
|
216
|
+
if (typeof raw === "string") {
|
|
217
|
+
try {
|
|
218
|
+
parsed = JSON.parse(raw);
|
|
219
|
+
} catch {
|
|
220
|
+
parsed = {};
|
|
221
|
+
}
|
|
222
|
+
} else if (raw && typeof raw === "object") {
|
|
223
|
+
parsed = raw;
|
|
224
|
+
}
|
|
225
|
+
return shapeMarkupReport(parsed);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async screenshot(options = {}) {
|
|
229
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
230
|
+
const fullPage = options.fullPage === true;
|
|
231
|
+
if (!fullPage) {
|
|
232
|
+
const shot = await this.cdp.send(
|
|
233
|
+
"Page.captureScreenshot",
|
|
234
|
+
{ format: "png", fromSurface: true, captureBeyondViewport: false },
|
|
235
|
+
timeoutMs,
|
|
236
|
+
);
|
|
237
|
+
if (typeof shot?.data !== "string" || shot.data.length === 0) throw new Error("Chrome returned an empty screenshot");
|
|
238
|
+
return { base64: shot.data, fullPage: false, clipped: false };
|
|
239
|
+
}
|
|
240
|
+
let content = null;
|
|
241
|
+
try {
|
|
242
|
+
const metrics = await this.cdp.send("Page.getLayoutMetrics", {}, 10_000);
|
|
243
|
+
content = metrics?.cssContentSize ?? metrics?.contentSize ?? null;
|
|
244
|
+
} catch {
|
|
245
|
+
content = null;
|
|
246
|
+
}
|
|
247
|
+
const contentWidth = Number(content?.width);
|
|
248
|
+
const contentHeight = Number(content?.height);
|
|
249
|
+
if (!Number.isFinite(contentWidth) || !Number.isFinite(contentHeight) || contentWidth <= 0 || contentHeight <= 0) {
|
|
250
|
+
const shot = await this.cdp.send(
|
|
251
|
+
"Page.captureScreenshot",
|
|
252
|
+
{ format: "png", fromSurface: true, captureBeyondViewport: true },
|
|
253
|
+
timeoutMs,
|
|
254
|
+
);
|
|
255
|
+
if (typeof shot?.data !== "string" || shot.data.length === 0) throw new Error("Chrome returned an empty screenshot");
|
|
256
|
+
return { base64: shot.data, fullPage: false, clipped: true, fallback: "layout-metrics-unavailable" };
|
|
257
|
+
}
|
|
258
|
+
const clipWidth = Math.max(1, Math.min(MAX_SCREENSHOT_HEIGHT, Math.ceil(contentWidth)));
|
|
259
|
+
const clipHeight = Math.max(1, Math.min(MAX_SCREENSHOT_HEIGHT, Math.ceil(contentHeight)));
|
|
260
|
+
const shot = await this.cdp.send(
|
|
261
|
+
"Page.captureScreenshot",
|
|
262
|
+
{
|
|
263
|
+
format: "png",
|
|
264
|
+
fromSurface: true,
|
|
265
|
+
captureBeyondViewport: true,
|
|
266
|
+
clip: { x: 0, y: 0, width: clipWidth, height: clipHeight, scale: 1 },
|
|
267
|
+
},
|
|
268
|
+
timeoutMs,
|
|
269
|
+
);
|
|
270
|
+
if (typeof shot?.data !== "string" || shot.data.length === 0) throw new Error("Chrome returned an empty screenshot");
|
|
271
|
+
return {
|
|
272
|
+
base64: shot.data,
|
|
273
|
+
fullPage: true,
|
|
274
|
+
clipped: contentWidth > clipWidth || contentHeight > clipHeight,
|
|
275
|
+
contentWidth: Math.ceil(contentWidth),
|
|
276
|
+
contentHeight: Math.ceil(contentHeight),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
dispose() {
|
|
281
|
+
this.console.dispose();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function writeScreenshot(file, base64) {
|
|
286
|
+
await fsp.mkdir(path.dirname(file), { recursive: true });
|
|
287
|
+
const buffer = Buffer.from(base64, "base64");
|
|
288
|
+
await fsp.writeFile(file, buffer);
|
|
289
|
+
const dimensions = pngSize(buffer);
|
|
290
|
+
const stats = await fsp.stat(file);
|
|
291
|
+
return { path: file, format: "png", width: dimensions.width, height: dimensions.height, bytes: stats.size };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function signalCleanupAttach(signal, callback) {
|
|
295
|
+
if (!signal || typeof signal.addEventListener !== "function") return () => {};
|
|
296
|
+
signal.addEventListener("abort", callback, { once: true });
|
|
297
|
+
return () => {
|
|
298
|
+
try {
|
|
299
|
+
signal.removeEventListener("abort", callback);
|
|
300
|
+
} catch {
|
|
301
|
+
/* ignore */
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Run `fn` with a booted Chrome session. Always kills Chrome and removes the
|
|
308
|
+
* temp profile afterwards; cleanup warnings are appended to the result.
|
|
309
|
+
*/
|
|
310
|
+
async function withSession(options, fn) {
|
|
311
|
+
if (options.signal?.aborted) throw abortError();
|
|
312
|
+
const detected = options.executablePath
|
|
313
|
+
? { found: true, path: options.executablePath, kind: options.kind ?? "custom", source: "explicit" }
|
|
314
|
+
: detectChrome();
|
|
315
|
+
if (detected.found !== true) {
|
|
316
|
+
const error = new Error(
|
|
317
|
+
"Chrome/Edge not found. Install Google Chrome or set CHROME_PATH to a Chromium-based browser executable.",
|
|
318
|
+
);
|
|
319
|
+
error.code = "CHROME_NOT_FOUND";
|
|
320
|
+
error.candidates = detected.candidates ?? [];
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
const session = await ChromeSession.start({
|
|
324
|
+
executablePath: detected.path,
|
|
325
|
+
kind: detected.kind,
|
|
326
|
+
timeoutMs: options.launchTimeoutMs ?? 15_000,
|
|
327
|
+
});
|
|
328
|
+
const detach = signalCleanupAttach(options.signal, () => {
|
|
329
|
+
session.close().catch(() => {});
|
|
330
|
+
});
|
|
331
|
+
let result;
|
|
332
|
+
try {
|
|
333
|
+
const page = await new PageSession(session.cdp).init();
|
|
334
|
+
try {
|
|
335
|
+
result = await fn({ session, page });
|
|
336
|
+
} catch (error) {
|
|
337
|
+
if (options.signal?.aborted) throw abortError();
|
|
338
|
+
throw error;
|
|
339
|
+
} finally {
|
|
340
|
+
page.dispose();
|
|
341
|
+
}
|
|
342
|
+
} finally {
|
|
343
|
+
detach();
|
|
344
|
+
await session.close();
|
|
345
|
+
if (result !== null && typeof result === "object" && typeof session.cleanupWarning === "string") {
|
|
346
|
+
result.warnings = [...(Array.isArray(result.warnings) ? result.warnings : []), session.cleanupWarning];
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return result;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function normalizeOptions(options = {}) {
|
|
353
|
+
const cwd = typeof options.cwd === "string" && options.cwd.length > 0 ? options.cwd : process.cwd();
|
|
354
|
+
const target = normalizeTarget(options.target ?? options.url, { cwd });
|
|
355
|
+
if (target.kind === "file" && options.validateFile !== false) {
|
|
356
|
+
let stats;
|
|
357
|
+
try {
|
|
358
|
+
stats = fs.statSync(target.file);
|
|
359
|
+
} catch {
|
|
360
|
+
throw new Error(`local file not found: ${target.file}`);
|
|
361
|
+
}
|
|
362
|
+
if (!stats.isFile()) throw new Error(`not a file: ${target.file}`);
|
|
363
|
+
}
|
|
364
|
+
return { cwd, target };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Single-viewport screenshot. */
|
|
368
|
+
export async function captureShot(options = {}) {
|
|
369
|
+
const { cwd, target } = normalizeOptions(options);
|
|
370
|
+
const viewport = normalizeViewport(options);
|
|
371
|
+
const timeoutMs = parseTimeout(options.timeoutMs);
|
|
372
|
+
const fullPage = options.fullPage === true;
|
|
373
|
+
const outDir = resolveOutDir(options.outDir, cwd);
|
|
374
|
+
await fsp.mkdir(outDir, { recursive: true });
|
|
375
|
+
return withSession(options, async ({ session, page }) => {
|
|
376
|
+
await page.setViewport(viewport.width, viewport.height);
|
|
377
|
+
const navigation = await page.navigate(target.url, { timeoutMs, signal: options.signal });
|
|
378
|
+
const overflow = await page.overflowReport();
|
|
379
|
+
const consoleErrors = page.console.list();
|
|
380
|
+
const screenshot = await page.screenshot({ fullPage, timeoutMs: Math.min(30_000, Math.max(5_000, timeoutMs)) });
|
|
381
|
+
const file = path.join(
|
|
382
|
+
outDir,
|
|
383
|
+
screenshotFileName({ target: target.url, width: viewport.width, height: viewport.height, fullPage }),
|
|
384
|
+
);
|
|
385
|
+
const output = await writeScreenshot(file, screenshot.base64);
|
|
386
|
+
output.fullPage = screenshot.fullPage === true;
|
|
387
|
+
output.clipped = screenshot.clipped === true;
|
|
388
|
+
const result = {
|
|
389
|
+
ok: true,
|
|
390
|
+
action: "shot",
|
|
391
|
+
target: {
|
|
392
|
+
input: target.input,
|
|
393
|
+
url: target.url,
|
|
394
|
+
finalUrl: navigation.finalUrl,
|
|
395
|
+
kind: target.kind,
|
|
396
|
+
},
|
|
397
|
+
chrome: session.chromeInfo(),
|
|
398
|
+
viewport: { width: viewport.width, height: viewport.height },
|
|
399
|
+
output,
|
|
400
|
+
horizontalOverflow: overflow.horizontalOverflow,
|
|
401
|
+
consoleErrors,
|
|
402
|
+
page: { title: navigation.title, readyState: navigation.readyState },
|
|
403
|
+
loadMs: navigation.loadMs,
|
|
404
|
+
loadTimedOut: navigation.loadTimedOut,
|
|
405
|
+
};
|
|
406
|
+
result.text = formatShotText(result);
|
|
407
|
+
return result;
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Responsive audit: navigate once per width, measure `documentElement`
|
|
413
|
+
* scrollWidth/clientWidth, list overflowing elements and console errors.
|
|
414
|
+
* PNGs are written only when `screenshots` is true (outDir then defaults to
|
|
415
|
+
* `<cwd>/.dsh/browser-qa`).
|
|
416
|
+
*/
|
|
417
|
+
export async function auditPages(options = {}) {
|
|
418
|
+
const { cwd, target } = normalizeOptions(options);
|
|
419
|
+
const widths = parseWidths(options.widths);
|
|
420
|
+
const { height } = normalizeViewport(options);
|
|
421
|
+
const timeoutMs = parseTimeout(options.timeoutMs);
|
|
422
|
+
const screenshots = options.screenshots === true || (typeof options.outDir === "string" && options.outDir.trim().length > 0);
|
|
423
|
+
const outDir = screenshots ? resolveOutDir(options.outDir, cwd) : null;
|
|
424
|
+
if (outDir !== null) await fsp.mkdir(outDir, { recursive: true });
|
|
425
|
+
const budgetMs = Math.min(AUDIT_BUDGET_MS, timeoutMs * widths.length + 25_000);
|
|
426
|
+
return withSession(options, async ({ session, page }) => {
|
|
427
|
+
const deadline = Date.now() + budgetMs;
|
|
428
|
+
const reports = [];
|
|
429
|
+
for (const width of widths) {
|
|
430
|
+
if (options.signal?.aborted) throw abortError();
|
|
431
|
+
if (Date.now() >= deadline) {
|
|
432
|
+
reports.push({ width, height, skipped: true, reason: "time budget exhausted" });
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
page.console.reset();
|
|
436
|
+
try {
|
|
437
|
+
const perWidthTimeout = Math.max(3_000, Math.min(timeoutMs, deadline - Date.now()));
|
|
438
|
+
await page.setViewport(width, height);
|
|
439
|
+
const navigation = await page.navigate(target.url, { timeoutMs: perWidthTimeout, signal: options.signal });
|
|
440
|
+
const overflow = await page.overflowReport({ timeoutMs: Math.min(10_000, perWidthTimeout) });
|
|
441
|
+
let markup = null;
|
|
442
|
+
let markupError = null;
|
|
443
|
+
try {
|
|
444
|
+
markup = await page.markupReport({ timeoutMs: Math.min(6_000, perWidthTimeout) });
|
|
445
|
+
} catch (error) {
|
|
446
|
+
markupError = errorMessage(error);
|
|
447
|
+
}
|
|
448
|
+
const consoleErrors = page.console.list();
|
|
449
|
+
let screenshot = null;
|
|
450
|
+
if (outDir !== null) {
|
|
451
|
+
const shot = await page.screenshot({
|
|
452
|
+
fullPage: options.fullPage === true,
|
|
453
|
+
timeoutMs: Math.min(30_000, perWidthTimeout),
|
|
454
|
+
});
|
|
455
|
+
const file = path.join(
|
|
456
|
+
outDir,
|
|
457
|
+
screenshotFileName({ target: target.url, width, height, fullPage: options.fullPage === true }),
|
|
458
|
+
);
|
|
459
|
+
screenshot = await writeScreenshot(file, shot.base64);
|
|
460
|
+
screenshot.fullPage = shot.fullPage === true;
|
|
461
|
+
screenshot.clipped = shot.clipped === true;
|
|
462
|
+
}
|
|
463
|
+
reports.push({
|
|
464
|
+
width,
|
|
465
|
+
height,
|
|
466
|
+
viewport: overflow.viewport,
|
|
467
|
+
horizontalOverflow: overflow.horizontalOverflow,
|
|
468
|
+
overflowingElements: overflow.overflowingElements,
|
|
469
|
+
overflowingTotal: overflow.overflowingTotal,
|
|
470
|
+
overflowingTruncated: overflow.overflowingTruncated,
|
|
471
|
+
markup,
|
|
472
|
+
...(markupError ? { markupError } : {}),
|
|
473
|
+
consoleErrors,
|
|
474
|
+
page: { finalUrl: navigation.finalUrl, title: navigation.title, readyState: navigation.readyState },
|
|
475
|
+
loadMs: navigation.loadMs,
|
|
476
|
+
loadTimedOut: navigation.loadTimedOut,
|
|
477
|
+
screenshot,
|
|
478
|
+
});
|
|
479
|
+
} catch (error) {
|
|
480
|
+
if (isAbortError(error) || options.signal?.aborted) throw abortError();
|
|
481
|
+
reports.push({ width, height, error: errorMessage(error) });
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
const result = {
|
|
485
|
+
ok: true,
|
|
486
|
+
action: "audit",
|
|
487
|
+
target: {
|
|
488
|
+
input: target.input,
|
|
489
|
+
url: target.url,
|
|
490
|
+
finalUrl: reports.find((report) => typeof report.page?.finalUrl === "string")?.page.finalUrl ?? target.url,
|
|
491
|
+
kind: target.kind,
|
|
492
|
+
},
|
|
493
|
+
chrome: session.chromeInfo(),
|
|
494
|
+
outDir,
|
|
495
|
+
widths,
|
|
496
|
+
height,
|
|
497
|
+
reports,
|
|
498
|
+
summary: summarizeAudit(reports),
|
|
499
|
+
};
|
|
500
|
+
result.text = formatAuditText(result);
|
|
501
|
+
return result;
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Detect Chrome and read its version (launches a short-lived probe). */
|
|
506
|
+
export async function statusReport(options = {}) {
|
|
507
|
+
const cwd = typeof options.cwd === "string" && options.cwd.length > 0 ? options.cwd : process.cwd();
|
|
508
|
+
const detected = detectChrome();
|
|
509
|
+
const status = {
|
|
510
|
+
ok: false,
|
|
511
|
+
action: "status",
|
|
512
|
+
chrome: {
|
|
513
|
+
found: detected.found === true,
|
|
514
|
+
path: detected.path,
|
|
515
|
+
kind: detected.kind,
|
|
516
|
+
source: detected.source,
|
|
517
|
+
product: null,
|
|
518
|
+
version: null,
|
|
519
|
+
protocolVersion: null,
|
|
520
|
+
probeError: null,
|
|
521
|
+
},
|
|
522
|
+
runtime: {
|
|
523
|
+
node: process.version,
|
|
524
|
+
platform: process.platform,
|
|
525
|
+
webSocket: typeof WebSocket === "function",
|
|
526
|
+
},
|
|
527
|
+
candidates: Array.isArray(detected.candidates) ? detected.candidates : [],
|
|
528
|
+
defaultOutDir: resolveOutDir(undefined, cwd),
|
|
529
|
+
};
|
|
530
|
+
if (detected.found !== true) {
|
|
531
|
+
status.chrome.probeError = "Chrome/Edge executable not found";
|
|
532
|
+
status.text = formatStatusText(status);
|
|
533
|
+
return status;
|
|
534
|
+
}
|
|
535
|
+
if (typeof WebSocket !== "function") {
|
|
536
|
+
status.chrome.probeError = "This Node.js runtime has no built-in WebSocket (Node 22+ is required)";
|
|
537
|
+
status.text = formatStatusText(status);
|
|
538
|
+
return status;
|
|
539
|
+
}
|
|
540
|
+
try {
|
|
541
|
+
const probe = await probeChrome(detected.path, { kind: detected.kind, timeoutMs: options.timeoutMs ?? 8_000 });
|
|
542
|
+
status.chrome.product = probe.product ?? null;
|
|
543
|
+
status.chrome.version = probe.version ?? null;
|
|
544
|
+
status.chrome.protocolVersion = probe.protocolVersion ?? null;
|
|
545
|
+
status.chrome.cached = probe.cached === true;
|
|
546
|
+
status.ok = true;
|
|
547
|
+
} catch (error) {
|
|
548
|
+
status.chrome.probeError = errorMessage(error);
|
|
549
|
+
}
|
|
550
|
+
status.text = formatStatusText(status);
|
|
551
|
+
return status;
|
|
552
|
+
}
|