@ulpi/browse 1.4.1 → 1.4.4

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 (3) hide show
  1. package/README.md +5 -1
  2. package/dist/browse.cjs +663 -104
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -327,6 +327,10 @@ browse perf-audit [url] # Full performance audit with actionabl
327
327
  browse perf-audit [url] --no-coverage # Skip JS/CSS coverage (faster)
328
328
  browse perf-audit [url] --no-detect # Skip stack detection
329
329
  browse perf-audit [url] --json # Structured JSON output
330
+ browse perf-audit save [name] # Save audit report for later comparison
331
+ browse perf-audit compare <base> [curr] # Compare saved baseline vs current or saved audit
332
+ browse perf-audit list # List saved audit reports
333
+ browse perf-audit delete <name> # Delete a saved audit
330
334
  browse detect # Tech stack fingerprint (frameworks, SaaS, CDN, infra)
331
335
  browse coverage start # Start JS/CSS code coverage collection
332
336
  browse coverage stop # Stop and report per-file used/unused bytes
@@ -631,7 +635,7 @@ Use browse to test the login flow. Run browse --help to see available commands.
631
635
 
632
636
  ## MCP Server Mode
633
637
 
634
- Run browse as an [MCP](https://modelcontextprotocol.io/) server for editors that support the Model Context Protocol.
638
+ Run browse as an [MCP](https://modelcontextprotocol.io/) server for editors that support the Model Context Protocol. All 99 CLI commands are available as MCP tools, including `perf-audit`, `detect`, `coverage`, and `initscript`.
635
639
 
636
640
  ```bash
637
641
  browse --mcp
package/dist/browse.cjs CHANGED
@@ -828,7 +828,8 @@ var require_package = __commonJS({
828
828
  "package.json"(exports2, module2) {
829
829
  module2.exports = {
830
830
  name: "@ulpi/browse",
831
- version: "1.4.1",
831
+ version: "1.4.3",
832
+ homepage: "https://browse.ulpi.io",
832
833
  repository: {
833
834
  type: "git",
834
835
  url: "https://github.com/ulpi-io/browse"
@@ -1372,9 +1373,9 @@ var init_browser_manager = __esm({
1372
1373
  });
1373
1374
  } catch (err) {
1374
1375
  if (err.message?.includes("Failed to launch") || err.message?.includes("Target closed")) {
1375
- const fs18 = await import("fs");
1376
+ const fs19 = await import("fs");
1376
1377
  console.error(`[browse] Profile directory corrupted, recreating: ${profileDir}`);
1377
- fs18.rmSync(profileDir, { recursive: true, force: true });
1378
+ fs19.rmSync(profileDir, { recursive: true, force: true });
1378
1379
  context = await import_playwright.chromium.launchPersistentContext(profileDir, {
1379
1380
  headless: process.env.BROWSE_HEADED !== "1",
1380
1381
  viewport: { width: 1920, height: 1080 }
@@ -2161,8 +2162,8 @@ var init_browser_manager = __esm({
2161
2162
  // ─── Video Recording ──────────────────────────────────────
2162
2163
  async startVideoRecording(dir) {
2163
2164
  if (this.videoRecording) throw new Error("Video recording already active");
2164
- const fs18 = await import("fs");
2165
- fs18.mkdirSync(dir, { recursive: true });
2165
+ const fs19 = await import("fs");
2166
+ fs19.mkdirSync(dir, { recursive: true });
2166
2167
  this.videoRecording = { dir, startedAt: Date.now() };
2167
2168
  const viewport = this.currentDevice?.viewport || { width: 1920, height: 1080 };
2168
2169
  await this.recreateContext({
@@ -4257,11 +4258,11 @@ var init_lib = __esm({
4257
4258
  }
4258
4259
  }
4259
4260
  },
4260
- addToPath: function addToPath(path15, added, removed, oldPosInc, options) {
4261
- var last = path15.lastComponent;
4261
+ addToPath: function addToPath(path16, added, removed, oldPosInc, options) {
4262
+ var last = path16.lastComponent;
4262
4263
  if (last && !options.oneChangePerToken && last.added === added && last.removed === removed) {
4263
4264
  return {
4264
- oldPos: path15.oldPos + oldPosInc,
4265
+ oldPos: path16.oldPos + oldPosInc,
4265
4266
  lastComponent: {
4266
4267
  count: last.count + 1,
4267
4268
  added,
@@ -4271,7 +4272,7 @@ var init_lib = __esm({
4271
4272
  };
4272
4273
  } else {
4273
4274
  return {
4274
- oldPos: path15.oldPos + oldPosInc,
4275
+ oldPos: path16.oldPos + oldPosInc,
4275
4276
  lastComponent: {
4276
4277
  count: 1,
4277
4278
  added,
@@ -5474,15 +5475,15 @@ async function getSuspense(bm, page) {
5474
5475
  if (!roots || roots.size === 0) return "No fiber roots found";
5475
5476
  const root = roots.values().next().value;
5476
5477
  const boundaries = [];
5477
- const walk = (fiber, path15) => {
5478
+ const walk = (fiber, path16) => {
5478
5479
  if (!fiber) return;
5479
5480
  if (fiber.tag === 13) {
5480
5481
  const resolved = fiber.memoizedState === null;
5481
- const parent = path15.length > 0 ? path15[path15.length - 1] : "root";
5482
+ const parent = path16.length > 0 ? path16[path16.length - 1] : "root";
5482
5483
  boundaries.push(`Suspense in ${parent} \u2014 ${resolved ? "resolved (children visible)" : "pending (showing fallback)"}`);
5483
5484
  }
5484
5485
  const name = fiber.type?.displayName || fiber.type?.name || null;
5485
- const newPath = name ? [...path15, name] : path15;
5486
+ const newPath = name ? [...path16, name] : path16;
5486
5487
  let child = fiber.child;
5487
5488
  while (child) {
5488
5489
  walk(child, newPath);
@@ -12649,6 +12650,7 @@ var init_recommendations = __esm({
12649
12650
  // src/perf-audit/formatter.ts
12650
12651
  var formatter_exports = {};
12651
12652
  __export(formatter_exports, {
12653
+ formatAuditDiff: () => formatAuditDiff,
12652
12654
  formatPerfAudit: () => formatPerfAudit
12653
12655
  });
12654
12656
  function ratingFor(metric, value) {
@@ -13062,7 +13064,84 @@ function formatTopRecommendations(report) {
13062
13064
  }
13063
13065
  return lines.join("\n");
13064
13066
  }
13065
- var THRESHOLDS;
13067
+ function formatAuditDiff(diff2, json = false) {
13068
+ if (json) return JSON.stringify(diff2, null, 2);
13069
+ return formatTextDiff(diff2);
13070
+ }
13071
+ function formatTextDiff(diff2) {
13072
+ const sections = [];
13073
+ if (diff2.webVitals.length > 0) {
13074
+ const lines = ["Web Vitals:"];
13075
+ lines.push(
13076
+ ` ${padRight("Metric", 10)} ${padLeft("Baseline", 10)} ${padLeft("Current", 10)} ${padLeft("Delta", 10)} Verdict`
13077
+ );
13078
+ for (const d of diff2.webVitals) {
13079
+ const baseStr = d.baseline != null ? fmtVal(d.metric, d.baseline) : "---";
13080
+ const currStr = d.current != null ? fmtVal(d.metric, d.current) : "---";
13081
+ const deltaStr = d.deltaMs != null ? fmtDelta(d.metric, d.deltaMs) : "---";
13082
+ const symbol = VERDICT_SYMBOLS[d.verdict];
13083
+ lines.push(
13084
+ ` ${padRight(d.metric.toUpperCase(), 10)} ${padLeft(baseStr, 10)} ${padLeft(currStr, 10)} ${padLeft(deltaStr, 10)} ${symbol} ${d.verdict}`
13085
+ );
13086
+ }
13087
+ sections.push(lines.join("\n"));
13088
+ }
13089
+ if (diff2.resourceSize.length > 0) {
13090
+ const lines = ["Resources:"];
13091
+ lines.push(
13092
+ ` ${padRight("Type", 10)} ${padLeft("Baseline", 10)} ${padLeft("Current", 10)} ${padLeft("Delta", 10)} ${padLeft("Change", 8)} Verdict`
13093
+ );
13094
+ for (const d of diff2.resourceSize) {
13095
+ const baseStr = d.baseline != null ? formatBytes2(d.baseline) : "---";
13096
+ const currStr = d.current != null ? formatBytes2(d.current) : "---";
13097
+ const deltaStr = d.deltaMs != null ? fmtBytesDelta(d.deltaMs) : "---";
13098
+ const pctStr = d.deltaPct != null ? `${d.deltaPct > 0 ? "+" : ""}${d.deltaPct.toFixed(1)}%` : "---";
13099
+ const symbol = VERDICT_SYMBOLS[d.verdict];
13100
+ lines.push(
13101
+ ` ${padRight(d.metric, 10)} ${padLeft(baseStr, 10)} ${padLeft(currStr, 10)} ${padLeft(deltaStr, 10)} ${padLeft(pctStr, 8)} ${symbol} ${d.verdict}`
13102
+ );
13103
+ }
13104
+ sections.push(lines.join("\n"));
13105
+ }
13106
+ const nonTrivialCoverage = diff2.coverage.filter(
13107
+ (d) => d.verdict !== "unchanged" || d.baseline != null || d.current != null
13108
+ );
13109
+ if (nonTrivialCoverage.length > 0) {
13110
+ const lines = ["Coverage:"];
13111
+ lines.push(
13112
+ ` ${padRight("Metric", 16)} ${padLeft("Baseline", 10)} ${padLeft("Current", 10)} ${padLeft("Delta", 10)} Verdict`
13113
+ );
13114
+ for (const d of nonTrivialCoverage) {
13115
+ const baseStr = d.baseline != null ? `${d.baseline.toFixed(1)}%` : "---";
13116
+ const currStr = d.current != null ? `${d.current.toFixed(1)}%` : "---";
13117
+ const deltaStr = d.deltaMs != null ? `${d.deltaMs > 0 ? "+" : ""}${d.deltaMs.toFixed(1)}pp` : "---";
13118
+ const symbol = VERDICT_SYMBOLS[d.verdict];
13119
+ lines.push(
13120
+ ` ${padRight(d.metric, 16)} ${padLeft(baseStr, 10)} ${padLeft(currStr, 10)} ${padLeft(deltaStr, 10)} ${symbol} ${d.verdict}`
13121
+ );
13122
+ }
13123
+ sections.push(lines.join("\n"));
13124
+ }
13125
+ const s = diff2.summary;
13126
+ sections.push(
13127
+ `${s.regressions} regression${s.regressions !== 1 ? "s" : ""}, ${s.improvements} improvement${s.improvements !== 1 ? "s" : ""}, ${s.unchanged} unchanged`
13128
+ );
13129
+ return sections.join("\n\n");
13130
+ }
13131
+ function fmtVal(metric, value) {
13132
+ if (metric === "cls") return value.toFixed(3);
13133
+ return formatMs(value);
13134
+ }
13135
+ function fmtDelta(metric, delta) {
13136
+ const sign = delta > 0 ? "+" : "";
13137
+ if (metric === "cls") return `${sign}${delta.toFixed(3)}`;
13138
+ return `${sign}${formatMs(delta)}`;
13139
+ }
13140
+ function fmtBytesDelta(delta) {
13141
+ const sign = delta > 0 ? "+" : "";
13142
+ return `${sign}${formatBytes2(Math.abs(delta))}`;
13143
+ }
13144
+ var THRESHOLDS, VERDICT_SYMBOLS;
13066
13145
  var init_formatter = __esm({
13067
13146
  "src/perf-audit/formatter.ts"() {
13068
13147
  "use strict";
@@ -13075,10 +13154,261 @@ var init_formatter = __esm({
13075
13154
  ttfb: { good: 800, needsImprovement: 1800 },
13076
13155
  tbt: { good: 200, needsImprovement: 600 }
13077
13156
  };
13157
+ VERDICT_SYMBOLS = {
13158
+ regression: "\u2191",
13159
+ improvement: "\u2193",
13160
+ unchanged: "=",
13161
+ new: "+",
13162
+ missing: "\u2212"
13163
+ };
13164
+ }
13165
+ });
13166
+
13167
+ // src/perf-audit/persist.ts
13168
+ var persist_exports = {};
13169
+ __export(persist_exports, {
13170
+ deleteAudit: () => deleteAudit,
13171
+ listAudits: () => listAudits,
13172
+ loadAudit: () => loadAudit,
13173
+ saveAudit: () => saveAudit
13174
+ });
13175
+ function auditsDir(localDir) {
13176
+ return path12.join(localDir, "audits");
13177
+ }
13178
+ function auditPath(localDir, name) {
13179
+ return path12.join(auditsDir(localDir), `${sanitizeName(name)}.json`);
13180
+ }
13181
+ function saveAudit(localDir, name, report) {
13182
+ const dir = auditsDir(localDir);
13183
+ fs14.mkdirSync(dir, { recursive: true });
13184
+ const filePath = auditPath(localDir, name);
13185
+ fs14.writeFileSync(filePath, JSON.stringify(report, null, 2), { mode: 384 });
13186
+ return filePath;
13187
+ }
13188
+ function loadAudit(localDir, name) {
13189
+ const filePath = auditPath(localDir, name);
13190
+ if (!fs14.existsSync(filePath)) {
13191
+ throw new Error(
13192
+ `Audit not found: ${filePath}. Run "browse perf-audit save ${name}" first.`
13193
+ );
13194
+ }
13195
+ return JSON.parse(fs14.readFileSync(filePath, "utf-8"));
13196
+ }
13197
+ function listAudits(localDir) {
13198
+ const dir = auditsDir(localDir);
13199
+ if (!fs14.existsSync(dir)) return [];
13200
+ const files = fs14.readdirSync(dir).filter((f) => f.endsWith(".json"));
13201
+ if (files.length === 0) return [];
13202
+ const entries = files.map((file) => {
13203
+ const fp = path12.join(dir, file);
13204
+ const stat = fs14.statSync(fp);
13205
+ return {
13206
+ name: file.replace(".json", ""),
13207
+ sizeBytes: stat.size,
13208
+ date: new Date(stat.mtimeMs).toISOString()
13209
+ };
13210
+ });
13211
+ entries.sort((a, b) => b.date.localeCompare(a.date));
13212
+ return entries;
13213
+ }
13214
+ function deleteAudit(localDir, name) {
13215
+ const filePath = auditPath(localDir, name);
13216
+ if (!fs14.existsSync(filePath)) {
13217
+ throw new Error(
13218
+ `Audit not found: ${filePath}. Nothing to delete.`
13219
+ );
13220
+ }
13221
+ fs14.unlinkSync(filePath);
13222
+ }
13223
+ var fs14, path12;
13224
+ var init_persist = __esm({
13225
+ "src/perf-audit/persist.ts"() {
13226
+ "use strict";
13227
+ fs14 = __toESM(require("fs"), 1);
13228
+ path12 = __toESM(require("path"), 1);
13229
+ init_sanitize();
13230
+ }
13231
+ });
13232
+
13233
+ // src/perf-audit/diff.ts
13234
+ var diff_exports = {};
13235
+ __export(diff_exports, {
13236
+ diffAuditReports: () => diffAuditReports
13237
+ });
13238
+ function compareMetric(metric, baseline, current, threshold) {
13239
+ if (baseline === null && current === null) {
13240
+ return { metric, baseline, current, verdict: "unchanged" };
13241
+ }
13242
+ if (baseline === null) {
13243
+ return { metric, baseline, current, verdict: "new" };
13244
+ }
13245
+ if (current === null) {
13246
+ return { metric, baseline, current, verdict: "missing" };
13247
+ }
13248
+ const deltaMs = current - baseline;
13249
+ const deltaPct = baseline !== 0 ? deltaMs / baseline * 100 : current !== 0 ? 100 : 0;
13250
+ let verdict = "unchanged";
13251
+ if (deltaMs > threshold) {
13252
+ verdict = "regression";
13253
+ } else if (deltaMs < -threshold) {
13254
+ verdict = "improvement";
13255
+ }
13256
+ return {
13257
+ metric,
13258
+ baseline,
13259
+ current,
13260
+ deltaMs: round(deltaMs),
13261
+ deltaPct: round(deltaPct),
13262
+ verdict
13263
+ };
13264
+ }
13265
+ function compareResourceSize(metric, baseline, current) {
13266
+ if (baseline === null && current === null) {
13267
+ return { metric, baseline, current, verdict: "unchanged" };
13268
+ }
13269
+ if (baseline === null) {
13270
+ return { metric, baseline, current, verdict: "new" };
13271
+ }
13272
+ if (current === null) {
13273
+ return { metric, baseline, current, verdict: "missing" };
13274
+ }
13275
+ const deltaMs = current - baseline;
13276
+ const deltaPct = baseline !== 0 ? deltaMs / baseline * 100 : current !== 0 ? 100 : 0;
13277
+ let verdict = "unchanged";
13278
+ if (deltaPct > RESOURCE_SIZE_PCT_THRESHOLD) {
13279
+ verdict = "regression";
13280
+ } else if (deltaPct < -RESOURCE_SIZE_PCT_THRESHOLD) {
13281
+ verdict = "improvement";
13282
+ }
13283
+ return {
13284
+ metric,
13285
+ baseline,
13286
+ current,
13287
+ deltaMs: round(deltaMs),
13288
+ deltaPct: round(deltaPct),
13289
+ verdict
13290
+ };
13291
+ }
13292
+ function compareCoverage(metric, baseline, current) {
13293
+ if (baseline === null && current === null) {
13294
+ return { metric, baseline, current, verdict: "unchanged" };
13295
+ }
13296
+ if (baseline === null) {
13297
+ return { metric, baseline, current, verdict: "new" };
13298
+ }
13299
+ if (current === null) {
13300
+ return { metric, baseline, current, verdict: "missing" };
13301
+ }
13302
+ const deltaPP = current - baseline;
13303
+ const deltaPct = baseline !== 0 ? deltaPP / baseline * 100 : current !== 0 ? 100 : 0;
13304
+ let verdict = "unchanged";
13305
+ if (deltaPP > COVERAGE_PP_THRESHOLD) {
13306
+ verdict = "regression";
13307
+ } else if (deltaPP < -COVERAGE_PP_THRESHOLD) {
13308
+ verdict = "improvement";
13309
+ }
13310
+ return {
13311
+ metric,
13312
+ baseline,
13313
+ current,
13314
+ deltaMs: round(deltaPP),
13315
+ deltaPct: round(deltaPct),
13316
+ verdict
13317
+ };
13318
+ }
13319
+ function avgUnusedPct(entries) {
13320
+ if (entries.length === 0) return null;
13321
+ let totalBytes = 0;
13322
+ let unusedBytes = 0;
13323
+ for (const e of entries) {
13324
+ totalBytes += e.totalBytes;
13325
+ unusedBytes += e.unusedBytes;
13326
+ }
13327
+ return totalBytes > 0 ? round(unusedBytes / totalBytes * 100) : 0;
13328
+ }
13329
+ function round(n) {
13330
+ return Math.round(n * 100) / 100;
13331
+ }
13332
+ function diffAuditReports(baseline, current) {
13333
+ const webVitals = [
13334
+ compareMetric("ttfb", baseline.webVitals.ttfb, current.webVitals.ttfb, WEB_VITALS_THRESHOLDS.ttfb.absolute),
13335
+ compareMetric("fcp", baseline.webVitals.fcp, current.webVitals.fcp, WEB_VITALS_THRESHOLDS.fcp.absolute),
13336
+ compareMetric("lcp", baseline.webVitals.lcp, current.webVitals.lcp, WEB_VITALS_THRESHOLDS.lcp.absolute),
13337
+ compareMetric("cls", baseline.webVitals.cls, current.webVitals.cls, WEB_VITALS_THRESHOLDS.cls.absolute),
13338
+ compareMetric("tbt", baseline.webVitals.tbt, current.webVitals.tbt, WEB_VITALS_THRESHOLDS.tbt.absolute),
13339
+ compareMetric("inp", baseline.webVitals.inp, current.webVitals.inp, WEB_VITALS_THRESHOLDS.inp.absolute)
13340
+ ];
13341
+ const allCategoryKeys = /* @__PURE__ */ new Set([
13342
+ ...Object.keys(baseline.resources.categories),
13343
+ ...Object.keys(current.resources.categories)
13344
+ ]);
13345
+ const resourceSize = [];
13346
+ for (const key of allCategoryKeys) {
13347
+ const baselineBytes = baseline.resources.categories[key]?.totalSizeBytes ?? null;
13348
+ const currentBytes = current.resources.categories[key]?.totalSizeBytes ?? null;
13349
+ resourceSize.push(compareResourceSize(key, baselineBytes, currentBytes));
13350
+ }
13351
+ const coverageDeltas = [];
13352
+ const baselineJsUnused = baseline.coverage ? avgUnusedPct(baseline.coverage.js) : null;
13353
+ const currentJsUnused = current.coverage ? avgUnusedPct(current.coverage.js) : null;
13354
+ coverageDeltas.push(compareCoverage("js-unused-pct", baselineJsUnused, currentJsUnused));
13355
+ const baselineCssUnused = baseline.coverage ? avgUnusedPct(baseline.coverage.css) : null;
13356
+ const currentCssUnused = current.coverage ? avgUnusedPct(current.coverage.css) : null;
13357
+ coverageDeltas.push(compareCoverage("css-unused-pct", baselineCssUnused, currentCssUnused));
13358
+ const fixableCount = {
13359
+ baseline: baseline.fixable.length,
13360
+ current: current.fixable.length,
13361
+ delta: current.fixable.length - baseline.fixable.length
13362
+ };
13363
+ const allDeltas = [...webVitals, ...resourceSize, ...coverageDeltas];
13364
+ let regressions = 0;
13365
+ let improvements = 0;
13366
+ let unchanged = 0;
13367
+ for (const d of allDeltas) {
13368
+ switch (d.verdict) {
13369
+ case "regression":
13370
+ regressions++;
13371
+ break;
13372
+ case "improvement":
13373
+ improvements++;
13374
+ break;
13375
+ case "unchanged":
13376
+ unchanged++;
13377
+ break;
13378
+ }
13379
+ }
13380
+ return {
13381
+ webVitals,
13382
+ resourceSize,
13383
+ coverage: coverageDeltas,
13384
+ fixableCount,
13385
+ summary: { regressions, improvements, unchanged }
13386
+ };
13387
+ }
13388
+ var WEB_VITALS_THRESHOLDS, RESOURCE_SIZE_PCT_THRESHOLD, COVERAGE_PP_THRESHOLD;
13389
+ var init_diff = __esm({
13390
+ "src/perf-audit/diff.ts"() {
13391
+ "use strict";
13392
+ WEB_VITALS_THRESHOLDS = {
13393
+ ttfb: { absolute: 100 },
13394
+ fcp: { absolute: 100 },
13395
+ lcp: { absolute: 200 },
13396
+ cls: { absolute: 0.05 },
13397
+ tbt: { absolute: 100 },
13398
+ inp: { absolute: 50 }
13399
+ };
13400
+ RESOURCE_SIZE_PCT_THRESHOLD = 10;
13401
+ COVERAGE_PP_THRESHOLD = 5;
13078
13402
  }
13079
13403
  });
13080
13404
 
13081
13405
  // src/commands/meta.ts
13406
+ function padR(str, w) {
13407
+ return str.length >= w ? str : str + " ".repeat(w - str.length);
13408
+ }
13409
+ function padL(str, w) {
13410
+ return str.length >= w ? str : " ".repeat(w - str.length) + str;
13411
+ }
13082
13412
  async function handleMetaCommand(command, args, bm, shutdown2, sessionManager2, currentSession) {
13083
13413
  switch (command) {
13084
13414
  // ─── Tabs ──────────────────────────────────────────
@@ -13160,7 +13490,7 @@ async function handleMetaCommand(command, args, bm, shutdown2, sessionManager2,
13160
13490
  const lines = entries.map(
13161
13491
  (e) => `[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
13162
13492
  ).join("\n") + "\n";
13163
- fs14.appendFileSync(consolePath, lines);
13493
+ fs15.appendFileSync(consolePath, lines);
13164
13494
  buffers.lastConsoleFlushed = buffers.consoleTotalAdded;
13165
13495
  }
13166
13496
  const newNetworkCount = buffers.networkTotalAdded - buffers.lastNetworkFlushed;
@@ -13170,7 +13500,7 @@ async function handleMetaCommand(command, args, bm, shutdown2, sessionManager2,
13170
13500
  const lines = entries.map(
13171
13501
  (e) => `[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} \u2192 ${e.status || "pending"} (${e.duration || "?"}ms, ${e.size || "?"}B)`
13172
13502
  ).join("\n") + "\n";
13173
- fs14.appendFileSync(networkPath, lines);
13503
+ fs15.appendFileSync(networkPath, lines);
13174
13504
  buffers.lastNetworkFlushed = buffers.networkTotalAdded;
13175
13505
  }
13176
13506
  }
