sentinelayer-cli 0.8.11 → 0.9.0
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/package.json +10 -5
- package/src/agents/devtestbot/config/definition.js +100 -0
- package/src/agents/devtestbot/config/system-prompt.js +92 -0
- package/src/agents/devtestbot/index.js +9 -0
- package/src/agents/devtestbot/runner.js +769 -0
- package/src/agents/devtestbot/tool.js +707 -0
- package/src/agents/jules/stream.js +2 -12
- package/src/audit/orchestrator.js +471 -114
- package/src/audit/persona-loop.js +1342 -0
- package/src/audit/registry.js +58 -2
- package/src/commands/audit.js +42 -1
- package/src/commands/legacy-args.js +32 -1
- package/src/commands/omargate.js +4 -0
- package/src/commands/session.js +417 -89
- package/src/commands/swarm.js +11 -2
- package/src/cost/history.js +41 -21
- package/src/events/schema.js +27 -1
- package/src/guide/generator.js +14 -0
- package/src/legacy-cli.js +110 -18
- package/src/prompt/generator.js +4 -16
- package/src/review/ai-review.js +95 -6
- package/src/review/dd-report-email-client.js +148 -0
- package/src/review/investor-dd-devtestbot.js +599 -0
- package/src/review/investor-dd-orchestrator.js +135 -3
- package/src/review/omargate-cache.js +285 -0
- package/src/review/omargate-orchestrator.js +605 -4
- package/src/review/persona-prompts.js +34 -1
- package/src/review/report.js +189 -4
- package/src/session/coordination-guidance.js +48 -0
- package/src/session/daemon.js +3 -2
- package/src/session/listener.js +236 -0
- package/src/session/senti-naming.js +36 -0
- package/src/session/setup-guides.js +3 -15
- package/src/session/store.js +54 -5
- package/src/session/sync.js +23 -0
- package/src/spec/generator.js +8 -10
- package/src/swarm/registry.js +20 -0
- package/src/swarm/runtime.js +139 -1
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import fsp from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import AxeBuilder from "@axe-core/playwright";
|
|
10
|
+
import { chromium } from "playwright";
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
|
|
14
|
+
const DEFAULT_VIEWPORT = Object.freeze({ width: 1280, height: 720 });
|
|
15
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
16
|
+
const DEFAULT_LIGHTHOUSE_TIMEOUT_MS = 90000;
|
|
17
|
+
const SENSITIVE_KEY_PATTERN = /(?:authorization|cookie|set-cookie|token|secret|password|passwd|api[-_]?key|session|credential)/i;
|
|
18
|
+
const TOKEN_VALUE_PATTERN = /\b(?:bearer|token|password|secret|api[_-]?key|session)\s*[:=]\s*["']?[^"'\s&]+/gi;
|
|
19
|
+
const LONG_SECRET_PATTERN = /\b[A-Za-z0-9_-]{24,}\b/g;
|
|
20
|
+
|
|
21
|
+
export class DevTestBotRunnerError extends Error {
|
|
22
|
+
constructor(message, options = {}) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "DevTestBotRunnerError";
|
|
25
|
+
this.code = options.code || "DEVTESTBOT_RUNNER_ERROR";
|
|
26
|
+
this.cause = options.cause;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function launch({
|
|
31
|
+
baseUrl,
|
|
32
|
+
identityCreds = null,
|
|
33
|
+
outputDir,
|
|
34
|
+
headless = true,
|
|
35
|
+
viewport = DEFAULT_VIEWPORT,
|
|
36
|
+
recordVideo = true,
|
|
37
|
+
runLighthouse = true,
|
|
38
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
39
|
+
lighthouseTimeoutMs = DEFAULT_LIGHTHOUSE_TIMEOUT_MS,
|
|
40
|
+
browserOptions = {},
|
|
41
|
+
} = {}) {
|
|
42
|
+
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
|
43
|
+
const runId = "devtestbot-" + new Date().toISOString().replace(/[:.]/g, "-") + "-" + randomUUID().slice(0, 8);
|
|
44
|
+
const artifactRoot = path.resolve(outputDir || path.join(process.cwd(), ".sentinelayer", "runs", runId, "devtestbot"));
|
|
45
|
+
const videoDir = path.join(artifactRoot, "video");
|
|
46
|
+
await fsp.mkdir(videoDir, { recursive: true });
|
|
47
|
+
|
|
48
|
+
const browserExecutable = resolvePlaywrightChromiumExecutable();
|
|
49
|
+
const playwrightFfmpeg = findPlaywrightFfmpegExecutable();
|
|
50
|
+
if (recordVideo && !playwrightFfmpeg) {
|
|
51
|
+
throw new DevTestBotRunnerError(
|
|
52
|
+
"devTestBot video recording requires Playwright's ffmpeg payload. Run `npm run devtestbot:install-browsers`.",
|
|
53
|
+
{ code: "DEVTESTBOT_FFMPEG_MISSING" },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sensitiveValues = collectSensitiveValues(identityCreds);
|
|
58
|
+
const consoleEvents = [];
|
|
59
|
+
const networkEvents = [];
|
|
60
|
+
const videoFrames = [];
|
|
61
|
+
const pendingCaptures = new Set();
|
|
62
|
+
const artifacts = {};
|
|
63
|
+
let finalized = false;
|
|
64
|
+
|
|
65
|
+
const browser = await chromium.launch({
|
|
66
|
+
headless,
|
|
67
|
+
executablePath: browserExecutable,
|
|
68
|
+
args: [
|
|
69
|
+
"--disable-gpu",
|
|
70
|
+
"--no-sandbox",
|
|
71
|
+
...(browserOptions.args || []),
|
|
72
|
+
],
|
|
73
|
+
...omit(browserOptions, ["args"]),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const context = await browser.newContext({
|
|
77
|
+
baseURL: normalizedBaseUrl,
|
|
78
|
+
viewport,
|
|
79
|
+
httpCredentials: normalizeHttpCredentials(identityCreds),
|
|
80
|
+
recordVideo: recordVideo
|
|
81
|
+
? {
|
|
82
|
+
dir: videoDir,
|
|
83
|
+
size: viewport,
|
|
84
|
+
}
|
|
85
|
+
: undefined,
|
|
86
|
+
});
|
|
87
|
+
context.setDefaultTimeout(timeoutMs);
|
|
88
|
+
|
|
89
|
+
await context.addInitScript(() => {
|
|
90
|
+
window.__sentinelayerClickCoverage = [];
|
|
91
|
+
document.addEventListener(
|
|
92
|
+
"click",
|
|
93
|
+
(event) => {
|
|
94
|
+
const target = event.target;
|
|
95
|
+
if (!target || typeof target.closest !== "function") return;
|
|
96
|
+
const element = target.closest("button,a,input,select,textarea,[role],[data-testid],[data-test]");
|
|
97
|
+
if (!element) return;
|
|
98
|
+
const rect = element.getBoundingClientRect();
|
|
99
|
+
window.__sentinelayerClickCoverage.push({
|
|
100
|
+
tagName: element.tagName ? element.tagName.toLowerCase() : "",
|
|
101
|
+
id: element.id || "",
|
|
102
|
+
role: element.getAttribute("role") || "",
|
|
103
|
+
testId: element.getAttribute("data-testid") || element.getAttribute("data-test") || "",
|
|
104
|
+
name: element.getAttribute("aria-label") || element.getAttribute("name") || "",
|
|
105
|
+
text: (element.innerText || element.value || "").slice(0, 120),
|
|
106
|
+
x: Math.round(rect.left + rect.width / 2),
|
|
107
|
+
y: Math.round(rect.top + rect.height / 2),
|
|
108
|
+
timestamp: new Date().toISOString(),
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
true,
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const page = await context.newPage();
|
|
116
|
+
page.on("console", (message) => {
|
|
117
|
+
consoleEvents.push({
|
|
118
|
+
type: message.type(),
|
|
119
|
+
text: redactText(message.text(), sensitiveValues),
|
|
120
|
+
location: sanitizeLocation(message.location(), sensitiveValues),
|
|
121
|
+
timestamp: new Date().toISOString(),
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
page.on("pageerror", (error) => {
|
|
125
|
+
consoleEvents.push({
|
|
126
|
+
type: "pageerror",
|
|
127
|
+
text: redactText(error?.message || String(error), sensitiveValues),
|
|
128
|
+
timestamp: new Date().toISOString(),
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
page.on("request", (request) => {
|
|
132
|
+
networkEvents.push({
|
|
133
|
+
phase: "request",
|
|
134
|
+
method: request.method(),
|
|
135
|
+
resourceType: request.resourceType(),
|
|
136
|
+
url: sanitizeUrl(request.url(), sensitiveValues),
|
|
137
|
+
headers: redactHeaders(request.headers(), sensitiveValues),
|
|
138
|
+
timestamp: new Date().toISOString(),
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
page.on("response", (response) => {
|
|
142
|
+
trackCapture(
|
|
143
|
+
(async () => {
|
|
144
|
+
networkEvents.push({
|
|
145
|
+
phase: "response",
|
|
146
|
+
method: response.request().method(),
|
|
147
|
+
resourceType: response.request().resourceType(),
|
|
148
|
+
url: sanitizeUrl(response.url(), sensitiveValues),
|
|
149
|
+
status: response.status(),
|
|
150
|
+
ok: response.ok(),
|
|
151
|
+
headers: redactHeaders(await response.allHeaders().catch(() => ({})), sensitiveValues),
|
|
152
|
+
timestamp: new Date().toISOString(),
|
|
153
|
+
});
|
|
154
|
+
})(),
|
|
155
|
+
pendingCaptures,
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
page.on("requestfailed", (request) => {
|
|
159
|
+
networkEvents.push({
|
|
160
|
+
phase: "requestfailed",
|
|
161
|
+
method: request.method(),
|
|
162
|
+
resourceType: request.resourceType(),
|
|
163
|
+
url: sanitizeUrl(request.url(), sensitiveValues),
|
|
164
|
+
failure: redactText(request.failure()?.errorText || "unknown", sensitiveValues),
|
|
165
|
+
timestamp: new Date().toISOString(),
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
async function goto(target = "/", options = {}) {
|
|
170
|
+
const response = await page.goto(resolveNavigationUrl(target, normalizedBaseUrl), {
|
|
171
|
+
waitUntil: "networkidle",
|
|
172
|
+
timeout: timeoutMs,
|
|
173
|
+
...options,
|
|
174
|
+
});
|
|
175
|
+
await captureVideoFrame("after-goto").catch(() => {});
|
|
176
|
+
return response;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function captureVideoFrame(label) {
|
|
180
|
+
const png = await page.screenshot({ type: "png", fullPage: false });
|
|
181
|
+
videoFrames.push({
|
|
182
|
+
label,
|
|
183
|
+
pngBase64: png.toString("base64"),
|
|
184
|
+
timestamp: new Date().toISOString(),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function writeConsoleCapture() {
|
|
189
|
+
const outputPath = path.join(artifactRoot, "console.json");
|
|
190
|
+
await writeJson(outputPath, {
|
|
191
|
+
generatedAt: new Date().toISOString(),
|
|
192
|
+
count: consoleEvents.length,
|
|
193
|
+
events: consoleEvents,
|
|
194
|
+
});
|
|
195
|
+
artifacts.consolePath = outputPath;
|
|
196
|
+
return outputPath;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function writeNetworkCapture() {
|
|
200
|
+
await settlePendingCaptures(pendingCaptures);
|
|
201
|
+
const outputPath = path.join(artifactRoot, "network.json");
|
|
202
|
+
await writeJson(outputPath, {
|
|
203
|
+
generatedAt: new Date().toISOString(),
|
|
204
|
+
count: networkEvents.length,
|
|
205
|
+
events: networkEvents,
|
|
206
|
+
});
|
|
207
|
+
artifacts.networkPath = outputPath;
|
|
208
|
+
return outputPath;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function writeClickCoverage() {
|
|
212
|
+
const clicks = await page.evaluate(() => window.__sentinelayerClickCoverage || []).catch(() => []);
|
|
213
|
+
const sanitizedClicks = clicks.map((click) => sanitizeJson(click, sensitiveValues));
|
|
214
|
+
const outputPath = path.join(artifactRoot, "click-coverage.json");
|
|
215
|
+
await writeJson(outputPath, {
|
|
216
|
+
generatedAt: new Date().toISOString(),
|
|
217
|
+
count: sanitizedClicks.length,
|
|
218
|
+
clicks: sanitizedClicks,
|
|
219
|
+
});
|
|
220
|
+
artifacts.clickCoveragePath = outputPath;
|
|
221
|
+
return outputPath;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function scanA11y(options = {}) {
|
|
225
|
+
const outputPath = options.outputPath || path.join(artifactRoot, "a11y.json");
|
|
226
|
+
const startedAt = new Date().toISOString();
|
|
227
|
+
try {
|
|
228
|
+
const result = await new AxeBuilder({ page }).analyze();
|
|
229
|
+
const payload = sanitizeJson({
|
|
230
|
+
available: true,
|
|
231
|
+
generatedAt: new Date().toISOString(),
|
|
232
|
+
startedAt,
|
|
233
|
+
url: page.url(),
|
|
234
|
+
violations: result.violations || [],
|
|
235
|
+
passes: result.passes || [],
|
|
236
|
+
incomplete: result.incomplete || [],
|
|
237
|
+
inapplicable: result.inapplicable || [],
|
|
238
|
+
}, sensitiveValues);
|
|
239
|
+
await writeJson(outputPath, payload);
|
|
240
|
+
artifacts.a11yPath = outputPath;
|
|
241
|
+
return payload;
|
|
242
|
+
} catch (error) {
|
|
243
|
+
const payload = {
|
|
244
|
+
available: false,
|
|
245
|
+
generatedAt: new Date().toISOString(),
|
|
246
|
+
startedAt,
|
|
247
|
+
url: sanitizeUrl(page.url(), sensitiveValues),
|
|
248
|
+
reason: redactText(error?.message || String(error), sensitiveValues),
|
|
249
|
+
};
|
|
250
|
+
await writeJson(outputPath, payload);
|
|
251
|
+
artifacts.a11yPath = outputPath;
|
|
252
|
+
return payload;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function runLighthouseCapture(options = {}) {
|
|
257
|
+
const targetUrl = options.url || page.url() || normalizedBaseUrl;
|
|
258
|
+
const outputPath = options.outputPath || path.join(artifactRoot, "lighthouse.json");
|
|
259
|
+
const result = await runLighthouseCli({
|
|
260
|
+
url: targetUrl,
|
|
261
|
+
outputPath,
|
|
262
|
+
chromePath: browserExecutable,
|
|
263
|
+
timeoutMs: options.timeoutMs || lighthouseTimeoutMs,
|
|
264
|
+
sensitiveValues,
|
|
265
|
+
});
|
|
266
|
+
artifacts.lighthousePath = outputPath;
|
|
267
|
+
return result;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function finalize() {
|
|
271
|
+
if (finalized) {
|
|
272
|
+
return buildFinalResult({ artifacts, artifactRoot, consoleEvents, networkEvents });
|
|
273
|
+
}
|
|
274
|
+
finalized = true;
|
|
275
|
+
|
|
276
|
+
await settlePendingCaptures(pendingCaptures);
|
|
277
|
+
await writeConsoleCapture();
|
|
278
|
+
await writeNetworkCapture();
|
|
279
|
+
await writeClickCoverage();
|
|
280
|
+
if (!artifacts.a11yPath) await scanA11y();
|
|
281
|
+
if (runLighthouse && !artifacts.lighthousePath) await runLighthouseCapture();
|
|
282
|
+
let artifactError = null;
|
|
283
|
+
try {
|
|
284
|
+
await captureVideoFrame("final");
|
|
285
|
+
if (videoFrames.length > 0) {
|
|
286
|
+
const mp4Path = path.join(videoDir, "recording.mp4");
|
|
287
|
+
await writeMp4FromPngFrames(page, videoFrames, mp4Path, viewport);
|
|
288
|
+
artifacts.videoMp4Path = mp4Path;
|
|
289
|
+
}
|
|
290
|
+
} catch (error) {
|
|
291
|
+
artifactError = error;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const video = page.video();
|
|
295
|
+
let webmPath = null;
|
|
296
|
+
let closeError = null;
|
|
297
|
+
try {
|
|
298
|
+
await context.close();
|
|
299
|
+
} catch (error) {
|
|
300
|
+
closeError = error;
|
|
301
|
+
}
|
|
302
|
+
if (video) {
|
|
303
|
+
webmPath = await video.path().catch(() => null);
|
|
304
|
+
}
|
|
305
|
+
await browser.close();
|
|
306
|
+
if (closeError) {
|
|
307
|
+
throw new DevTestBotRunnerError("devTestBot failed to finalize browser context.", {
|
|
308
|
+
code: "DEVTESTBOT_CONTEXT_CLOSE_FAILED",
|
|
309
|
+
cause: closeError,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (webmPath && fs.existsSync(webmPath)) {
|
|
314
|
+
artifacts.videoWebmPath = webmPath;
|
|
315
|
+
}
|
|
316
|
+
if (artifactError) {
|
|
317
|
+
throw new DevTestBotRunnerError("devTestBot failed to write browser artifacts.", {
|
|
318
|
+
code: "DEVTESTBOT_ARTIFACT_WRITE_FAILED",
|
|
319
|
+
cause: artifactError,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const manifestPath = path.join(artifactRoot, "manifest.json");
|
|
324
|
+
artifacts.manifestPath = manifestPath;
|
|
325
|
+
await writeJson(manifestPath, {
|
|
326
|
+
runId,
|
|
327
|
+
generatedAt: new Date().toISOString(),
|
|
328
|
+
baseUrl: sanitizeUrl(normalizedBaseUrl, sensitiveValues),
|
|
329
|
+
identity: summarizeIdentity(identityCreds, sensitiveValues),
|
|
330
|
+
artifacts: Object.fromEntries(Object.entries(artifacts).map(([key, value]) => [key, value])),
|
|
331
|
+
counts: {
|
|
332
|
+
console: consoleEvents.length,
|
|
333
|
+
network: networkEvents.length,
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
return buildFinalResult({ artifacts, artifactRoot, consoleEvents, networkEvents });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
page,
|
|
342
|
+
browser,
|
|
343
|
+
context,
|
|
344
|
+
baseUrl: normalizedBaseUrl,
|
|
345
|
+
outputDir: artifactRoot,
|
|
346
|
+
artifacts,
|
|
347
|
+
captures: {
|
|
348
|
+
console: consoleEvents,
|
|
349
|
+
network: networkEvents,
|
|
350
|
+
},
|
|
351
|
+
goto,
|
|
352
|
+
scanA11y,
|
|
353
|
+
runLighthouse: runLighthouseCapture,
|
|
354
|
+
writeConsoleCapture,
|
|
355
|
+
writeNetworkCapture,
|
|
356
|
+
writeClickCoverage,
|
|
357
|
+
finalize,
|
|
358
|
+
close: finalize,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function resolvePlaywrightChromiumExecutable() {
|
|
363
|
+
const executablePath = chromium.executablePath();
|
|
364
|
+
if (executablePath && fs.existsSync(executablePath)) {
|
|
365
|
+
return executablePath;
|
|
366
|
+
}
|
|
367
|
+
throw new DevTestBotRunnerError(
|
|
368
|
+
"devTestBot requires Playwright Chromium. Run `npm run devtestbot:install-browsers`.",
|
|
369
|
+
{ code: "DEVTESTBOT_BROWSER_MISSING" },
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function findPlaywrightFfmpegExecutable(env = process.env, platform = process.platform) {
|
|
374
|
+
const roots = playwrightRegistryRoots(env, platform);
|
|
375
|
+
const names = playwrightFfmpegExecutableNames(platform);
|
|
376
|
+
for (const root of roots) {
|
|
377
|
+
const found = findExecutableUnder(root, names, 4);
|
|
378
|
+
if (found) return found;
|
|
379
|
+
}
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export async function writeMp4FromPngFrames(page, frames, outputPath, viewport = DEFAULT_VIEWPORT) {
|
|
384
|
+
if (!Array.isArray(frames) || frames.length === 0) {
|
|
385
|
+
throw new DevTestBotRunnerError("devTestBot MP4 generation requires at least one frame.", {
|
|
386
|
+
code: "DEVTESTBOT_MP4_NO_FRAMES",
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
await page.addScriptTag({ path: await resolveMp4MuxerBundlePath() });
|
|
390
|
+
const width = Number(viewport?.width || DEFAULT_VIEWPORT.width);
|
|
391
|
+
const height = Number(viewport?.height || DEFAULT_VIEWPORT.height);
|
|
392
|
+
const payloadFrames = frames.length === 1
|
|
393
|
+
? [frames[0], frames[0], frames[0], frames[0]]
|
|
394
|
+
: frames.flatMap((frame) => [frame, frame]);
|
|
395
|
+
const bytes = await page.evaluate(async ({ payloadFrames, width, height }) => {
|
|
396
|
+
if (!("VideoEncoder" in window)) {
|
|
397
|
+
throw new Error("WebCodecs VideoEncoder is unavailable in this browser context.");
|
|
398
|
+
}
|
|
399
|
+
if (!window.Mp4Muxer?.Muxer || !window.Mp4Muxer?.ArrayBufferTarget) {
|
|
400
|
+
throw new Error("mp4-muxer did not initialize in the browser context.");
|
|
401
|
+
}
|
|
402
|
+
const candidates = [
|
|
403
|
+
{ encoderCodec: "avc1.42001f", muxerCodec: "avc" },
|
|
404
|
+
{ encoderCodec: "vp09.00.10.08", muxerCodec: "vp9" },
|
|
405
|
+
{ encoderCodec: "av01.0.04M.08", muxerCodec: "av1" },
|
|
406
|
+
];
|
|
407
|
+
let selected = null;
|
|
408
|
+
for (const candidate of candidates) {
|
|
409
|
+
const support = await VideoEncoder.isConfigSupported({
|
|
410
|
+
codec: candidate.encoderCodec,
|
|
411
|
+
width,
|
|
412
|
+
height,
|
|
413
|
+
bitrate: 500000,
|
|
414
|
+
framerate: 2,
|
|
415
|
+
}).catch(() => ({ supported: false }));
|
|
416
|
+
if (support.supported) {
|
|
417
|
+
selected = candidate;
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (!selected) {
|
|
422
|
+
throw new Error("No browser-supported MP4 video codec was available.");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const target = new window.Mp4Muxer.ArrayBufferTarget();
|
|
426
|
+
const muxer = new window.Mp4Muxer.Muxer({
|
|
427
|
+
target,
|
|
428
|
+
video: {
|
|
429
|
+
codec: selected.muxerCodec,
|
|
430
|
+
width,
|
|
431
|
+
height,
|
|
432
|
+
frameRate: 2,
|
|
433
|
+
},
|
|
434
|
+
fastStart: "in-memory",
|
|
435
|
+
});
|
|
436
|
+
let encoderError = null;
|
|
437
|
+
const encoder = new VideoEncoder({
|
|
438
|
+
output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
|
|
439
|
+
error: (error) => { encoderError = error; },
|
|
440
|
+
});
|
|
441
|
+
encoder.configure({
|
|
442
|
+
codec: selected.encoderCodec,
|
|
443
|
+
width,
|
|
444
|
+
height,
|
|
445
|
+
bitrate: 500000,
|
|
446
|
+
framerate: 2,
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
const canvas = new OffscreenCanvas(width, height);
|
|
450
|
+
const context = canvas.getContext("2d", { alpha: false });
|
|
451
|
+
const bitmaps = await Promise.all(payloadFrames.map(async (item) => {
|
|
452
|
+
const imageResponse = await fetch("data:image/png;base64," + item.pngBase64);
|
|
453
|
+
const imageBlob = await imageResponse.blob();
|
|
454
|
+
return createImageBitmap(imageBlob);
|
|
455
|
+
}));
|
|
456
|
+
for (let index = 0; index < payloadFrames.length; index += 1) {
|
|
457
|
+
const bitmap = bitmaps[index];
|
|
458
|
+
context.fillStyle = "#ffffff";
|
|
459
|
+
context.fillRect(0, 0, width, height);
|
|
460
|
+
context.drawImage(bitmap, 0, 0, width, height);
|
|
461
|
+
bitmap.close();
|
|
462
|
+
const frame = new VideoFrame(canvas, {
|
|
463
|
+
timestamp: index * 500000,
|
|
464
|
+
duration: 500000,
|
|
465
|
+
});
|
|
466
|
+
encoder.encode(frame, { keyFrame: index === 0 });
|
|
467
|
+
frame.close();
|
|
468
|
+
if (encoderError) throw encoderError;
|
|
469
|
+
}
|
|
470
|
+
await encoder.flush();
|
|
471
|
+
encoder.close();
|
|
472
|
+
if (encoderError) throw encoderError;
|
|
473
|
+
muxer.finalize();
|
|
474
|
+
return Array.from(new Uint8Array(target.buffer));
|
|
475
|
+
}, { payloadFrames, width, height });
|
|
476
|
+
|
|
477
|
+
await fsp.mkdir(path.dirname(outputPath), { recursive: true });
|
|
478
|
+
await fsp.writeFile(outputPath, Buffer.from(bytes));
|
|
479
|
+
const stat = await fsp.stat(outputPath).catch(() => null);
|
|
480
|
+
if (!stat || stat.size <= 0) {
|
|
481
|
+
throw new DevTestBotRunnerError("devTestBot MP4 generation produced no output.", {
|
|
482
|
+
code: "DEVTESTBOT_MP4_EMPTY",
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
return outputPath;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export function redactText(value, sensitiveValues = []) {
|
|
489
|
+
let text = String(value ?? "");
|
|
490
|
+
for (const sensitiveValue of sensitiveValues) {
|
|
491
|
+
if (!sensitiveValue) continue;
|
|
492
|
+
text = text.split(sensitiveValue).join("[REDACTED]");
|
|
493
|
+
}
|
|
494
|
+
return text
|
|
495
|
+
.replace(TOKEN_VALUE_PATTERN, (match) => match.replace(/[:=]\s*["']?.*$/u, "=[REDACTED]"))
|
|
496
|
+
.replace(LONG_SECRET_PATTERN, "[REDACTED]");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function normalizeBaseUrl(baseUrl) {
|
|
500
|
+
if (!baseUrl) {
|
|
501
|
+
throw new DevTestBotRunnerError("launch({ baseUrl }) is required.", { code: "DEVTESTBOT_BASE_URL_REQUIRED" });
|
|
502
|
+
}
|
|
503
|
+
let parsed;
|
|
504
|
+
try {
|
|
505
|
+
parsed = new URL(baseUrl);
|
|
506
|
+
} catch (error) {
|
|
507
|
+
throw new DevTestBotRunnerError("launch({ baseUrl }) must be an absolute URL.", {
|
|
508
|
+
code: "DEVTESTBOT_BASE_URL_INVALID",
|
|
509
|
+
cause: error,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
513
|
+
throw new DevTestBotRunnerError("launch({ baseUrl }) must use http or https.", {
|
|
514
|
+
code: "DEVTESTBOT_BASE_URL_UNSUPPORTED",
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
return parsed.href.replace(/\/+$/, "");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function resolveNavigationUrl(target, baseUrl) {
|
|
521
|
+
return new URL(target || "/", baseUrl + "/").href;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function normalizeHttpCredentials(identityCreds) {
|
|
525
|
+
if (!identityCreds || typeof identityCreds !== "object") return undefined;
|
|
526
|
+
const username = identityCreds.username || identityCreds.email;
|
|
527
|
+
const password = identityCreds.password;
|
|
528
|
+
if (!username || !password) return undefined;
|
|
529
|
+
return {
|
|
530
|
+
username: String(username),
|
|
531
|
+
password: String(password),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function collectSensitiveValues(value, out = new Set()) {
|
|
536
|
+
if (value == null) return [...out];
|
|
537
|
+
if (typeof value === "string") {
|
|
538
|
+
if (value.length >= 4) out.add(value);
|
|
539
|
+
return [...out];
|
|
540
|
+
}
|
|
541
|
+
if (typeof value !== "object") return [...out];
|
|
542
|
+
for (const item of Object.values(value)) {
|
|
543
|
+
collectSensitiveValues(item, out);
|
|
544
|
+
}
|
|
545
|
+
return [...out];
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function redactHeaders(headers, sensitiveValues) {
|
|
549
|
+
const output = {};
|
|
550
|
+
for (const [key, value] of Object.entries(headers || {})) {
|
|
551
|
+
output[key] = SENSITIVE_KEY_PATTERN.test(key)
|
|
552
|
+
? "[REDACTED]"
|
|
553
|
+
: redactText(value, sensitiveValues);
|
|
554
|
+
}
|
|
555
|
+
return output;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function sanitizeUrl(rawUrl, sensitiveValues) {
|
|
559
|
+
try {
|
|
560
|
+
const parsed = new URL(rawUrl);
|
|
561
|
+
for (const key of [...parsed.searchParams.keys()]) {
|
|
562
|
+
if (SENSITIVE_KEY_PATTERN.test(key)) {
|
|
563
|
+
parsed.searchParams.set(key, "[REDACTED]");
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return redactText(parsed.href, sensitiveValues);
|
|
567
|
+
} catch {
|
|
568
|
+
return redactText(rawUrl, sensitiveValues);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function sanitizeLocation(location, sensitiveValues) {
|
|
573
|
+
if (!location || typeof location !== "object") return {};
|
|
574
|
+
return sanitizeJson(location, sensitiveValues);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function sanitizeJson(value, sensitiveValues) {
|
|
578
|
+
if (Array.isArray(value)) return value.map((item) => sanitizeJson(item, sensitiveValues));
|
|
579
|
+
if (value && typeof value === "object") {
|
|
580
|
+
const output = {};
|
|
581
|
+
for (const [key, item] of Object.entries(value)) {
|
|
582
|
+
output[key] = SENSITIVE_KEY_PATTERN.test(key)
|
|
583
|
+
? "[REDACTED]"
|
|
584
|
+
: sanitizeJson(item, sensitiveValues);
|
|
585
|
+
}
|
|
586
|
+
return output;
|
|
587
|
+
}
|
|
588
|
+
if (typeof value === "string") return redactText(value, sensitiveValues);
|
|
589
|
+
return value;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function runLighthouseCli({ url, outputPath, chromePath, timeoutMs, sensitiveValues }) {
|
|
593
|
+
const startedAt = new Date().toISOString();
|
|
594
|
+
await fsp.mkdir(path.dirname(outputPath), { recursive: true });
|
|
595
|
+
let cliPath;
|
|
596
|
+
try {
|
|
597
|
+
cliPath = await resolveLighthouseCliPath();
|
|
598
|
+
await execFileAsync(process.execPath, [
|
|
599
|
+
cliPath,
|
|
600
|
+
url,
|
|
601
|
+
"--output=json",
|
|
602
|
+
"--output-path=" + outputPath,
|
|
603
|
+
"--quiet",
|
|
604
|
+
"--preset=desktop",
|
|
605
|
+
"--throttling-method=provided",
|
|
606
|
+
"--only-categories=performance,accessibility,best-practices,seo",
|
|
607
|
+
"--chrome-flags=--headless=new --no-sandbox --disable-gpu",
|
|
608
|
+
], {
|
|
609
|
+
env: {
|
|
610
|
+
...process.env,
|
|
611
|
+
CHROME_PATH: chromePath,
|
|
612
|
+
},
|
|
613
|
+
timeout: timeoutMs,
|
|
614
|
+
windowsHide: true,
|
|
615
|
+
});
|
|
616
|
+
const raw = JSON.parse(await fsp.readFile(outputPath, "utf-8"));
|
|
617
|
+
const payload = sanitizeJson(raw, sensitiveValues);
|
|
618
|
+
await writeJson(outputPath, payload);
|
|
619
|
+
return {
|
|
620
|
+
available: true,
|
|
621
|
+
reportPath: outputPath,
|
|
622
|
+
startedAt,
|
|
623
|
+
scores: extractLighthouseScores(payload),
|
|
624
|
+
};
|
|
625
|
+
} catch (error) {
|
|
626
|
+
const existingReport = await readJsonIfPresent(outputPath);
|
|
627
|
+
if (existingReport && existingReport.categories) {
|
|
628
|
+
const payload = sanitizeJson(existingReport, sensitiveValues);
|
|
629
|
+
payload.devtestbotCaptureWarning = redactText(error?.message || String(error), sensitiveValues).slice(0, 500);
|
|
630
|
+
await writeJson(outputPath, payload);
|
|
631
|
+
return {
|
|
632
|
+
available: true,
|
|
633
|
+
reportPath: outputPath,
|
|
634
|
+
startedAt,
|
|
635
|
+
warning: payload.devtestbotCaptureWarning,
|
|
636
|
+
scores: extractLighthouseScores(payload),
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
const payload = {
|
|
640
|
+
available: false,
|
|
641
|
+
generatedAt: new Date().toISOString(),
|
|
642
|
+
startedAt,
|
|
643
|
+
url: sanitizeUrl(url, sensitiveValues),
|
|
644
|
+
reason: redactText(error?.message || String(error), sensitiveValues),
|
|
645
|
+
};
|
|
646
|
+
await writeJson(outputPath, payload);
|
|
647
|
+
return {
|
|
648
|
+
available: false,
|
|
649
|
+
reportPath: outputPath,
|
|
650
|
+
reason: payload.reason,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async function readJsonIfPresent(filePath) {
|
|
656
|
+
try {
|
|
657
|
+
return JSON.parse(await fsp.readFile(filePath, "utf-8"));
|
|
658
|
+
} catch {
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function resolveLighthouseCliPath() {
|
|
664
|
+
const packagePath = await import.meta.resolve("lighthouse/package.json");
|
|
665
|
+
return path.join(path.dirname(fileURLToPath(packagePath)), "cli", "index.js");
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async function resolveMp4MuxerBundlePath() {
|
|
669
|
+
const entryPath = fileURLToPath(await import.meta.resolve("mp4-muxer"));
|
|
670
|
+
return entryPath.endsWith(".mjs") ? entryPath.replace(/\.mjs$/u, ".js") : entryPath;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function extractLighthouseScores(report) {
|
|
674
|
+
const categories = report?.categories || {};
|
|
675
|
+
return {
|
|
676
|
+
performance: categories.performance?.score ?? null,
|
|
677
|
+
accessibility: categories.accessibility?.score ?? null,
|
|
678
|
+
bestPractices: categories["best-practices"]?.score ?? null,
|
|
679
|
+
seo: categories.seo?.score ?? null,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function summarizeIdentity(identityCreds, sensitiveValues) {
|
|
684
|
+
if (!identityCreds || typeof identityCreds !== "object") {
|
|
685
|
+
return { provided: false };
|
|
686
|
+
}
|
|
687
|
+
return {
|
|
688
|
+
provided: true,
|
|
689
|
+
username: identityCreds.username || identityCreds.email
|
|
690
|
+
? redactText(identityCreds.username || identityCreds.email, sensitiveValues)
|
|
691
|
+
: null,
|
|
692
|
+
fields: Object.keys(identityCreds).sort(),
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function playwrightRegistryRoots(env, platform = process.platform) {
|
|
697
|
+
const roots = [];
|
|
698
|
+
if (env.PLAYWRIGHT_BROWSERS_PATH && env.PLAYWRIGHT_BROWSERS_PATH !== "0") {
|
|
699
|
+
roots.push(env.PLAYWRIGHT_BROWSERS_PATH);
|
|
700
|
+
}
|
|
701
|
+
if (env.PLAYWRIGHT_BROWSERS_PATH === "0") {
|
|
702
|
+
roots.push(path.resolve("node_modules", "playwright-core", ".local-browsers"));
|
|
703
|
+
}
|
|
704
|
+
if (platform === "win32" && env.LOCALAPPDATA) {
|
|
705
|
+
roots.push(path.join(env.LOCALAPPDATA, "ms-playwright"));
|
|
706
|
+
} else if (platform === "darwin") {
|
|
707
|
+
roots.push(path.join(os.homedir(), "Library", "Caches", "ms-playwright"));
|
|
708
|
+
} else {
|
|
709
|
+
roots.push(path.join(os.homedir(), ".cache", "ms-playwright"));
|
|
710
|
+
}
|
|
711
|
+
return [...new Set(roots.filter(Boolean))];
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function playwrightFfmpegExecutableNames(platform = process.platform) {
|
|
715
|
+
if (platform === "win32") return ["ffmpeg-win64.exe", "ffmpeg.exe"];
|
|
716
|
+
if (platform === "darwin") return ["ffmpeg-mac", "ffmpeg"];
|
|
717
|
+
return ["ffmpeg-linux", "ffmpeg"];
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function findExecutableUnder(root, executableNames, maxDepth) {
|
|
721
|
+
if (!root || !fs.existsSync(root) || maxDepth < 0) return null;
|
|
722
|
+
const entries = fs.readdirSync(root, { withFileTypes: true });
|
|
723
|
+
for (const entry of entries) {
|
|
724
|
+
const fullPath = path.join(root, entry.name);
|
|
725
|
+
if (entry.isFile() && executableNames.includes(entry.name)) {
|
|
726
|
+
return fullPath;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
for (const entry of entries) {
|
|
730
|
+
if (!entry.isDirectory()) continue;
|
|
731
|
+
const found = findExecutableUnder(path.join(root, entry.name), executableNames, maxDepth - 1);
|
|
732
|
+
if (found) return found;
|
|
733
|
+
}
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function trackCapture(promise, pendingCaptures) {
|
|
738
|
+
const guarded = Promise.resolve(promise).catch(() => {});
|
|
739
|
+
pendingCaptures.add(guarded);
|
|
740
|
+
guarded.finally(() => pendingCaptures.delete(guarded));
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async function settlePendingCaptures(pendingCaptures) {
|
|
744
|
+
await Promise.allSettled([...pendingCaptures]);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async function writeJson(outputPath, payload) {
|
|
748
|
+
await fsp.mkdir(path.dirname(outputPath), { recursive: true });
|
|
749
|
+
await fsp.writeFile(outputPath, JSON.stringify(payload, null, 2) + "\n", "utf-8");
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function buildFinalResult({ artifacts, artifactRoot, consoleEvents, networkEvents }) {
|
|
753
|
+
return {
|
|
754
|
+
outputDir: artifactRoot,
|
|
755
|
+
artifacts: { ...artifacts },
|
|
756
|
+
counts: {
|
|
757
|
+
console: consoleEvents.length,
|
|
758
|
+
network: networkEvents.length,
|
|
759
|
+
},
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function omit(input, keys) {
|
|
764
|
+
const output = {};
|
|
765
|
+
for (const [key, value] of Object.entries(input || {})) {
|
|
766
|
+
if (!keys.includes(key)) output[key] = value;
|
|
767
|
+
}
|
|
768
|
+
return output;
|
|
769
|
+
}
|