browser-debug-mcp-bridge 1.11.1 → 1.13.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.
Files changed (33) hide show
  1. package/README.md +4 -2
  2. package/apps/mcp-server/dist/db/automation-repository.js +9 -4
  3. package/apps/mcp-server/dist/db/automation-repository.js.map +1 -1
  4. package/apps/mcp-server/dist/db/migrations.js +300 -1
  5. package/apps/mcp-server/dist/db/migrations.js.map +1 -1
  6. package/apps/mcp-server/dist/db/schema.js +226 -2
  7. package/apps/mcp-server/dist/db/schema.js.map +1 -1
  8. package/apps/mcp-server/dist/lighthouse-report.js +1001 -0
  9. package/apps/mcp-server/dist/lighthouse-report.js.map +1 -0
  10. package/apps/mcp-server/dist/main.js +249 -1
  11. package/apps/mcp-server/dist/main.js.map +1 -1
  12. package/apps/mcp-server/dist/mcp/server.js +3705 -311
  13. package/apps/mcp-server/dist/mcp/server.js.map +1 -1
  14. package/apps/mcp-server/dist/mcp/target-resolution.js +390 -0
  15. package/apps/mcp-server/dist/mcp/target-resolution.js.map +1 -0
  16. package/apps/mcp-server/dist/mcp/tool-loop-guard.js +655 -0
  17. package/apps/mcp-server/dist/mcp/tool-loop-guard.js.map +1 -0
  18. package/apps/mcp-server/dist/mock-store.js +408 -0
  19. package/apps/mcp-server/dist/mock-store.js.map +1 -0
  20. package/apps/mcp-server/dist/override-audit-contract.js +58 -0
  21. package/apps/mcp-server/dist/override-audit-contract.js.map +1 -1
  22. package/apps/mcp-server/dist/override-audit.js +100 -4
  23. package/apps/mcp-server/dist/override-audit.js.map +1 -1
  24. package/apps/mcp-server/dist/override-poc.js +4 -4
  25. package/apps/mcp-server/dist/override-poc.js.map +1 -1
  26. package/apps/mcp-server/dist/override-profile-generator.js +3 -9
  27. package/apps/mcp-server/dist/override-profile-generator.js.map +1 -1
  28. package/apps/mcp-server/dist/ssr-mock.js +480 -0
  29. package/apps/mcp-server/dist/ssr-mock.js.map +1 -0
  30. package/apps/mcp-server/dist/websocket/messages.js +5 -0
  31. package/apps/mcp-server/dist/websocket/messages.js.map +1 -1
  32. package/apps/mcp-server/package.json +5 -0
  33. package/package.json +9 -4