@@ -13187,22 +13517,22 @@ async function handleMetaCommand(command, args, bm, shutdown2, sessionManager2,
13187
13517
  const statesDir = `${LOCAL_DIR}/states`;
13188
13518
  const statePath = `${statesDir}/${name}.json`;
13189
13519
  if (subcommand === "list") {
13190
- if (!fs14.existsSync(statesDir)) return "(no saved states)";
13191
- const files = fs14.readdirSync(statesDir).filter((f) => f.endsWith(".json"));
13520
+ if (!fs15.existsSync(statesDir)) return "(no saved states)";
13521
+ const files = fs15.readdirSync(statesDir).filter((f) => f.endsWith(".json"));
13192
13522
  if (files.length === 0) return "(no saved states)";
13193
13523
  const lines = [];
13194
13524
  for (const file of files) {
13195
13525
  const fp = `${statesDir}/${file}`;
13196
- const stat = fs14.statSync(fp);
13526
+ const stat = fs15.statSync(fp);
13197
13527
  lines.push(` ${file.replace(".json", "")} ${stat.size}B ${new Date(stat.mtimeMs).toISOString()}`);
13198
13528
  }
13199
13529
  return lines.join("\n");
13200
13530
  }
13201
13531
  if (subcommand === "show") {
13202
- if (!fs14.existsSync(statePath)) {
13532
+ if (!fs15.existsSync(statePath)) {
13203
13533
  throw new Error(`State file not found: ${statePath}`);
13204
13534
  }
13205
- const data = JSON.parse(fs14.readFileSync(statePath, "utf-8"));
13535
+ const data = JSON.parse(fs15.readFileSync(statePath, "utf-8"));
13206
13536
  const cookieCount = data.cookies?.length || 0;
13207
13537
  const originCount = data.origins?.length || 0;
13208
13538
  const storageItems = (data.origins || []).reduce((sum, o) => sum + (o.localStorage?.length || 0), 0);
@@ -13227,15 +13557,15 @@ async function handleMetaCommand(command, args, bm, shutdown2, sessionManager2,
13227
13557
  const context = bm.getContext();
13228
13558
  if (!context) throw new Error("No browser context");
13229
13559
  const state = await context.storageState();
13230
- fs14.mkdirSync(statesDir, { recursive: true });
13231
- fs14.writeFileSync(statePath, JSON.stringify(state, null, 2), { mode: 384 });
13560
+ fs15.mkdirSync(statesDir, { recursive: true });
13561
+ fs15.writeFileSync(statePath, JSON.stringify(state, null, 2), { mode: 384 });
13232
13562
  return `State saved: ${statePath}`;
13233
13563
  }
13234
13564
  if (subcommand === "load") {
13235
- if (!fs14.existsSync(statePath)) {
13565
+ if (!fs15.existsSync(statePath)) {
13236
13566
  throw new Error(`State file not found: ${statePath}. Run "browse state save ${name}" first.`);
13237
13567
  }
13238
- const stateData = JSON.parse(fs14.readFileSync(statePath, "utf-8"));
13568
+ const stateData = JSON.parse(fs15.readFileSync(statePath, "utf-8"));
13239
13569
  const context = bm.getContext();
13240
13570
  if (!context) throw new Error("No browser context");
13241
13571
  const warnings = [];
@@ -13394,9 +13724,9 @@ ${legend.join("\n")}`;
13394
13724
  try {
13395
13725
  for (const vp of viewports) {
13396
13726
  await page.setViewportSize({ width: vp.width, height: vp.height });
13397
- const path15 = `${prefix}-${vp.name}.png`;
13398
- await page.screenshot({ path: path15, fullPage: true });
13399
- results.push(`${vp.name} (${vp.width}x${vp.height}): ${path15}`);
13727
+ const path16 = `${prefix}-${vp.name}.png`;
13728
+ await page.screenshot({ path: path16, fullPage: true });
13729
+ results.push(`${vp.name} (${vp.width}x${vp.height}): ${path16}`);
13400
13730
  }
13401
13731
  } finally {
13402
13732
  if (originalViewport) {
@@ -13540,13 +13870,13 @@ ${legend.join("\n")}`;
13540
13870
  const diffArgs = args.filter((a) => a !== "--full");
13541
13871
  const baseline = diffArgs[0];
13542
13872
  if (!baseline) throw new Error("Usage: browse screenshot-diff <baseline> [current] [--threshold 0.1] [--full]");
13543
- if (!fs14.existsSync(baseline)) throw new Error(`Baseline file not found: ${baseline}`);
13873
+ if (!fs15.existsSync(baseline)) throw new Error(`Baseline file not found: ${baseline}`);
13544
13874
  let thresholdPct = 0.1;
13545
13875
  const threshIdx = diffArgs.indexOf("--threshold");
13546
13876
  if (threshIdx !== -1 && diffArgs[threshIdx + 1]) {
13547
13877
  thresholdPct = parseFloat(diffArgs[threshIdx + 1]);
13548
13878
  }
13549
- const baselineBuffer = fs14.readFileSync(baseline);
13879
+ const baselineBuffer = fs15.readFileSync(baseline);
13550
13880
  let currentBuffer;
13551
13881
  let currentPath;
13552
13882
  for (let i = 1; i < diffArgs.length; i++) {
@@ -13560,8 +13890,8 @@ ${legend.join("\n")}`;
13560
13890
  }
13561
13891
  }
13562
13892
  if (currentPath) {
13563
- if (!fs14.existsSync(currentPath)) throw new Error(`Current screenshot not found: ${currentPath}`);
13564
- currentBuffer = fs14.readFileSync(currentPath);
13893
+ if (!fs15.existsSync(currentPath)) throw new Error(`Current screenshot not found: ${currentPath}`);
13894
+ currentBuffer = fs15.readFileSync(currentPath);
13565
13895
  } else {
13566
13896
  const page = bm.getPage();
13567
13897
  currentBuffer = await page.screenshot({ fullPage: isFullPageDiff });
@@ -13571,7 +13901,7 @@ ${legend.join("\n")}`;
13571
13901
  const extIdx = baseline.lastIndexOf(".");
13572
13902
  const diffPath = extIdx > 0 ? baseline.slice(0, extIdx) + "-diff" + baseline.slice(extIdx) : baseline + "-diff.png";
13573
13903
  if (!result.passed && result.diffImage) {
13574
- fs14.writeFileSync(diffPath, result.diffImage);
13904
+ fs15.writeFileSync(diffPath, result.diffImage);
13575
13905
  }
13576
13906
  return [
13577
13907
  `Pixels: ${result.totalPixels}`,
@@ -13707,7 +14037,7 @@ ${legend.join("\n")}`;
13707
14037
  const { formatAsHar: formatAsHar2 } = await Promise.resolve().then(() => (init_har(), har_exports));
13708
14038
  const har = formatAsHar2(sessionBuffers.networkBuffer, recording.startTime);
13709
14039
  const harPath = args[1] || (currentSession ? `${currentSession.outputDir}/recording.har` : `${LOCAL_DIR}/browse-recording.har`);
13710
- fs14.writeFileSync(harPath, JSON.stringify(har, null, 2));
14040
+ fs15.writeFileSync(harPath, JSON.stringify(har, null, 2));
13711
14041
  const entryCount = har.log.entries.length;
13712
14042
  return `HAR saved: ${harPath} (${entryCount} entries)`;
13713
14043
  }
@@ -13957,7 +14287,7 @@ ${snapshot}`;
13957
14287
  throw new Error(`Unknown format: ${format}. Use "browse" (chain JSON) or "replay" (Playwright/Puppeteer).`);
13958
14288
  }
13959
14289
  if (filePath) {
13960
- fs14.writeFileSync(filePath, output);
14290
+ fs15.writeFileSync(filePath, output);
13961
14291
  return `Exported ${steps.length} steps as ${format}: ${filePath}`;
13962
14292
  }
13963
14293
  return output;
@@ -14199,29 +14529,119 @@ ${lines.join("\n")}
14199
14529
  return lines.join("\n");
14200
14530
  }
14201
14531
  case "perf-audit": {
14202
- const flags = new Set(args.filter((a) => a.startsWith("--")));
14203
- const positionalArgs = args.filter((a) => !a.startsWith("--"));
14204
- const url = positionalArgs[0];
14205
- const options = {
14206
- includeCoverage: !flags.has("--no-coverage"),
14207
- includeDetection: !flags.has("--no-detect")
14208
- };
14209
- const jsonOutput = flags.has("--json");
14210
- if (url) {
14211
- const { handleWriteCommand: handleWriteCommand2 } = await Promise.resolve().then(() => (init_write(), write_exports));
14212
- await handleWriteCommand2("goto", [url], bm, currentSession?.domainFilter);
14532
+ const subcommand = args[0];
14533
+ if (subcommand === "save") {
14534
+ const subArgs = args.slice(1);
14535
+ const flags = new Set(subArgs.filter((a) => a.startsWith("--")));
14536
+ const positionalArgs = subArgs.filter((a) => !a.startsWith("--"));
14537
+ const jsonOutput = flags.has("--json");
14538
+ const options = {
14539
+ includeCoverage: !flags.has("--no-coverage"),
14540
+ includeDetection: !flags.has("--no-detect")
14541
+ };
14542
+ let name;
14543
+ let url;
14544
+ for (const arg of positionalArgs) {
14545
+ if (arg.startsWith("http://") || arg.startsWith("https://")) {
14546
+ url = arg;
14547
+ } else {
14548
+ name = arg;
14549
+ }
14550
+ }
14551
+ if (url) {
14552
+ const { handleWriteCommand: handleWriteCommand2 } = await Promise.resolve().then(() => (init_write(), write_exports));
14553
+ await handleWriteCommand2("goto", [url], bm, currentSession?.domainFilter);
14554
+ }
14555
+ const { runPerfAudit: runPerfAudit2 } = await Promise.resolve().then(() => (init_perf_audit(), perf_audit_exports));
14556
+ const { formatPerfAudit: formatPerfAudit2 } = await Promise.resolve().then(() => (init_formatter(), formatter_exports));
14557
+ const { saveAudit: saveAudit2 } = await Promise.resolve().then(() => (init_persist(), persist_exports));
14558
+ const networkEntries = currentSession?.buffers?.networkBuffer || [];
14559
+ const report = await runPerfAudit2(bm, networkEntries, options);
14560
+ if (!name) {
14561
+ try {
14562
+ const pageUrl = new URL(bm.getCurrentUrl());
14563
+ const host = pageUrl.hostname.replace(/\./g, "-");
14564
+ const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
14565
+ name = `${host}-${date}`;
14566
+ } catch {
14567
+ name = `audit-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`;
14568
+ }
14569
+ }
14570
+ const filePath = saveAudit2(LOCAL_DIR, name, report);
14571
+ const formatted = formatPerfAudit2(report, jsonOutput);
14572
+ return `${formatted}
14573
+
14574
+ Audit saved: ${filePath}`;
14575
+ }
14576
+ if (subcommand === "compare") {
14577
+ if (!args[1]) throw new Error("Usage: browse perf-audit compare <baseline> [current]");
14578
+ const subArgs = args.slice(1);
14579
+ const flags = new Set(subArgs.filter((a) => a.startsWith("--")));
14580
+ const positionalArgs = subArgs.filter((a) => !a.startsWith("--"));
14581
+ const jsonOutput = flags.has("--json");
14582
+ const { loadAudit: loadAudit2 } = await Promise.resolve().then(() => (init_persist(), persist_exports));
14583
+ const { diffAuditReports: diffAuditReports2 } = await Promise.resolve().then(() => (init_diff(), diff_exports));
14584
+ const { formatAuditDiff: formatAuditDiff2 } = await Promise.resolve().then(() => (init_formatter(), formatter_exports));
14585
+ const baseline = loadAudit2(LOCAL_DIR, positionalArgs[0]);
14586
+ let current;
14587
+ if (positionalArgs[1]) {
14588
+ current = loadAudit2(LOCAL_DIR, positionalArgs[1]);
14589
+ } else {
14590
+ const options = {
14591
+ includeCoverage: !flags.has("--no-coverage"),
14592
+ includeDetection: !flags.has("--no-detect")
14593
+ };
14594
+ const { runPerfAudit: runPerfAudit2 } = await Promise.resolve().then(() => (init_perf_audit(), perf_audit_exports));
14595
+ const networkEntries = currentSession?.buffers?.networkBuffer || [];
14596
+ current = await runPerfAudit2(bm, networkEntries, options);
14597
+ }
14598
+ const diff2 = diffAuditReports2(baseline, current);
14599
+ return formatAuditDiff2(diff2, jsonOutput);
14600
+ }
14601
+ if (subcommand === "list") {
14602
+ const { listAudits: listAudits2 } = await Promise.resolve().then(() => (init_persist(), persist_exports));
14603
+ const entries = listAudits2(LOCAL_DIR);
14604
+ if (entries.length === 0) return "(no saved audits)";
14605
+ const lines = [];
14606
+ lines.push(`${padR("Name", 40)} ${padL("Size", 10)} ${"Date"}`);
14607
+ for (const e of entries) {
14608
+ const sizeStr = e.sizeBytes < 1024 ? `${e.sizeBytes}B` : `${Math.round(e.sizeBytes / 1024)}KB`;
14609
+ const dateStr = e.date.slice(0, 19).replace("T", " ");
14610
+ lines.push(`${padR(e.name, 40)} ${padL(sizeStr, 10)} ${dateStr}`);
14611
+ }
14612
+ return lines.join("\n");
14613
+ }
14614
+ if (subcommand === "delete") {
14615
+ if (!args[1]) throw new Error("Usage: browse perf-audit delete <name>");
14616
+ const { deleteAudit: deleteAudit2 } = await Promise.resolve().then(() => (init_persist(), persist_exports));
14617
+ deleteAudit2(LOCAL_DIR, args[1]);
14618
+ return `Audit deleted: ${args[1]}`;
14619
+ }
14620
+ {
14621
+ const flags = new Set(args.filter((a) => a.startsWith("--")));
14622
+ const positionalArgs = args.filter((a) => !a.startsWith("--"));
14623
+ const url = positionalArgs[0];
14624
+ const options = {
14625
+ includeCoverage: !flags.has("--no-coverage"),
14626
+ includeDetection: !flags.has("--no-detect")
14627
+ };
14628
+ const jsonOutput = flags.has("--json");
14629
+ if (url) {
14630
+ const { handleWriteCommand: handleWriteCommand2 } = await Promise.resolve().then(() => (init_write(), write_exports));
14631
+ await handleWriteCommand2("goto", [url], bm, currentSession?.domainFilter);
14632
+ }
14633
+ const { runPerfAudit: runPerfAudit2 } = await Promise.resolve().then(() => (init_perf_audit(), perf_audit_exports));
14634
+ const { formatPerfAudit: formatPerfAudit2 } = await Promise.resolve().then(() => (init_formatter(), formatter_exports));
14635
+ const networkEntries = currentSession?.buffers?.networkBuffer || [];
14636
+ const report = await runPerfAudit2(bm, networkEntries, options);
14637
+ return formatPerfAudit2(report, jsonOutput);
14213
14638
  }
14214
- const { runPerfAudit: runPerfAudit2 } = await Promise.resolve().then(() => (init_perf_audit(), perf_audit_exports));
14215
- const { formatPerfAudit: formatPerfAudit2 } = await Promise.resolve().then(() => (init_formatter(), formatter_exports));
14216
- const networkEntries = currentSession?.buffers?.networkBuffer || [];
14217
- const report = await runPerfAudit2(bm, networkEntries, options);
14218
- return formatPerfAudit2(report, jsonOutput);
14219
14639
  }
14220
14640
  default:
14221
14641
  throw new Error(`Unknown meta command: ${command}`);
14222
14642
  }
14223
14643
  }
14224
- var fs14, LOCAL_DIR;
14644
+ var fs15, LOCAL_DIR;
14225
14645
  var init_meta = __esm({
14226
14646
  "src/commands/meta.ts"() {
14227
14647
  "use strict";
@@ -14229,7 +14649,7 @@ var init_meta = __esm({
14229
14649
  init_constants();
14230
14650
  init_sanitize();
14231
14651
  init_lib();
14232
- fs14 = __toESM(require("fs"), 1);
14652
+ fs15 = __toESM(require("fs"), 1);
14233
14653
  LOCAL_DIR = process.env.BROWSE_LOCAL_DIR || "/tmp";
14234
14654
  }
14235
14655
  });
@@ -14631,6 +15051,44 @@ function mapToolCallToCommand(toolName, params) {
14631
15051
  if (params.api_key) args.push(String(params.api_key));
14632
15052
  return { command: "provider", args };
14633
15053
  }
15054
+ // ─── PERFORMANCE AUDIT ──────────────────────────────
15055
+ case "perf-audit": {
15056
+ const args = [];
15057
+ if (params.url) args.push(String(params.url));
15058
+ if (params.no_coverage) args.push("--no-coverage");
15059
+ if (params.no_detect) args.push("--no-detect");
15060
+ if (params.json) args.push("--json");
15061
+ return { command: "perf-audit", args };
15062
+ }
15063
+ case "perf-audit-save": {
15064
+ const args = ["save"];
15065
+ if (params.name) args.push(String(params.name));
15066
+ if (params.url) args.push(String(params.url));
15067
+ if (params.no_coverage) args.push("--no-coverage");
15068
+ if (params.no_detect) args.push("--no-detect");
15069
+ return { command: "perf-audit", args };
15070
+ }
15071
+ case "perf-audit-compare": {
15072
+ const args = ["compare", String(params.baseline)];
15073
+ if (params.current) args.push(String(params.current));
15074
+ if (params.no_coverage) args.push("--no-coverage");
15075
+ if (params.no_detect) args.push("--no-detect");
15076
+ if (params.json) args.push("--json");
15077
+ return { command: "perf-audit", args };
15078
+ }
15079
+ case "perf-audit-list":
15080
+ return { command: "perf-audit", args: ["list"] };
15081
+ case "perf-audit-delete":
15082
+ return { command: "perf-audit", args: ["delete", String(params.name)] };
15083
+ case "detect":
15084
+ return { command: "detect", args: [] };
15085
+ case "coverage":
15086
+ return { command: "coverage", args: [String(params.action)] };
15087
+ case "initscript": {
15088
+ const args = [String(params.action)];
15089
+ if (params.action === "set" && params.code) args.push(String(params.code));
15090
+ return { command: "initscript", args };
15091
+ }
14634
15092
  default:
14635
15093
  throw new Error(`Unknown MCP tool: ${toolName}`);
14636
15094
  }
@@ -15605,6 +16063,103 @@ var init_mcp_tools = __esm({
15605
16063
  },
15606
16064
  required: ["action"]
15607
16065
  }
16066
+ },
16067
+ // ─── Performance Audit ────────────────────────────────────────────
16068
+ {
16069
+ name: "browse_perf_audit",
16070
+ description: "Run a full performance audit on the current page (or a URL). Returns Core Web Vitals (LCP, CLS, TBT, FCP, TTFB, INP), LCP critical path reconstruction, layout shift attribution, long task script attribution, resource breakdown, render-blocking detection, image audit, font audit, DOM complexity, tech stack detection (108 frameworks, 55 SaaS platforms), third-party impact analysis, JS/CSS coverage, and prioritized recommendations. The page is reloaded as part of the audit.",
16071
+ inputSchema: {
16072
+ type: "object",
16073
+ properties: {
16074
+ url: { type: "string", description: "URL to audit. If provided, navigates there first. If omitted, audits the current page." },
16075
+ no_coverage: { type: "boolean", description: "Skip JS/CSS coverage collection (faster audit)." },
16076
+ no_detect: { type: "boolean", description: "Skip framework/SaaS/infrastructure detection." },
16077
+ json: { type: "boolean", description: "Return structured JSON instead of formatted text." }
16078
+ }
16079
+ }
16080
+ },
16081
+ {
16082
+ name: "browse_perf_audit_save",
16083
+ description: "Run a performance audit and save the report to .browse/audits/ for later comparison.",
16084
+ inputSchema: {
16085
+ type: "object",
16086
+ properties: {
16087
+ name: { type: "string", description: "Name for the saved report. Auto-generated from URL + timestamp if omitted." },
16088
+ url: { type: "string", description: "URL to audit. If omitted, audits the current page." },
16089
+ no_coverage: { type: "boolean", description: "Skip JS/CSS coverage collection (faster audit)." },
16090
+ no_detect: { type: "boolean", description: "Skip framework/SaaS/infrastructure detection." }
16091
+ }
16092
+ }
16093
+ },
16094
+ {
16095
+ name: "browse_perf_audit_compare",
16096
+ description: "Compare a saved audit report against the current page or another saved report. Shows regressions and improvements.",
16097
+ inputSchema: {
16098
+ type: "object",
16099
+ properties: {
16100
+ baseline: { type: "string", description: "Name of the saved baseline audit report." },
16101
+ current: { type: "string", description: "Name of a second saved report to compare against. If omitted, runs a live audit for comparison." },
16102
+ no_coverage: { type: "boolean", description: "Skip JS/CSS coverage collection when running a live audit." },
16103
+ no_detect: { type: "boolean", description: "Skip framework/SaaS/infrastructure detection when running a live audit." },
16104
+ json: { type: "boolean", description: "Return structured JSON instead of formatted text." }
16105
+ },
16106
+ required: ["baseline"]
16107
+ }
16108
+ },
16109
+ {
16110
+ name: "browse_perf_audit_list",
16111
+ description: "List all saved performance audit reports in .browse/audits/.",
16112
+ inputSchema: { type: "object", properties: {} }
16113
+ },
16114
+ {
16115
+ name: "browse_perf_audit_delete",
16116
+ description: "Delete a saved performance audit report from .browse/audits/.",
16117
+ inputSchema: {
16118
+ type: "object",
16119
+ properties: {
16120
+ name: { type: "string", description: "Name of the saved audit report to delete." }
16121
+ },
16122
+ required: ["name"]
16123
+ }
16124
+ },
16125
+ {
16126
+ name: "browse_detect",
16127
+ description: "Detect the technology stack of the current page. Returns frameworks (React, Vue, Angular, Next.js, Laravel, WordPress, Magento, etc. \u2014 108 total with version, build mode, config depth), SaaS platforms (Shopify, Wix, Squarespace, etc. \u2014 55 total with app enumeration and constraints), infrastructure (CDN, protocol, compression, caching, Service Worker), DOM complexity, and third-party inventory (88 known domains classified by category).",
16128
+ inputSchema: { type: "object", properties: {} }
16129
+ },
16130
+ {
16131
+ name: "browse_coverage",
16132
+ description: "Collect JS and CSS code coverage. Start collection, navigate/interact with the page, then stop to see per-file used/unused bytes sorted by wasted bytes descending.",
16133
+ inputSchema: {
16134
+ type: "object",
16135
+ properties: {
16136
+ action: { type: "string", description: "Coverage operation.", enum: ["start", "stop"] }
16137
+ },
16138
+ required: ["action"]
16139
+ }
16140
+ },
16141
+ {
16142
+ name: "browse_initscript",
16143
+ description: "Inject JavaScript that runs before every page load (via context.addInitScript). Useful for mocking APIs, injecting polyfills, or setting up performance observers. Scripts persist across navigations and survive device emulation.",
16144
+ inputSchema: {
16145
+ type: "object",
16146
+ properties: {
16147
+ action: { type: "string", description: "Operation to perform.", enum: ["set", "show", "clear"] },
16148
+ code: { type: "string", description: 'JavaScript code to inject (required for "set").' }
16149
+ },
16150
+ required: ["action"]
16151
+ }
16152
+ },
16153
+ {
16154
+ name: "browse_scrollintoview",
16155
+ description: "Scroll an element into view. Alias for scrollinto.",
16156
+ inputSchema: {
16157
+ type: "object",
16158
+ properties: {
16159
+ selector: { type: "string", description: "CSS selector or @ref of the element to scroll into view." }
16160
+ },
16161
+ required: ["selector"]
16162
+ }
15608
16163
  }
15609
16164
  ];
15610
16165
  }
@@ -16025,7 +16580,7 @@ var init_domain_filter = __esm({
16025
16580
  });
16026
16581
 
16027
16582
  // src/session-manager.ts
16028
- var fs15, path12, SessionManager;
16583
+ var fs16, path13, SessionManager;
16029
16584
  var init_session_manager = __esm({
16030
16585
  "src/session-manager.ts"() {
16031
16586
  "use strict";
@@ -16035,8 +16590,8 @@ var init_session_manager = __esm({
16035
16590
  init_sanitize();
16036
16591
  init_session_persist();
16037
16592
  init_encryption();
16038
- fs15 = __toESM(require("fs"), 1);
16039
- path12 = __toESM(require("path"), 1);
16593
+ fs16 = __toESM(require("fs"), 1);
16594
+ path13 = __toESM(require("path"), 1);
16040
16595
  SessionManager = class {
16041
16596
  sessions = /* @__PURE__ */ new Map();
16042
16597
  browser;
@@ -16091,8 +16646,8 @@ var init_session_manager = __esm({
16091
16646
  }
16092
16647
  return session;
16093
16648
  }
16094
- const outputDir = path12.join(this.localDir, "sessions", sanitizeName(sessionId));
16095
- fs15.mkdirSync(outputDir, { recursive: true });
16649
+ const outputDir = path13.join(this.localDir, "sessions", sanitizeName(sessionId));
16650
+ fs16.mkdirSync(outputDir, { recursive: true });
16096
16651
  const buffers = new SessionBuffers();
16097
16652
  const manager = new BrowserManager(buffers);
16098
16653
  await manager.launchWithBrowser(this.browser, this.reuseContext && this.sessions.size === 0);
@@ -16276,7 +16831,7 @@ function flushSessionBuffers(session, final) {
16276
16831
  const lines = newEntries.map(
16277
16832
  (e) => `[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
16278
16833
  ).join("\n") + "\n";
16279
- fs16.appendFileSync(consolePath, lines);
16834
+ fs17.appendFileSync(consolePath, lines);
16280
16835
  buffers.lastConsoleFlushed = buffers.consoleTotalAdded;
16281
16836
  }
16282
16837
  let newNetworkCount = buffers.networkTotalAdded - buffers.lastNetworkFlushed;
@@ -16301,7 +16856,7 @@ function flushSessionBuffers(session, final) {
16301
16856
  const lines = prefix.map(
16302
16857
  (e) => `[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} \u2192 ${e.status || "pending"} (${e.duration || "?"}ms, ${e.size || "?"}B)`
16303
16858
  ).join("\n") + "\n";
16304
- fs16.appendFileSync(networkPath, lines);
16859
+ fs17.appendFileSync(networkPath, lines);
16305
16860
  buffers.lastNetworkFlushed += prefixLen;
16306
16861
  }
16307
16862
  }
@@ -16509,9 +17064,9 @@ async function shutdown() {
16509
17064
  await activeRuntime?.close?.().catch(() => {
16510
17065
  });
16511
17066
  try {
16512
- const currentState = JSON.parse(fs16.readFileSync(STATE_FILE, "utf-8"));
17067
+ const currentState = JSON.parse(fs17.readFileSync(STATE_FILE, "utf-8"));
16513
17068
  if (currentState.pid === process.pid || currentState.token === AUTH_TOKEN) {
16514
- fs16.unlinkSync(STATE_FILE);
17069
+ fs17.unlinkSync(STATE_FILE);
16515
17070
  }
16516
17071
  } catch {
16517
17072
  }
@@ -16528,15 +17083,15 @@ async function start() {
16528
17083
  const { BrowserManager: BrowserManager2, getProfileDir: getProfileDir2 } = await Promise.resolve().then(() => (init_browser_manager(), browser_manager_exports));
16529
17084
  const { SessionBuffers: SessionBuffers2 } = await Promise.resolve().then(() => (init_buffers(), buffers_exports));
16530
17085
  const profileDir = getProfileDir2(LOCAL_DIR2, profileName);
16531
- fs16.mkdirSync(profileDir, { recursive: true });
17086
+ fs17.mkdirSync(profileDir, { recursive: true });
16532
17087
  const bm = new BrowserManager2();
16533
17088
  await bm.launchPersistent(profileDir, () => {
16534
17089
  if (isShuttingDown) return;
16535
17090
  console.error("[browse] Chromium disconnected (profile mode). Shutting down.");
16536
17091
  shutdown();
16537
17092
  });
16538
- const outputDir = path13.join(LOCAL_DIR2, "sessions", profileName);
16539
- fs16.mkdirSync(outputDir, { recursive: true });
17093
+ const outputDir = path14.join(LOCAL_DIR2, "sessions", profileName);
17094
+ fs17.mkdirSync(outputDir, { recursive: true });
16540
17095
  profileSession = {
16541
17096
  id: profileName,
16542
17097
  manager: bm,
@@ -16631,7 +17186,7 @@ async function start() {
16631
17186
  const context = session.manager.getContext();
16632
17187
  if (context) {
16633
17188
  try {
16634
- const stateData = JSON.parse(fs16.readFileSync(stateFilePath, "utf-8"));
17189
+ const stateData = JSON.parse(fs17.readFileSync(stateFilePath, "utf-8"));
16635
17190
  if (stateData.cookies?.length) {
16636
17191
  await context.addCookies(stateData.cookies);
16637
17192
  }
@@ -16658,7 +17213,7 @@ async function start() {
16658
17213
  port,
16659
17214
  token: AUTH_TOKEN,
16660
17215
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
16661
- serverPath: path13.resolve(path13.dirname((0, import_url2.fileURLToPath)(__import_meta_url)), "server.ts")
17216
+ serverPath: path14.resolve(path14.dirname((0, import_url2.fileURLToPath)(__import_meta_url)), "server.ts")
16662
17217
  };
16663
17218
  if (profileName) {
16664
17219
  state.profile = profileName;
@@ -16666,12 +17221,12 @@ async function start() {
16666
17221
  if (DEBUG_PORT > 0) {
16667
17222
  state.debugPort = DEBUG_PORT;
16668
17223
  }
16669
- fs16.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { mode: 384 });
17224
+ fs17.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { mode: 384 });
16670
17225
  console.log(`[browse] Server running on http://127.0.0.1:${port} (PID: ${process.pid})`);
16671
17226
  console.log(`[browse] State file: ${STATE_FILE}`);
16672
17227
  console.log(`[browse] Idle timeout: ${IDLE_TIMEOUT_MS / 1e3}s`);
16673
17228
  }
16674
- var fs16, path13, crypto3, http, import_url2, net3, AUTH_TOKEN, DEBUG_PORT, BROWSE_PORT, BROWSE_INSTANCE, INSTANCE_SUFFIX, LOCAL_DIR2, STATE_FILE, IDLE_TIMEOUT_MS, sessionManager, browser, profileSession, activeRuntime, isShuttingDown, isRemoteBrowser, policyChecker, READ_COMMANDS2, WRITE_COMMANDS2, META_COMMANDS2, RECORDING_SKIP, PAGE_CONTENT_COMMANDS, BOUNDARY_NONCE, flushInterval, sessionCleanupInterval;
17229
+ var fs17, path14, crypto3, http, import_url2, net3, AUTH_TOKEN, DEBUG_PORT, BROWSE_PORT, BROWSE_INSTANCE, INSTANCE_SUFFIX, LOCAL_DIR2, STATE_FILE, IDLE_TIMEOUT_MS, sessionManager, browser, profileSession, activeRuntime, isShuttingDown, isRemoteBrowser, policyChecker, READ_COMMANDS2, WRITE_COMMANDS2, META_COMMANDS2, RECORDING_SKIP, PAGE_CONTENT_COMMANDS, BOUNDARY_NONCE, flushInterval, sessionCleanupInterval;
16675
17230
  var init_server = __esm({
16676
17231
  "src/server.ts"() {
16677
17232
  "use strict";
@@ -16682,8 +17237,8 @@ var init_server = __esm({
16682
17237
  init_meta();
16683
17238
  init_policy();
16684
17239
  init_constants();
16685
- fs16 = __toESM(require("fs"), 1);
16686
- path13 = __toESM(require("path"), 1);
17240
+ fs17 = __toESM(require("fs"), 1);
17241
+ path14 = __toESM(require("path"), 1);
16687
17242
  crypto3 = __toESM(require("crypto"), 1);
16688
17243
  http = __toESM(require("http"), 1);
16689
17244
  import_url2 = require("url");
@@ -16873,8 +17428,8 @@ __export(cli_exports, {
16873
17428
  resolveServerScript: () => resolveServerScript
16874
17429
  });
16875
17430
  module.exports = __toCommonJS(cli_exports);
16876
- var fs17 = __toESM(require("fs"), 1);
16877
- var path14 = __toESM(require("path"), 1);
17431
+ var fs18 = __toESM(require("fs"), 1);
17432
+ var path15 = __toESM(require("path"), 1);
16878
17433
  var import_child_process4 = require("child_process");
16879
17434
  var import_url3 = require("url");
16880
17435
  init_constants();
@@ -16930,50 +17485,50 @@ var INSTANCE_SUFFIX2 = BROWSE_PORT2 ? `-${BROWSE_PORT2}` : BROWSE_INSTANCE2 ? `-
16930
17485
  function resolveLocalDir() {
16931
17486
  if (process.env.BROWSE_LOCAL_DIR) {
16932
17487
  try {
16933
- fs17.mkdirSync(process.env.BROWSE_LOCAL_DIR, { recursive: true });
17488
+ fs18.mkdirSync(process.env.BROWSE_LOCAL_DIR, { recursive: true });
16934
17489
  } catch {
16935
17490
  }
16936
17491
  return process.env.BROWSE_LOCAL_DIR;
16937
17492
  }
16938
17493
  let dir = process.cwd();
16939
17494
  for (let i = 0; i < 20; i++) {
16940
- if (fs17.existsSync(path14.join(dir, ".git")) || fs17.existsSync(path14.join(dir, ".claude"))) {
16941
- const browseDir = path14.join(dir, ".browse");
17495
+ if (fs18.existsSync(path15.join(dir, ".git")) || fs18.existsSync(path15.join(dir, ".claude"))) {
17496
+ const browseDir = path15.join(dir, ".browse");
16942
17497
  try {
16943
- fs17.mkdirSync(browseDir, { recursive: true });
16944
- const gi = path14.join(browseDir, ".gitignore");
16945
- if (!fs17.existsSync(gi)) {
16946
- fs17.writeFileSync(gi, "*\n");
17498
+ fs18.mkdirSync(browseDir, { recursive: true });
17499
+ const gi = path15.join(browseDir, ".gitignore");
17500
+ if (!fs18.existsSync(gi)) {
17501
+ fs18.writeFileSync(gi, "*\n");
16947
17502
  }
16948
17503
  } catch {
16949
17504
  }
16950
17505
  return browseDir;
16951
17506
  }
16952
- const parent = path14.dirname(dir);
17507
+ const parent = path15.dirname(dir);
16953
17508
  if (parent === dir) break;
16954
17509
  dir = parent;
16955
17510
  }
16956
17511
  return "/tmp";
16957
17512
  }
16958
17513
  var LOCAL_DIR3 = resolveLocalDir();
16959
- var STATE_FILE2 = process.env.BROWSE_STATE_FILE || path14.join(LOCAL_DIR3, `browse-server${INSTANCE_SUFFIX2}.json`);
17514
+ var STATE_FILE2 = process.env.BROWSE_STATE_FILE || path15.join(LOCAL_DIR3, `browse-server${INSTANCE_SUFFIX2}.json`);
16960
17515
  var MAX_START_WAIT = parseInt(process.env.BROWSE_START_TIMEOUT || "0", 10) || 8e3;
16961
17516
  var LOCK_FILE = STATE_FILE2 + ".lock";
16962
17517
  var LOCK_STALE_MS = DEFAULTS.LOCK_STALE_THRESHOLD_MS;
16963
17518
  var __filename_cli = (0, import_url3.fileURLToPath)(__import_meta_url);
16964
- var __dirname_cli = path14.dirname(__filename_cli);
17519
+ var __dirname_cli = path15.dirname(__filename_cli);
16965
17520
  function resolveServerScript(env = process.env, metaDir = __dirname_cli) {
16966
17521
  if (env.BROWSE_SERVER_SCRIPT) {
16967
17522
  return env.BROWSE_SERVER_SCRIPT;
16968
17523
  }
16969
17524
  if (metaDir.startsWith("/")) {
16970
- const direct = path14.resolve(metaDir, "server.ts");
16971
- if (fs17.existsSync(direct)) {
17525
+ const direct = path15.resolve(metaDir, "server.ts");
17526
+ if (fs18.existsSync(direct)) {
16972
17527
  return direct;
16973
17528
  }
16974
17529
  }
16975
17530
  const selfPath = (0, import_url3.fileURLToPath)(__import_meta_url);
16976
- if (fs17.existsSync(selfPath)) {
17531
+ if (fs18.existsSync(selfPath)) {
16977
17532
  return "__self__";
16978
17533
  }
16979
17534
  throw new Error(
@@ -16983,7 +17538,7 @@ function resolveServerScript(env = process.env, metaDir = __dirname_cli) {
16983
17538
  var SERVER_SCRIPT = resolveServerScript();
16984
17539
  function readState() {
16985
17540
  try {
16986
- const data = fs17.readFileSync(STATE_FILE2, "utf-8");
17541
+ const data = fs18.readFileSync(STATE_FILE2, "utf-8");
16987
17542
  return JSON.parse(data);
16988
17543
  } catch {
16989
17544
  return null;
@@ -16999,7 +17554,7 @@ function isProcessAlive(pid) {
16999
17554
  }
17000
17555
  async function listInstances() {
17001
17556
  try {
17002
- const files = fs17.readdirSync(LOCAL_DIR3).filter(
17557
+ const files = fs18.readdirSync(LOCAL_DIR3).filter(
17003
17558
  (f) => f.startsWith("browse-server") && f.endsWith(".json") && !f.endsWith(".lock")
17004
17559
  );
17005
17560
  if (files.length === 0) {
@@ -17009,7 +17564,7 @@ async function listInstances() {
17009
17564
  let found = false;
17010
17565
  for (const file of files) {
17011
17566
  try {
17012
- const data = JSON.parse(fs17.readFileSync(path14.join(LOCAL_DIR3, file), "utf-8"));
17567
+ const data = JSON.parse(fs18.readFileSync(path15.join(LOCAL_DIR3, file), "utf-8"));
17013
17568
  if (!data.pid || !data.port) continue;
17014
17569
  const alive = isProcessAlive(data.pid);
17015
17570
  let status = "dead";
@@ -17032,7 +17587,7 @@ async function listInstances() {
17032
17587
  found = true;
17033
17588
  if (!alive) {
17034
17589
  try {
17035
- fs17.unlinkSync(path14.join(LOCAL_DIR3, file));
17590
+ fs18.unlinkSync(path15.join(LOCAL_DIR3, file));
17036
17591
  } catch {
17037
17592
  }
17038
17593
  }
@@ -17055,15 +17610,15 @@ function isBrowseProcess(pid) {
17055
17610
  }
17056
17611
  function acquireLock() {
17057
17612
  try {
17058
- fs17.writeFileSync(LOCK_FILE, String(process.pid), { flag: "wx" });
17613
+ fs18.writeFileSync(LOCK_FILE, String(process.pid), { flag: "wx" });
17059
17614
  return true;
17060
17615
  } catch (err) {
17061
17616
  if (err.code === "EEXIST") {
17062
17617
  try {
17063
- const stat = fs17.statSync(LOCK_FILE);
17618
+ const stat = fs18.statSync(LOCK_FILE);
17064
17619
  if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
17065
17620
  try {
17066
- fs17.unlinkSync(LOCK_FILE);
17621
+ fs18.unlinkSync(LOCK_FILE);
17067
17622
  } catch {
17068
17623
  }
17069
17624
  return acquireLock();
@@ -17077,7 +17632,7 @@ function acquireLock() {
17077
17632
  }
17078
17633
  function releaseLock() {
17079
17634
  try {
17080
- fs17.unlinkSync(LOCK_FILE);
17635
+ fs18.unlinkSync(LOCK_FILE);
17081
17636
  } catch {
17082
17637
  }
17083
17638
  }
@@ -17094,7 +17649,7 @@ async function startServer() {
17094
17649
  }
17095
17650
  await sleep(100);
17096
17651
  }
17097
- if (!fs17.existsSync(LOCK_FILE) || fs17.readFileSync(LOCK_FILE, "utf-8").trim() !== String(process.pid)) {
17652
+ if (!fs18.existsSync(LOCK_FILE) || fs18.readFileSync(LOCK_FILE, "utf-8").trim() !== String(process.pid)) {
17098
17653
  const state = readState();
17099
17654
  if (state && isProcessAlive(state.pid)) return state;
17100
17655
  throw new Error("Server failed to start (another process is starting it)");
@@ -17104,7 +17659,7 @@ async function startServer() {
17104
17659
  try {
17105
17660
  const oldState = readState();
17106
17661
  if (oldState && !isProcessAlive(oldState.pid)) {
17107
- fs17.unlinkSync(STATE_FILE2);
17662
+ fs18.unlinkSync(STATE_FILE2);
17108
17663
  }
17109
17664
  } catch {
17110
17665
  }
@@ -17190,7 +17745,7 @@ async function ensureServer() {
17190
17745
  }
17191
17746
  if (state) {
17192
17747
  try {
17193
- fs17.unlinkSync(STATE_FILE2);
17748
+ fs18.unlinkSync(STATE_FILE2);
17194
17749
  } catch {
17195
17750
  }
17196
17751
  }
@@ -17200,21 +17755,21 @@ async function ensureServer() {
17200
17755
  }
17201
17756
  function cleanOrphanedServers() {
17202
17757
  try {
17203
- const files = fs17.readdirSync(LOCAL_DIR3);
17758
+ const files = fs18.readdirSync(LOCAL_DIR3);
17204
17759
  for (const file of files) {
17205
17760
  if (!file.startsWith("browse-server") || !file.endsWith(".json") || file.endsWith(".lock")) continue;
17206
- const filePath = path14.join(LOCAL_DIR3, file);
17761
+ const filePath = path15.join(LOCAL_DIR3, file);
17207
17762
  if (filePath === STATE_FILE2) continue;
17208
17763
  try {
17209
- const data = JSON.parse(fs17.readFileSync(filePath, "utf-8"));
17764
+ const data = JSON.parse(fs18.readFileSync(filePath, "utf-8"));
17210
17765
  if (!data.pid) {
17211
- fs17.unlinkSync(filePath);
17766
+ fs18.unlinkSync(filePath);
17212
17767
  continue;
17213
17768
  }
17214
17769
  const suffixMatch = file.match(/browse-server-(\d+)\.json$/);
17215
17770
  if (suffixMatch && data.port === parseInt(suffixMatch[1], 10) && isProcessAlive(data.pid)) continue;
17216
17771
  if (!isProcessAlive(data.pid)) {
17217
- fs17.unlinkSync(filePath);
17772
+ fs18.unlinkSync(filePath);
17218
17773
  continue;
17219
17774
  }
17220
17775
  if (isBrowseProcess(data.pid)) {
@@ -17225,7 +17780,7 @@ function cleanOrphanedServers() {
17225
17780
  }
17226
17781
  } catch {
17227
17782
  try {
17228
- fs17.unlinkSync(filePath);
17783
+ fs18.unlinkSync(filePath);
17229
17784
  } catch {
17230
17785
  }
17231
17786
  }
@@ -17331,7 +17886,7 @@ async function sendCommand(state, command, args, retries = 0, sessionId) {
17331
17886
  await sleep(300);
17332
17887
  }
17333
17888
  try {
17334
- fs17.unlinkSync(STATE_FILE2);
17889
+ fs18.unlinkSync(STATE_FILE2);
17335
17890
  } catch {
17336
17891
  }
17337
17892
  if (command === "restart") {
@@ -17650,6 +18205,10 @@ React: react-devtools enable|disable|tree|props|suspense|errors
17650
18205
  Providers: provider save|list|delete <name> [api-key]
17651
18206
  Detect: detect (frameworks, CDN, third-party, SaaS, infra)
17652
18207
  Performance: perf-audit [url] [--no-coverage] [--no-detect] [--json]
18208
+ perf-audit save [name] Save audit report to .browse/audits/
18209
+ perf-audit compare <base> [curr] Compare saved audit vs current or another
18210
+ perf-audit list List saved audit reports
18211
+ perf-audit delete <name> Delete a saved audit report
17653
18212
  Debug: inspect (requires BROWSE_DEBUG_PORT)
17654
18213
  Server: status | instances | stop | restart | doctor | upgrade
17655
18214
  Setup: install-skill [path]
@@ -17704,7 +18263,7 @@ if (process.argv.includes("--mcp")) {
17704
18263
  Promise.resolve().then(() => (init_mcp(), mcp_exports)).then((m) => m.startMcpServer(jsonMode));
17705
18264
  } else if (process.env.__BROWSE_SERVER_MODE === "1") {
17706
18265
  Promise.resolve().then(() => init_server());
17707
- } else if (process.argv[1] && fs17.realpathSync(process.argv[1]) === fs17.realpathSync(__filename_cli)) {
18266
+ } else if (process.argv[1] && fs18.realpathSync(process.argv[1]) === fs18.realpathSync(__filename_cli)) {
17708
18267
  main().catch((err) => {
17709
18268
  console.error(`[browse] ${err.message}`);
17710
18269
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@ulpi/browse",
3
- "version": "1.4.1",
3
+ "version": "1.4.4",
4
+ "homepage": "https://browse.ulpi.io",
4
5
  "repository": {
5
6
  "type": "git",
6
7
  "url": "https://github.com/ulpi-io/browse"