@debugg-ai/debugg-ai-mcp
Version:
Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.
194 lines (193 loc) • 7.52 kB
JavaScript
/**
* harSummarizer — pure HAR + console aggregation utilities.
*
* Aggregation key for networkSummary: `origin + pathname` (per system reqs).
* Refetch loops with varying query strings collapse into a single entry.
*
* Pure functions — no I/O, no async — so they can be reused by the future
* `summarize_execution` tool.
*/
/**
* Re-aggregate backend's pre-grouped network_summary entries by
* `origin + pathname` (vs backend's full-URL key). Collapses refetch loops:
* 5 separate `/api/poll?n=0..4` entries become 1 entry with count: 5.
*
* Backend `browser.capture` (commit 154e1e69) emits network_summary already
* grouped by full URL with shape `{url, count, methods[], statuses{}, resource_types[]}`.
* That preserves the per-request granularity but defeats the original
* client #1 use case ("endpoint hit N times" refetch detection). MCP-side
* re-aggregation runs once over the small pre-grouped list — cheap.
*/
export function reaggregateByOriginPath(entries) {
if (!Array.isArray(entries))
return [];
const buckets = new Map();
for (const e of entries) {
try {
const url = e?.url;
if (typeof url !== 'string')
continue;
const parsed = new URL(url);
const key = `${parsed.origin}${parsed.pathname}`;
const count = typeof e.count === 'number' ? e.count : 0;
const statuses = e.statuses ?? {};
const existing = buckets.get(key);
if (existing) {
existing.count += count;
for (const [code, n] of Object.entries(statuses)) {
if (typeof n === 'number') {
existing.statuses[code] = (existing.statuses[code] ?? 0) + n;
}
}
}
else {
const out = {
url: key,
count,
statuses: { ...statuses },
totalBytes: 0, // Backend's pre-grouped shape doesn't expose response bytes; placeholder until we wire fetched-bytes.
};
buckets.set(key, out);
}
}
catch {
// malformed URL — skip
}
}
return [...buckets.values()].sort((a, b) => b.count - a.count);
}
/**
* Map backend's console_slice entry shape to MCP's ConsoleErrorEntry.
* Backend shape: {text, level, location: {url, line}, timestamp}
* MCP shape: {level, text, source?, lineNumber?, timestamp?}
*/
export function mapConsoleSlice(entries) {
if (!Array.isArray(entries))
return [];
const out = [];
for (const e of entries) {
if (typeof e !== 'object' || e === null)
continue;
const entry = {
level: typeof e.level === 'string' ? e.level : 'log',
text: typeof e.text === 'string' ? e.text : '',
};
const loc = e.location ?? {};
if (typeof loc.url === 'string' && loc.url)
entry.source = loc.url;
else if (typeof e.source === 'string' && e.source)
entry.source = e.source;
if (typeof loc.line === 'number')
entry.lineNumber = loc.line;
else if (typeof e.lineNumber === 'number')
entry.lineNumber = e.lineNumber;
// Backend timestamps are ISO strings; MCP type uses number (ms since epoch).
// Coerce when possible; otherwise pass through unchanged.
if (typeof e.timestamp === 'number') {
entry.timestamp = e.timestamp;
}
else if (typeof e.timestamp === 'string') {
const parsed = Date.parse(e.timestamp);
if (!isNaN(parsed))
entry.timestamp = parsed;
}
out.push(entry);
}
return out;
}
/**
* Aggregate HAR `log.entries` into per-endpoint NetworkSummary[], sorted
* descending by request count (hottest endpoints first). Malformed entries
* (missing request.url or response.status) are skipped, not thrown.
*/
export function summarizeHar(harEntries) {
if (!Array.isArray(harEntries))
return [];
const buckets = new Map();
for (const entry of harEntries) {
try {
const reqUrl = entry?.request?.url;
const status = entry?.response?.status;
if (typeof reqUrl !== 'string' || typeof status !== 'number')
continue;
// Aggregation key: origin + pathname (refetch loops collapse).
let parsed;
try {
parsed = new URL(reqUrl);
}
catch {
continue;
}
const key = `${parsed.origin}${parsed.pathname}`;
const bytesRaw = entry?.response?.content?.size;
const bytes = typeof bytesRaw === 'number' && bytesRaw >= 0 ? bytesRaw : 0;
const mime = entry?.response?.content?.mimeType;
const mimeStr = typeof mime === 'string' && mime ? mime : '';
const existing = buckets.get(key);
if (existing) {
existing.count++;
const sk = String(status);
existing.statuses[sk] = (existing.statuses[sk] ?? 0) + 1;
existing.totalBytes += bytes;
if (mimeStr)
existing.mimeTypes.add(mimeStr);
}
else {
buckets.set(key, {
url: key,
count: 1,
statuses: { [String(status)]: 1 },
totalBytes: bytes,
mimeTypes: mimeStr ? new Set([mimeStr]) : new Set(),
});
}
}
catch {
// malformed — skip
}
}
return [...buckets.values()]
.map(({ mimeTypes, url, count, statuses, totalBytes }) => {
const out = { url, count, statuses, totalBytes };
// Only attach mimeType when homogeneous — mixed types omit the field.
if (mimeTypes.size === 1) {
out.mimeType = [...mimeTypes][0];
}
return out;
})
.sort((a, b) => b.count - a.count);
}
/**
* Normalize a console-log JSON array into ConsoleErrorEntry[].
* Maps backend's snake_case (`line_number`, `url`) to MCP's camelCase
* (`lineNumber`, `source`). Drops entries that aren't plain objects.
*/
export function summarizeConsole(consoleEntries) {
if (!Array.isArray(consoleEntries))
return [];
const out = [];
for (const e of consoleEntries) {
if (typeof e !== 'object' || e === null)
continue;
const entry = {
level: typeof e.level === 'string' ? e.level : 'log',
text: typeof e.text === 'string' ? e.text : '',
};
// source: prefer `url` (backend convention), fall back to `source`
const sourceVal = typeof e.url === 'string' && e.url
? e.url
: (typeof e.source === 'string' && e.source ? e.source : undefined);
if (sourceVal)
entry.source = sourceVal;
// lineNumber: snake_case from backend → camelCase
const lineVal = typeof e.line_number === 'number'
? e.line_number
: (typeof e.lineNumber === 'number' ? e.lineNumber : undefined);
if (typeof lineVal === 'number')
entry.lineNumber = lineVal;
if (typeof e.timestamp === 'number')
entry.timestamp = e.timestamp;
out.push(entry);
}
return out;
}