@@ -0,0 +1,1001 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'fs';
3
+ import { basename, dirname, extname, join, relative, resolve, sep } from 'path';
4
+ import { getRuntimeDataDir } from './runtime-paths.js';
5
+ export const LIGHTHOUSE_REPORT_ASSET_DIR = 'lighthouse-reports';
6
+ const DEFAULT_REPORT_LIMIT = 25;
7
+ const MAX_REPORT_LIMIT = 200;
8
+ const DEFAULT_ASSET_CHUNK_BYTES = 64 * 1024;
9
+ const MAX_ASSET_CHUNK_BYTES = 256 * 1024;
10
+ const DEFAULT_FIX_ITEM_LIMIT = 50;
11
+ const MAX_FIX_ITEM_LIMIT = 200;
12
+ const DEFAULT_SOURCE_CANDIDATE_LIMIT = 5;
13
+ const MAX_SOURCE_CANDIDATE_LIMIT = 20;
14
+ const MAX_REPO_SCAN_FILES = 20_000;
15
+ const MAX_REPO_SCAN_DEPTH = 12;
16
+ const LIGHTHOUSE_CATEGORY_IDS = new Set(['performance', 'accessibility', 'best-practices', 'seo', 'pwa']);
17
+ const LIGHTHOUSE_ASSETS = new Set(['json', 'html']);
18
+ const REPO_SCAN_IGNORED_DIRS = new Set([
19
+ '.git',
20
+ '.hg',
21
+ '.next',
22
+ '.nx',
23
+ '.turbo',
24
+ 'coverage',
25
+ 'dist',
26
+ 'build',
27
+ 'node_modules',
28
+ 'test-results',
29
+ 'playwright-report',
30
+ ]);
31
+ const SOURCE_CANDIDATE_EXTENSIONS = new Set([
32
+ '.astro',
33
+ '.avif',
34
+ '.cjs',
35
+ '.css',
36
+ '.gif',
37
+ '.html',
38
+ '.jpeg',
39
+ '.jpg',
40
+ '.js',
41
+ '.jsx',
42
+ '.less',
43
+ '.mjs',
44
+ '.png',
45
+ '.sass',
46
+ '.scss',
47
+ '.svg',
48
+ '.svelte',
49
+ '.ts',
50
+ '.tsx',
51
+ '.vue',
52
+ '.webp',
53
+ '.woff',
54
+ '.woff2',
55
+ ]);
56
+ export function getLighthouseArtifactRoot() {
57
+ return join(getRuntimeDataDir(), LIGHTHOUSE_REPORT_ASSET_DIR);
58
+ }
59
+ export function normalizeLighthouseCategories(value) {
60
+ if (!Array.isArray(value)) {
61
+ return ['performance'];
62
+ }
63
+ const categories = value
64
+ .filter((entry) => typeof entry === 'string')
65
+ .map((entry) => entry.trim())
66
+ .filter((entry) => LIGHTHOUSE_CATEGORY_IDS.has(entry));
67
+ return categories.length > 0 ? Array.from(new Set(categories)) : ['performance'];
68
+ }
69
+ export function normalizeLighthouseFormFactor(value) {
70
+ return value === 'desktop' ? 'desktop' : 'mobile';
71
+ }
72
+ export function normalizeLighthouseAsset(value) {
73
+ return value === 'html' ? 'html' : 'json';
74
+ }
75
+ export function resolveLighthouseLimit(value, fallback = DEFAULT_REPORT_LIMIT) {
76
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
77
+ return fallback;
78
+ }
79
+ return Math.min(Math.max(1, Math.floor(value)), MAX_REPORT_LIMIT);
80
+ }
81
+ export function resolveLighthouseOffset(value) {
82
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
83
+ return 0;
84
+ }
85
+ return Math.max(0, Math.floor(value));
86
+ }
87
+ export function resolveLighthouseChunkBytes(value) {
88
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
89
+ return DEFAULT_ASSET_CHUNK_BYTES;
90
+ }
91
+ return Math.min(Math.max(1, Math.floor(value)), MAX_ASSET_CHUNK_BYTES);
92
+ }
93
+ export function resolveLighthouseFixItemLimit(value) {
94
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
95
+ return DEFAULT_FIX_ITEM_LIMIT;
96
+ }
97
+ return Math.min(Math.max(1, Math.floor(value)), MAX_FIX_ITEM_LIMIT);
98
+ }
99
+ export function resolveLighthouseSourceCandidateLimit(value) {
100
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
101
+ return DEFAULT_SOURCE_CANDIDATE_LIMIT;
102
+ }
103
+ return Math.min(Math.max(1, Math.floor(value)), MAX_SOURCE_CANDIDATE_LIMIT);
104
+ }
105
+ export function resolveLighthouseUrl(db, input) {
106
+ if (typeof input.url === 'string' && input.url.trim().length > 0) {
107
+ return normalizeHttpUrl(input.url, 'url');
108
+ }
109
+ if (!input.sessionId) {
110
+ throw new Error('url or sessionId is required');
111
+ }
112
+ const row = db
113
+ .prepare('SELECT url_last, url_start FROM sessions WHERE session_id = ?')
114
+ .get(input.sessionId);
115
+ if (!row) {
116
+ throw new Error(`Session not found: ${input.sessionId}`);
117
+ }
118
+ const candidate = row.url_last ?? row.url_start;
119
+ if (!candidate) {
120
+ throw new Error(`Session has no URL to audit: ${input.sessionId}`);
121
+ }
122
+ return normalizeHttpUrl(candidate, 'session URL');
123
+ }
124
+ export async function runLighthouseReport(db, input, runner = createDefaultLighthouseRunner()) {
125
+ const createdAt = Date.now();
126
+ const reportId = `lhr-${createdAt}-${randomUUID()}`;
127
+ const url = resolveLighthouseUrl(db, input);
128
+ const formFactor = normalizeLighthouseFormFactor(input.formFactor);
129
+ const categories = normalizeLighthouseCategories(input.categories);
130
+ const artifactDir = input.artifactDir ?? getLighthouseArtifactRoot();
131
+ const chromeFlags = normalizeChromeFlags(input.chromeFlags);
132
+ const maxWaitForLoadMs = normalizeMaxWaitForLoadMs(input.maxWaitForLoadMs);
133
+ insertPendingReport(db, {
134
+ reportId,
135
+ sessionId: input.sessionId,
136
+ requestedUrl: url,
137
+ createdAt,
138
+ formFactor,
139
+ categories,
140
+ maxWaitForLoadMs,
141
+ });
142
+ const startedAt = Date.now();
143
+ try {
144
+ const result = await runner.run({
145
+ url,
146
+ formFactor,
147
+ categories,
148
+ maxWaitForLoadMs,
149
+ chromeFlags,
150
+ });
151
+ const completedAt = Date.now();
152
+ const artifactPaths = persistLighthouseArtifacts(artifactDir, reportId, result);
153
+ const record = mapLighthouseResultToRecord({
154
+ reportId,
155
+ sessionId: input.sessionId,
156
+ requestedUrl: url,
157
+ formFactor,
158
+ createdAt,
159
+ completedAt,
160
+ durationMs: completedAt - startedAt,
161
+ result,
162
+ artifactPaths,
163
+ });
164
+ updateCompletedReport(db, record);
165
+ return record;
166
+ }
167
+ catch (error) {
168
+ const completedAt = Date.now();
169
+ const message = error instanceof Error ? error.message : String(error);
170
+ const failed = mapFailedLighthouseReport({
171
+ reportId,
172
+ sessionId: input.sessionId,
173
+ requestedUrl: url,
174
+ formFactor,
175
+ createdAt,
176
+ completedAt,
177
+ durationMs: completedAt - startedAt,
178
+ errorMessage: message,
179
+ });
180
+ updateCompletedReport(db, failed);
181
+ return failed;
182
+ }
183
+ }
184
+ export function listLighthouseReports(db, input) {
185
+ const limit = resolveLighthouseLimit(input.limit);
186
+ const offset = resolveLighthouseOffset(input.offset);
187
+ const clauses = [];
188
+ const params = [];
189
+ if (input.sessionId) {
190
+ clauses.push('session_id = ?');
191
+ params.push(input.sessionId);
192
+ }
193
+ if (typeof input.urlContains === 'string' && input.urlContains.trim().length > 0) {
194
+ clauses.push('requested_url LIKE ?');
195
+ params.push(`%${input.urlContains.trim()}%`);
196
+ }
197
+ if (input.status === 'succeeded' || input.status === 'failed') {
198
+ clauses.push('status = ?');
199
+ params.push(input.status);
200
+ }
201
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
202
+ const rows = db
203
+ .prepare(`
204
+ SELECT *
205
+ FROM lighthouse_reports
206
+ ${where}
207
+ ORDER BY created_at DESC
208
+ LIMIT ? OFFSET ?
209
+ `)
210
+ .all(...params, limit + 1, offset);
211
+ const page = rows.slice(0, limit);
212
+ return {
213
+ reports: page.map(mapReportRow),
214
+ pagination: {
215
+ limit,
216
+ offset,
217
+ returned: page.length,
218
+ hasMore: rows.length > limit,
219
+ },
220
+ };
221
+ }
222
+ export function getLighthouseReport(db, reportId) {
223
+ const row = db.prepare('SELECT * FROM lighthouse_reports WHERE report_id = ?').get(reportId);
224
+ if (!row) {
225
+ throw new Error(`Lighthouse report not found: ${reportId}`);
226
+ }
227
+ return mapReportRow(row);
228
+ }
229
+ export function getLighthouseReportAsset(db, input) {
230
+ const row = db.prepare('SELECT json_path, json_bytes, html_path, html_bytes FROM lighthouse_reports WHERE report_id = ?').get(input.reportId);
231
+ if (!row) {
232
+ throw new Error(`Lighthouse report not found: ${input.reportId}`);
233
+ }
234
+ const assetPath = input.asset === 'html' ? row.html_path : row.json_path;
235
+ const totalBytes = input.asset === 'html' ? row.html_bytes : row.json_bytes;
236
+ if (!assetPath || !existsSync(assetPath)) {
237
+ throw new Error(`Lighthouse ${input.asset} asset is not available for report: ${input.reportId}`);
238
+ }
239
+ const offset = resolveLighthouseOffset(input.offset);
240
+ const maxBytes = resolveLighthouseChunkBytes(input.maxBytes);
241
+ const buffer = readFileSync(assetPath);
242
+ const chunk = buffer.subarray(offset, Math.min(buffer.length, offset + maxBytes));
243
+ const encoding = input.encoding === 'raw' ? 'raw' : 'base64';
244
+ return {
245
+ reportId: input.reportId,
246
+ asset: input.asset,
247
+ encoding,
248
+ offset,
249
+ bytesReturned: chunk.length,
250
+ totalBytes: totalBytes ?? buffer.length,
251
+ hasMore: offset + chunk.length < buffer.length,
252
+ data: encoding === 'base64' ? chunk.toString('base64') : chunk.toString('utf8'),
253
+ };
254
+ }
255
+ export function planLighthouseFixes(db, input) {
256
+ const report = getLighthouseReport(db, input.reportId);
257
+ if (report.status !== 'succeeded' || !report.jsonPath) {
258
+ throw new Error(`Lighthouse report is not usable for fix planning: ${input.reportId}`);
259
+ }
260
+ const lhr = JSON.parse(readFileSync(report.jsonPath, 'utf8'));
261
+ const sourceContext = createLighthouseSourceContext({
262
+ projectRoot: input.projectRoot,
263
+ routePath: input.routePath,
264
+ sourceCandidateLimit: input.sourceCandidateLimit,
265
+ reportUrl: report.finalUrl ?? report.requestedUrl,
266
+ });
267
+ const minRank = priorityRank(input.minPriority ?? 'low');
268
+ const limit = resolveLighthouseFixItemLimit(input.limit);
269
+ const items = createLighthouseFixPlanItems(lhr, sourceContext)
270
+ .filter((item) => priorityRank(item.priority) <= minRank)
271
+ .slice(0, limit);
272
+ const priorityCounts = countPriorities(items);
273
+ const createdAt = Date.now();
274
+ const planId = `lhfix-${createdAt}-${randomUUID()}`;
275
+ const summary = {
276
+ reportId: input.reportId,
277
+ requestedUrl: report.requestedUrl,
278
+ finalUrl: report.finalUrl,
279
+ scores: report.scores,
280
+ generatedFromAuditCount: Object.keys(lhr.audits ?? {}).length,
281
+ returnedItemCount: items.length,
282
+ sourceContext: sourceContext
283
+ ? {
284
+ projectRoot: sourceContext.rootPath,
285
+ routePath: sourceContext.routePath,
286
+ scannedFileCount: sourceContext.scanFileCount,
287
+ scanTruncated: sourceContext.scanTruncated,
288
+ sourceCandidateLimit: sourceContext.sourceCandidateLimit,
289
+ }
290
+ : undefined,
291
+ };
292
+ db.prepare(`
293
+ INSERT INTO lighthouse_fix_plans (
294
+ plan_id, report_id, session_id, created_at, item_count, critical_count, high_count,
295
+ medium_count, low_count, summary_json, items_json
296
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
297
+ `).run(planId, input.reportId, report.sessionId ?? null, createdAt, items.length, priorityCounts.critical, priorityCounts.high, priorityCounts.medium, priorityCounts.low, JSON.stringify(summary), JSON.stringify(items));
298
+ return {
299
+ planId,
300
+ reportId: input.reportId,
301
+ sessionId: report.sessionId,
302
+ createdAt,
303
+ itemCount: items.length,
304
+ priorityCounts,
305
+ summary,
306
+ items,
307
+ };
308
+ }
309
+ export function createLighthouseFixPlanItems(lhr, sourceContext) {
310
+ const auditCategoryMap = buildAuditCategoryMap(lhr);
311
+ const audits = Object.values(lhr.audits ?? {});
312
+ const items = audits
313
+ .filter((audit) => isActionableAudit(audit))
314
+ .map((audit) => {
315
+ const details = audit.details ?? {};
316
+ const savingsMs = normalizeSavings(details.overallSavingsMs);
317
+ const savingsBytes = normalizeSavings(details.overallSavingsBytes);
318
+ const priority = classifyPriority(audit, savingsMs, savingsBytes);
319
+ const resourceUrls = extractAuditResourceUrls(audit);
320
+ const sourceCandidates = sourceContext ? findSourceCandidatesForAudit(audit, resourceUrls, sourceContext) : [];
321
+ return {
322
+ auditId: audit.id,
323
+ title: audit.title ?? audit.id,
324
+ priority,
325
+ categoryIds: auditCategoryMap.get(audit.id) ?? [],
326
+ score: audit.score,
327
+ displayValue: audit.displayValue,
328
+ estimatedSavingsMs: savingsMs,
329
+ estimatedSavingsBytes: savingsBytes,
330
+ rationale: buildFixRationale(audit, savingsMs, savingsBytes),
331
+ suggestedAction: buildSuggestedAction(audit.id),
332
+ resourceUrls,
333
+ sourceCandidates,
334
+ fixReadiness: classifyFixReadiness(sourceCandidates),
335
+ nextSteps: buildLighthouseNextSteps(audit.id, resourceUrls, sourceCandidates),
336
+ };
337
+ });
338
+ return items.sort((first, second) => {
339
+ const priorityDelta = priorityRank(first.priority) - priorityRank(second.priority);
340
+ if (priorityDelta !== 0) {
341
+ return priorityDelta;
342
+ }
343
+ return (second.estimatedSavingsMs ?? 0) - (first.estimatedSavingsMs ?? 0);
344
+ });
345
+ }
346
+ function createLighthouseSourceContext(input) {
347
+ if (typeof input.projectRoot !== 'string' || input.projectRoot.trim().length === 0) {
348
+ return undefined;
349
+ }
350
+ const rootPath = resolve(input.projectRoot.trim());
351
+ if (!existsSync(rootPath) || !statSync(rootPath).isDirectory()) {
352
+ throw new Error(`projectRoot must be an existing directory: ${input.projectRoot}`);
353
+ }
354
+ const scan = scanProjectFiles(rootPath);
355
+ return {
356
+ rootPath,
357
+ routePath: normalizeRoutePath(input.routePath) ?? normalizeRoutePathFromUrl(input.reportUrl),
358
+ sourceCandidateLimit: resolveLighthouseSourceCandidateLimit(input.sourceCandidateLimit),
359
+ files: scan.files,
360
+ scanFileCount: scan.scanned,
361
+ scanTruncated: scan.truncated,
362
+ };
363
+ }
364
+ function scanProjectFiles(rootPath) {
365
+ const files = [];
366
+ let scanned = 0;
367
+ let truncated = false;
368
+ function walk(currentPath, depth) {
369
+ if (depth > MAX_REPO_SCAN_DEPTH || scanned >= MAX_REPO_SCAN_FILES) {
370
+ truncated = true;
371
+ return;
372
+ }
373
+ let entries;
374
+ try {
375
+ entries = readdirSync(currentPath, { withFileTypes: true });
376
+ }
377
+ catch {
378
+ return;
379
+ }
380
+ for (const entry of entries) {
381
+ if (scanned >= MAX_REPO_SCAN_FILES) {
382
+ truncated = true;
383
+ return;
384
+ }
385
+ const entryPath = join(currentPath, entry.name);
386
+ if (entry.isDirectory()) {
387
+ if (!REPO_SCAN_IGNORED_DIRS.has(entry.name)) {
388
+ walk(entryPath, depth + 1);
389
+ }
390
+ continue;
391
+ }
392
+ if (!entry.isFile() || !isSourceCandidateFile(entry.name)) {
393
+ continue;
394
+ }
395
+ scanned += 1;
396
+ const extension = extname(entry.name).toLowerCase();
397
+ files.push({
398
+ path: entryPath,
399
+ relativePath: toPosixPath(relative(rootPath, entryPath)),
400
+ basename: entry.name.toLowerCase(),
401
+ extension,
402
+ normalizedStem: normalizeAssetStem(entry.name),
403
+ });
404
+ }
405
+ }
406
+ walk(rootPath, 0);
407
+ return { files, scanned, truncated };
408
+ }
409
+ function isSourceCandidateFile(fileName) {
410
+ return SOURCE_CANDIDATE_EXTENSIONS.has(extname(fileName).toLowerCase());
411
+ }
412
+ function extractAuditResourceUrls(audit) {
413
+ const urls = new Set();
414
+ collectResourceUrls(audit.details, urls, 0);
415
+ return Array.from(urls).slice(0, 25);
416
+ }
417
+ function collectResourceUrls(value, urls, depth) {
418
+ if (depth > 8 || urls.size >= 25 || value === null || value === undefined) {
419
+ return;
420
+ }
421
+ if (typeof value === 'string') {
422
+ const url = normalizeResourceUrl(value);
423
+ if (url) {
424
+ urls.add(url);
425
+ }
426
+ return;
427
+ }
428
+ if (Array.isArray(value)) {
429
+ for (const entry of value) {
430
+ collectResourceUrls(entry, urls, depth + 1);
431
+ }
432
+ return;
433
+ }
434
+ if (typeof value !== 'object') {
435
+ return;
436
+ }
437
+ for (const entry of Object.values(value)) {
438
+ collectResourceUrls(entry, urls, depth + 1);
439
+ }
440
+ }
441
+ function normalizeResourceUrl(value) {
442
+ const trimmed = value.trim();
443
+ if (trimmed.length > 2_048) {
444
+ return undefined;
445
+ }
446
+ try {
447
+ const parsed = new URL(trimmed);
448
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.toString() : undefined;
449
+ }
450
+ catch {
451
+ return trimmed.startsWith('/') && !trimmed.startsWith('//') ? trimmed : undefined;
452
+ }
453
+ }
454
+ function findSourceCandidatesForAudit(audit, resourceUrls, context) {
455
+ const candidates = new Map();
456
+ for (const resourceUrl of resourceUrls) {
457
+ for (const candidate of findResourceSourceCandidates(resourceUrl, context)) {
458
+ candidates.set(`${candidate.matchType}:${candidate.relativePath}:${candidate.resourceUrl ?? ''}`, candidate);
459
+ if (candidates.size >= context.sourceCandidateLimit) {
460
+ return Array.from(candidates.values());
461
+ }
462
+ }
463
+ }
464
+ if (auditCanUseRouteCandidates(audit.id)) {
465
+ for (const candidate of findRouteSourceCandidates(context)) {
466
+ candidates.set(`${candidate.matchType}:${candidate.relativePath}`, candidate);
467
+ if (candidates.size >= context.sourceCandidateLimit) {
468
+ return Array.from(candidates.values());
469
+ }
470
+ }
471
+ }
472
+ return Array.from(candidates.values());
473
+ }
474
+ function findResourceSourceCandidates(resourceUrl, context) {
475
+ const resourcePath = extractResourcePath(resourceUrl);
476
+ if (!resourcePath) {
477
+ return [];
478
+ }
479
+ const resourceFileName = basename(resourcePath).toLowerCase();
480
+ if (!resourceFileName) {
481
+ return [];
482
+ }
483
+ const normalizedResourceStem = normalizeAssetStem(resourceFileName);
484
+ const resourceExtension = extname(resourceFileName).toLowerCase();
485
+ const pathSuffixes = [
486
+ stripLeadingSlash(resourcePath),
487
+ `public/${stripLeadingSlash(resourcePath)}`,
488
+ ].map((entry) => entry.toLowerCase());
489
+ const candidates = [];
490
+ for (const file of context.files) {
491
+ const lowerRelativePath = file.relativePath.toLowerCase();
492
+ const exactPathMatch = pathSuffixes.some((suffix) => lowerRelativePath.endsWith(suffix));
493
+ if (exactPathMatch) {
494
+ candidates.push({
495
+ path: file.path,
496
+ relativePath: file.relativePath,
497
+ matchType: 'resource-path',
498
+ resourceUrl,
499
+ reason: `File path matches Lighthouse resource ${resourcePath}`,
500
+ });
501
+ continue;
502
+ }
503
+ if (file.extension === resourceExtension &&
504
+ (file.basename === resourceFileName || (normalizedResourceStem.length > 0 && file.normalizedStem === normalizedResourceStem))) {
505
+ candidates.push({
506
+ path: file.path,
507
+ relativePath: file.relativePath,
508
+ matchType: 'resource-name',
509
+ resourceUrl,
510
+ reason: `File name matches Lighthouse resource ${resourceFileName}`,
511
+ });
512
+ }
513
+ }
514
+ return candidates.slice(0, context.sourceCandidateLimit);
515
+ }
516
+ function findRouteSourceCandidates(context) {
517
+ const routePath = context.routePath;
518
+ if (!routePath) {
519
+ return [];
520
+ }
521
+ const routeSegments = routePath === '/' ? [] : routePath.split('/').filter(Boolean);
522
+ const routePart = routeSegments.join('/');
523
+ const routePatterns = routeSegments.length === 0
524
+ ? [
525
+ 'app/page',
526
+ 'src/app/page',
527
+ 'pages/index',
528
+ 'src/pages/index',
529
+ ]
530
+ : [
531
+ `app/${routePart}/page`,
532
+ `src/app/${routePart}/page`,
533
+ `pages/${routePart}`,
534
+ `src/pages/${routePart}`,
535
+ `pages/${routePart}/index`,
536
+ `src/pages/${routePart}/index`,
537
+ ];
538
+ const layoutPatterns = routeSegments.length === 0
539
+ ? ['app/layout', 'src/app/layout']
540
+ : [
541
+ `app/${routePart}/layout`,
542
+ `src/app/${routePart}/layout`,
543
+ ];
544
+ const candidates = [];
545
+ for (const file of context.files) {
546
+ const withoutExtension = file.relativePath.slice(0, -file.extension.length).toLowerCase();
547
+ if (routePatterns.some((pattern) => withoutExtension.endsWith(pattern))) {
548
+ candidates.push({
549
+ path: file.path,
550
+ relativePath: file.relativePath,
551
+ matchType: 'route-entry',
552
+ reason: `Route entry candidate for ${routePath}`,
553
+ });
554
+ continue;
555
+ }
556
+ if (layoutPatterns.some((pattern) => withoutExtension.endsWith(pattern))) {
557
+ candidates.push({
558
+ path: file.path,
559
+ relativePath: file.relativePath,
560
+ matchType: 'route-layout',
561
+ reason: `Route layout candidate for ${routePath}`,
562
+ });
563
+ }
564
+ }
565
+ return candidates.slice(0, context.sourceCandidateLimit);
566
+ }
567
+ function auditCanUseRouteCandidates(auditId) {
568
+ return new Set([
569
+ 'cumulative-layout-shift',
570
+ 'first-contentful-paint',
571
+ 'interactive',
572
+ 'largest-contentful-paint',
573
+ 'render-blocking-resources',
574
+ 'server-response-time',
575
+ 'speed-index',
576
+ 'total-blocking-time',
577
+ 'unused-css-rules',
578
+ 'unused-javascript',
579
+ ]).has(auditId);
580
+ }
581
+ function classifyFixReadiness(candidates) {
582
+ if (candidates.some((candidate) => candidate.matchType === 'resource-path' || candidate.matchType === 'resource-name')) {
583
+ return 'source-located';
584
+ }
585
+ if (candidates.some((candidate) => candidate.matchType === 'route-entry' || candidate.matchType === 'route-layout')) {
586
+ return 'route-located';
587
+ }
588
+ return 'needs-investigation';
589
+ }
590
+ function buildLighthouseNextSteps(auditId, resourceUrls, sourceCandidates) {
591
+ const steps = [];
592
+ const hasResources = resourceUrls.length > 0;
593
+ const hasCandidates = sourceCandidates.length > 0;
594
+ if (hasCandidates) {
595
+ steps.push('Review the listed sourceCandidates first; they are the most likely files to edit for this audit.');
596
+ }
597
+ else if (hasResources) {
598
+ steps.push('Inspect the listed resourceUrls and map any bundled or hashed assets back through source maps or the app bundler.');
599
+ }
600
+ else {
601
+ steps.push('Use the persisted JSON/HTML report to inspect the audit details before editing source.');
602
+ }
603
+ const auditSteps = {
604
+ 'render-blocking-resources': 'Move non-critical CSS/JS off the initial render path, inline critical CSS only when justified, or add preload/defer where the framework supports it.',
605
+ 'unused-javascript': 'Trace the listed scripts to their route imports and split or lazy-load code that is not needed for the initial interaction.',
606
+ 'unused-css-rules': 'Move broad CSS into route/component styles or remove selectors that are not used by the audited page.',
607
+ 'modern-image-formats': 'Convert located image assets to AVIF/WebP and update references while keeping fallbacks where needed.',
608
+ 'uses-optimized-images': 'Resize or recompress located images to match rendered dimensions and quality requirements.',
609
+ 'largest-contentful-paint': 'Find the LCP element or resource, prioritize its load, and reduce server/render work that delays it.',
610
+ 'total-blocking-time': 'Break long startup tasks, reduce hydration work, or defer non-critical scripts from the audited route.',
611
+ 'cumulative-layout-shift': 'Reserve image/embed/font space with dimensions, aspect-ratio, or stable fallback layout.',
612
+ 'server-response-time': 'Inspect the route handler, data fetching, cache policy, and deployment path for the audited URL.',
613
+ };
614
+ steps.push(auditSteps[auditId] ?? 'Apply the remediation described by Lighthouse, then run a new report to compare scores and metrics.');
615
+ steps.push('After editing, run run_lighthouse_report again for the same URL and compare the new plan against this report.');
616
+ return steps;
617
+ }
618
+ function normalizeRoutePath(value) {
619
+ if (typeof value !== 'string' || value.trim().length === 0) {
620
+ return undefined;
621
+ }
622
+ const trimmed = value.trim();
623
+ const withoutQuery = trimmed.split(/[?#]/, 1)[0] ?? '';
624
+ const route = withoutQuery.startsWith('/') ? withoutQuery : `/${withoutQuery}`;
625
+ return route.replace(/\/+/g, '/').replace(/\/$/, '') || '/';
626
+ }
627
+ function normalizeRoutePathFromUrl(value) {
628
+ if (typeof value !== 'string' || value.trim().length === 0) {
629
+ return undefined;
630
+ }
631
+ try {
632
+ return normalizeRoutePath(new URL(value).pathname);
633
+ }
634
+ catch {
635
+ return normalizeRoutePath(value);
636
+ }
637
+ }
638
+ function extractResourcePath(resourceUrl) {
639
+ try {
640
+ return decodeURIComponent(new URL(resourceUrl).pathname);
641
+ }
642
+ catch {
643
+ return resourceUrl.startsWith('/') ? decodeURIComponent(resourceUrl.split(/[?#]/, 1)[0] ?? '') : undefined;
644
+ }
645
+ }
646
+ function normalizeAssetStem(fileName) {
647
+ const extension = extname(fileName);
648
+ const name = extension.length > 0 ? basename(fileName, extension) : basename(fileName);
649
+ const normalized = name.toLowerCase();
650
+ const parts = normalized.split(/[._-]/);
651
+ const lastPart = parts.at(-1);
652
+ if (lastPart && /^[a-z0-9]{8,}$/.test(lastPart) && parts.length > 1) {
653
+ return parts.slice(0, -1).join('-');
654
+ }
655
+ return normalized;
656
+ }
657
+ function stripLeadingSlash(value) {
658
+ return value.replace(/^\/+/, '');
659
+ }
660
+ function toPosixPath(value) {
661
+ return value.split(sep).join('/');
662
+ }
663
+ function createDefaultLighthouseRunner() {
664
+ return {
665
+ async run(input) {
666
+ const [{ default: lighthouse, desktopConfig }, { launch }] = await Promise.all([
667
+ import('lighthouse'),
668
+ import('chrome-launcher'),
669
+ ]);
670
+ const chrome = await launch({
671
+ chromeFlags: [
672
+ '--headless=new',
673
+ '--disable-gpu',
674
+ ...input.chromeFlags,
675
+ ],
676
+ });
677
+ try {
678
+ const result = await lighthouse(input.url, {
679
+ port: chrome.port,
680
+ output: ['json', 'html'],
681
+ logLevel: 'error',
682
+ onlyCategories: input.categories,
683
+ formFactor: input.formFactor,
684
+ maxWaitForLoad: input.maxWaitForLoadMs,
685
+ channel: 'browser-debug-mcp-bridge',
686
+ }, input.formFactor === 'desktop' ? desktopConfig : undefined);
687
+ if (!result) {
688
+ throw new Error('Lighthouse did not return a report.');
689
+ }
690
+ return result;
691
+ }
692
+ finally {
693
+ await chrome.kill();
694
+ }
695
+ },
696
+ };
697
+ }
698
+ function normalizeHttpUrl(value, fieldName) {
699
+ try {
700
+ const parsed = new URL(value.trim());
701
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
702
+ throw new Error(`${fieldName} must be an http(s) URL`);
703
+ }
704
+ return parsed.toString();
705
+ }
706
+ catch (error) {
707
+ if (error instanceof Error && error.message.includes('http(s) URL')) {
708
+ throw error;
709
+ }
710
+ throw new Error(`${fieldName} must be a valid absolute URL`);
711
+ }
712
+ }
713
+ function normalizeChromeFlags(value) {
714
+ if (!Array.isArray(value)) {
715
+ return [];
716
+ }
717
+ return value
718
+ .filter((entry) => typeof entry === 'string')
719
+ .map((entry) => entry.trim())
720
+ .filter((entry) => entry.length > 0)
721
+ .slice(0, 20);
722
+ }
723
+ function normalizeMaxWaitForLoadMs(value) {
724
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
725
+ return undefined;
726
+ }
727
+ return Math.min(Math.max(1_000, Math.floor(value)), 120_000);
728
+ }
729
+ function insertPendingReport(db, input) {
730
+ db.prepare(`
731
+ INSERT INTO lighthouse_reports (
732
+ report_id, session_id, requested_url, status, created_at, form_factor,
733
+ categories_json, metrics_json, scores_json, run_warnings_json, config_json
734
+ ) VALUES (?, ?, ?, 'failed', ?, ?, ?, ?, ?, ?, ?)
735
+ `).run(input.reportId, input.sessionId ?? null, input.requestedUrl, input.createdAt, input.formFactor, JSON.stringify({ requested: input.categories }), '{}', '{}', '[]', JSON.stringify({ categories: input.categories, maxWaitForLoadMs: input.maxWaitForLoadMs }));
736
+ }
737
+ function updateCompletedReport(db, record) {
738
+ db.prepare(`
739
+ UPDATE lighthouse_reports
740
+ SET
741
+ final_url = ?,
742
+ status = ?,
743
+ completed_at = ?,
744
+ duration_ms = ?,
745
+ lighthouse_version = ?,
746
+ user_agent = ?,
747
+ categories_json = ?,
748
+ metrics_json = ?,
749
+ scores_json = ?,
750
+ score_performance = ?,
751
+ score_accessibility = ?,
752
+ score_best_practices = ?,
753
+ score_seo = ?,
754
+ score_pwa = ?,
755
+ json_path = ?,
756
+ json_bytes = ?,
757
+ html_path = ?,
758
+ html_bytes = ?,
759
+ run_warnings_json = ?,
760
+ runtime_error_json = ?,
761
+ error_message = ?
762
+ WHERE report_id = ?
763
+ `).run(record.finalUrl ?? null, record.status, record.completedAt ?? null, record.durationMs ?? null, record.lighthouseVersion ?? null, typeof record.metrics.userAgent === 'string' ? record.metrics.userAgent : null, JSON.stringify(record.categories), JSON.stringify(record.metrics), JSON.stringify(record.scores), record.scores.performance ?? null, record.scores.accessibility ?? null, record.scores['best-practices'] ?? null, record.scores.seo ?? null, record.scores.pwa ?? null, record.jsonPath ?? null, record.jsonBytes ?? null, record.htmlPath ?? null, record.htmlBytes ?? null, JSON.stringify(record.runWarnings), record.runtimeError ? JSON.stringify(record.runtimeError) : null, record.errorMessage ?? null, record.reportId);
764
+ }
765
+ function persistLighthouseArtifacts(artifactDir, reportId, result) {
766
+ const safeReportId = reportId.replace(/[^a-zA-Z0-9._-]/g, '_');
767
+ mkdirSync(artifactDir, { recursive: true });
768
+ const jsonPath = join(artifactDir, `${safeReportId}.json`);
769
+ const htmlPath = join(artifactDir, `${safeReportId}.html`);
770
+ const reports = Array.isArray(result.report) ? result.report : [result.report];
771
+ const jsonReport = JSON.stringify(result.lhr, null, 2);
772
+ const htmlReport = reports.find((entry) => entry.trim().startsWith('<')) ?? '';
773
+ writeFileSync(jsonPath, jsonReport, 'utf8');
774
+ writeFileSync(htmlPath, htmlReport, 'utf8');
775
+ return {
776
+ jsonPath,
777
+ jsonBytes: statSync(jsonPath).size,
778
+ htmlPath,
779
+ htmlBytes: statSync(htmlPath).size,
780
+ };
781
+ }
782
+ function mapLighthouseResultToRecord(input) {
783
+ const lhr = input.result.lhr;
784
+ return {
785
+ reportId: input.reportId,
786
+ sessionId: input.sessionId,
787
+ requestedUrl: lhr.requestedUrl ?? input.requestedUrl,
788
+ finalUrl: lhr.finalDisplayedUrl ?? lhr.finalUrl,
789
+ status: lhr.runtimeError ? 'failed' : 'succeeded',
790
+ createdAt: input.createdAt,
791
+ completedAt: input.completedAt,
792
+ durationMs: input.durationMs,
793
+ lighthouseVersion: lhr.lighthouseVersion,
794
+ formFactor: input.formFactor,
795
+ categories: summarizeCategories(lhr),
796
+ metrics: summarizeMetrics(lhr),
797
+ scores: summarizeScores(lhr),
798
+ runWarnings: Array.isArray(lhr.runWarnings) ? lhr.runWarnings : [],
799
+ runtimeError: lhr.runtimeError,
800
+ jsonPath: input.artifactPaths.jsonPath,
801
+ jsonBytes: input.artifactPaths.jsonBytes,
802
+ htmlPath: input.artifactPaths.htmlPath,
803
+ htmlBytes: input.artifactPaths.htmlBytes,
804
+ errorMessage: lhr.runtimeError?.message,
805
+ };
806
+ }
807
+ function mapFailedLighthouseReport(input) {
808
+ return {
809
+ reportId: input.reportId,
810
+ sessionId: input.sessionId,
811
+ requestedUrl: input.requestedUrl,
812
+ status: 'failed',
813
+ createdAt: input.createdAt,
814
+ completedAt: input.completedAt,
815
+ durationMs: input.durationMs,
816
+ formFactor: input.formFactor,
817
+ categories: {},
818
+ metrics: {},
819
+ scores: {},
820
+ runWarnings: [],
821
+ errorMessage: input.errorMessage,
822
+ };
823
+ }
824
+ function summarizeCategories(lhr) {
825
+ return Object.fromEntries(Object.entries(lhr.categories ?? {}).map(([id, category]) => [
826
+ id,
827
+ {
828
+ title: category.title ?? id,
829
+ score: category.score,
830
+ },
831
+ ]));
832
+ }
833
+ function summarizeScores(lhr) {
834
+ return Object.fromEntries(Object.entries(lhr.categories ?? {}).map(([id, category]) => [id, category.score]));
835
+ }
836
+ function summarizeMetrics(lhr) {
837
+ const metricIds = [
838
+ 'first-contentful-paint',
839
+ 'largest-contentful-paint',
840
+ 'total-blocking-time',
841
+ 'cumulative-layout-shift',
842
+ 'speed-index',
843
+ 'interactive',
844
+ ];
845
+ const metrics = {
846
+ fetchTime: lhr.fetchTime,
847
+ userAgent: lhr.userAgent,
848
+ environment: lhr.environment,
849
+ };
850
+ for (const id of metricIds) {
851
+ const audit = lhr.audits?.[id];
852
+ if (audit) {
853
+ metrics[id] = {
854
+ score: audit.score,
855
+ displayValue: audit.displayValue,
856
+ };
857
+ }
858
+ }
859
+ return metrics;
860
+ }
861
+ function mapReportRow(row) {
862
+ return {
863
+ reportId: String(row.report_id),
864
+ sessionId: typeof row.session_id === 'string' ? row.session_id : undefined,
865
+ requestedUrl: String(row.requested_url),
866
+ finalUrl: typeof row.final_url === 'string' ? row.final_url : undefined,
867
+ status: row.status === 'succeeded' ? 'succeeded' : 'failed',
868
+ createdAt: Number(row.created_at),
869
+ completedAt: typeof row.completed_at === 'number' ? row.completed_at : undefined,
870
+ durationMs: typeof row.duration_ms === 'number' ? row.duration_ms : undefined,
871
+ lighthouseVersion: typeof row.lighthouse_version === 'string' ? row.lighthouse_version : undefined,
872
+ formFactor: row.form_factor === 'desktop' ? 'desktop' : 'mobile',
873
+ categories: parseJsonRecord(row.categories_json),
874
+ metrics: parseJsonRecord(row.metrics_json),
875
+ scores: parseJsonRecord(row.scores_json),
876
+ runWarnings: parseJsonArray(row.run_warnings_json),
877
+ runtimeError: parseJsonRecordOrUndefined(row.runtime_error_json),
878
+ jsonPath: typeof row.json_path === 'string' ? row.json_path : undefined,
879
+ jsonBytes: typeof row.json_bytes === 'number' ? row.json_bytes : undefined,
880
+ htmlPath: typeof row.html_path === 'string' ? row.html_path : undefined,
881
+ htmlBytes: typeof row.html_bytes === 'number' ? row.html_bytes : undefined,
882
+ errorMessage: typeof row.error_message === 'string' ? row.error_message : undefined,
883
+ };
884
+ }
885
+ function parseJsonRecord(value) {
886
+ if (typeof value !== 'string') {
887
+ return {};
888
+ }
889
+ try {
890
+ const parsed = JSON.parse(value);
891
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
892
+ }
893
+ catch {
894
+ return {};
895
+ }
896
+ }
897
+ function parseJsonRecordOrUndefined(value) {
898
+ const parsed = parseJsonRecord(value);
899
+ return Object.keys(parsed).length > 0 ? parsed : undefined;
900
+ }
901
+ function parseJsonArray(value) {
902
+ if (typeof value !== 'string') {
903
+ return [];
904
+ }
905
+ try {
906
+ const parsed = JSON.parse(value);
907
+ return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === 'string') : [];
908
+ }
909
+ catch {
910
+ return [];
911
+ }
912
+ }
913
+ function buildAuditCategoryMap(lhr) {
914
+ const map = new Map();
915
+ for (const [categoryId, category] of Object.entries(lhr.categories ?? {})) {
916
+ for (const ref of category.auditRefs ?? []) {
917
+ const categories = map.get(ref.id) ?? [];
918
+ categories.push(categoryId);
919
+ map.set(ref.id, categories);
920
+ }
921
+ }
922
+ return map;
923
+ }
924
+ function isActionableAudit(audit) {
925
+ if (!audit.id || audit.scoreDisplayMode === 'notApplicable' || audit.scoreDisplayMode === 'manual') {
926
+ return false;
927
+ }
928
+ if (audit.details?.type === 'opportunity') {
929
+ return true;
930
+ }
931
+ return typeof audit.score === 'number' && audit.score < 1;
932
+ }
933
+ function classifyPriority(audit, savingsMs, savingsBytes) {
934
+ if ((savingsMs ?? 0) >= 1_000 || (savingsBytes ?? 0) >= 500_000 || (typeof audit.score === 'number' && audit.score <= 0.25)) {
935
+ return 'critical';
936
+ }
937
+ if ((savingsMs ?? 0) >= 500 || (savingsBytes ?? 0) >= 150_000 || (typeof audit.score === 'number' && audit.score <= 0.5)) {
938
+ return 'high';
939
+ }
940
+ if ((savingsMs ?? 0) >= 100 || (savingsBytes ?? 0) >= 50_000 || (typeof audit.score === 'number' && audit.score < 0.9)) {
941
+ return 'medium';
942
+ }
943
+ return 'low';
944
+ }
945
+ function priorityRank(priority) {
946
+ if (priority === 'critical')
947
+ return 1;
948
+ if (priority === 'high')
949
+ return 2;
950
+ if (priority === 'medium')
951
+ return 3;
952
+ return 4;
953
+ }
954
+ function normalizeSavings(value) {
955
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.round(value) : undefined;
956
+ }
957
+ function buildFixRationale(audit, savingsMs, savingsBytes) {
958
+ const savings = [];
959
+ if (savingsMs) {
960
+ savings.push(`${savingsMs} ms estimated time savings`);
961
+ }
962
+ if (savingsBytes) {
963
+ savings.push(`${savingsBytes} bytes estimated transfer savings`);
964
+ }
965
+ if (savings.length > 0) {
966
+ return savings.join('; ');
967
+ }
968
+ if (typeof audit.score === 'number') {
969
+ return `Audit score is ${audit.score}`;
970
+ }
971
+ return 'Lighthouse marked this audit as needing attention';
972
+ }
973
+ function buildSuggestedAction(auditId) {
974
+ const suggestions = {
975
+ 'render-blocking-resources': 'Defer or inline critical CSS/JS and remove render-blocking requests from the initial path.',
976
+ 'unused-javascript': 'Reduce, split, or lazy-load unused JavaScript shipped during initial page load.',
977
+ 'unused-css-rules': 'Remove unused CSS and load route-specific styles only where needed.',
978
+ 'modern-image-formats': 'Serve images in modern formats such as AVIF or WebP where browser support allows.',
979
+ 'uses-optimized-images': 'Resize and compress image assets to match rendered dimensions and quality needs.',
980
+ 'largest-contentful-paint': 'Identify the LCP element and prioritize its resource, server response, and render path.',
981
+ 'total-blocking-time': 'Break up long main-thread work and reduce expensive JavaScript execution during startup.',
982
+ 'cumulative-layout-shift': 'Reserve dimensions for images/embeds and avoid late layout-affecting DOM or font changes.',
983
+ 'server-response-time': 'Reduce backend latency, caching misses, and document request processing time.',
984
+ };
985
+ return suggestions[auditId] ?? 'Inspect the Lighthouse audit details and apply the recommended remediation for this audit.';
986
+ }
987
+ function countPriorities(items) {
988
+ return {
989
+ critical: items.filter((item) => item.priority === 'critical').length,
990
+ high: items.filter((item) => item.priority === 'high').length,
991
+ medium: items.filter((item) => item.priority === 'medium').length,
992
+ low: items.filter((item) => item.priority === 'low').length,
993
+ };
994
+ }
995
+ export function ensureArtifactParent(path) {
996
+ mkdirSync(dirname(path), { recursive: true });
997
+ }
998
+ export function isSupportedLighthouseAsset(value) {
999
+ return typeof value === 'string' && LIGHTHOUSE_ASSETS.has(value);
1000
+ }
1001
+ //# sourceMappingURL=lighthouse-report.js.map