@forkpoint/agent-lighthouse-core 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -17396,17 +17396,17 @@ function stubCheck(meta, tag, explanation) {
17396
17396
  tags: [tag]
17397
17397
  };
17398
17398
  }
17399
- async function runAudits(ctx, config, onProgress) {
17399
+ function planAudits(ctx, config) {
17400
17400
  const scannedPageTypes = new Set(ctx.pages.map((p) => p.pageType));
17401
- const all = [];
17402
- const allChecks = [];
17401
+ const runnable = [];
17402
+ const skipped = [];
17403
17403
  for (const cat of config.categories) {
17404
17404
  const regs = config.audits[cat.id] ?? [];
17405
17405
  for (const reg2 of regs) {
17406
17406
  const applicable = reg2.meta.applicablePageTypes;
17407
17407
  if (applicable && applicable.length > 0) {
17408
17408
  if (!applicable.some((pt) => scannedPageTypes.has(pt))) {
17409
- allChecks.push(
17409
+ skipped.push(
17410
17410
  stubCheck(
17411
17411
  reg2.meta,
17412
17412
  TAG_SKIPPED_PAGE_TYPE,
@@ -17416,30 +17416,35 @@ async function runAudits(ctx, config, onProgress) {
17416
17416
  continue;
17417
17417
  }
17418
17418
  }
17419
- all.push({ reg: reg2, categoryId: cat.id });
17419
+ runnable.push({ reg: reg2, categoryId: cat.id });
17420
17420
  }
17421
17421
  }
17422
- const totalAudits = all.length;
17423
- let completed = 0;
17422
+ return { runnable, skipped };
17423
+ }
17424
+ async function runAudits(ctx, config, onEvent, plan) {
17425
+ const { runnable, skipped } = plan ?? planAudits(ctx, config);
17426
+ const allChecks = [...skipped];
17424
17427
  const batchSize = 20;
17425
- for (let i = 0; i < totalAudits; i += batchSize) {
17426
- const batch = all.slice(i, i + batchSize);
17428
+ for (let i = 0; i < runnable.length; i += batchSize) {
17429
+ const batch = runnable.slice(i, i + batchSize);
17427
17430
  const batchResults = await Promise.all(
17428
17431
  batch.map(async ({ reg: reg2 }) => {
17432
+ const label2 = `${reg2.meta.id} ${reg2.meta.title}`;
17429
17433
  try {
17430
17434
  const instance = reg2.create();
17431
17435
  const result = await instance.audit(ctx);
17432
- return instance.toCheckResult(result);
17436
+ const check = instance.toCheckResult(result);
17437
+ onEvent?.({ type: "unit:done", label: label2 });
17438
+ return check;
17433
17439
  } catch (err) {
17434
17440
  logger.error({ err, auditId: reg2.meta.id }, "[scanner] Audit error");
17435
17441
  const message = err instanceof Error ? err.message : String(err);
17442
+ onEvent?.({ type: "unit:fail", label: label2, error: message });
17436
17443
  return stubCheck(reg2.meta, TAG_SCAN_ERROR, `Audit failed to run: ${message}`);
17437
17444
  }
17438
17445
  })
17439
17446
  );
17440
17447
  allChecks.push(...batchResults);
17441
- completed += batchResults.length;
17442
- onProgress?.(completed, totalAudits);
17443
17448
  }
17444
17449
  const categories = config.categories.map((cat) => {
17445
17450
  const catChecks = allChecks.filter((c) => c.category === cat.id);
@@ -17487,6 +17492,110 @@ function buildWeightedCategoryResult(cat, checks2, registrations) {
17487
17492
  };
17488
17493
  }
17489
17494
 
17495
+ // src/progress.ts
17496
+ var PHASE_WEIGHTS = {
17497
+ "fetch-root": 0.35,
17498
+ "fetch-pages": 0.2,
17499
+ analyze: 0.1,
17500
+ audits: 0.3,
17501
+ report: 0.05
17502
+ };
17503
+ var ProgressTracker = class {
17504
+ onEvent;
17505
+ startMs;
17506
+ doneWeight = 0;
17507
+ phase = null;
17508
+ phaseStartMs = 0;
17509
+ totalUnits = 0;
17510
+ completedUnits = 0;
17511
+ lastFraction = 0;
17512
+ constructor(onEvent) {
17513
+ this.onEvent = onEvent;
17514
+ this.startMs = performance.now();
17515
+ }
17516
+ /** Fraction of the whole scan that is complete, in [0, 1]. Never decreases. */
17517
+ get fraction() {
17518
+ let f = this.doneWeight;
17519
+ if (this.phase !== null) {
17520
+ const ratio = this.totalUnits > 0 ? Math.min(1, this.completedUnits / this.totalUnits) : 0;
17521
+ f += PHASE_WEIGHTS[this.phase] * ratio;
17522
+ }
17523
+ return Math.min(1, Math.max(f, this.lastFraction));
17524
+ }
17525
+ /** Stamp an event with the current fraction/elapsed and advance the floor. */
17526
+ stamp() {
17527
+ const fraction = this.fraction;
17528
+ this.lastFraction = Math.max(this.lastFraction, fraction);
17529
+ return { fraction, elapsedMs: this.elapsedMs() };
17530
+ }
17531
+ elapsedMs() {
17532
+ return Math.max(0, Math.round(performance.now() - this.startMs));
17533
+ }
17534
+ scanStart(url) {
17535
+ this.onEvent({ type: "scan:start", url, ...this.stamp() });
17536
+ }
17537
+ phaseStart(phase, totalUnits) {
17538
+ this.phase = phase;
17539
+ this.phaseStartMs = performance.now();
17540
+ this.totalUnits = Math.max(0, totalUnits);
17541
+ this.completedUnits = 0;
17542
+ this.onEvent({
17543
+ type: "phase:start",
17544
+ phase,
17545
+ totalUnits: this.totalUnits,
17546
+ ...this.stamp()
17547
+ });
17548
+ }
17549
+ /** Correct the current phase's unit total (e.g. discovery finds pages mid-phase). */
17550
+ setPhaseTotal(totalUnits) {
17551
+ this.totalUnits = Math.max(this.completedUnits, totalUnits);
17552
+ }
17553
+ unitDone(label2) {
17554
+ const phase = this.phase;
17555
+ if (phase === null) return;
17556
+ this.completedUnits += 1;
17557
+ this.onEvent({
17558
+ type: "unit:done",
17559
+ phase,
17560
+ completed: this.completedUnits,
17561
+ total: this.totalUnits,
17562
+ label: label2,
17563
+ ...this.stamp()
17564
+ });
17565
+ }
17566
+ /** A failed unit still counts as settled work so the phase can complete. */
17567
+ unitFail(label2, error) {
17568
+ const phase = this.phase;
17569
+ if (phase === null) return;
17570
+ this.completedUnits += 1;
17571
+ this.onEvent({
17572
+ type: "unit:fail",
17573
+ phase,
17574
+ label: label2,
17575
+ error,
17576
+ ...this.stamp()
17577
+ });
17578
+ }
17579
+ phaseDone() {
17580
+ const phase = this.phase;
17581
+ if (phase === null) return;
17582
+ this.phase = null;
17583
+ this.doneWeight += PHASE_WEIGHTS[phase];
17584
+ const durationMs = Math.max(0, Math.round(performance.now() - this.phaseStartMs));
17585
+ this.totalUnits = 0;
17586
+ this.completedUnits = 0;
17587
+ this.onEvent({
17588
+ type: "phase:done",
17589
+ phase,
17590
+ durationMs,
17591
+ ...this.stamp()
17592
+ });
17593
+ }
17594
+ scanDone(score) {
17595
+ this.onEvent({ type: "scan:done", durationMs: this.elapsedMs(), score, ...this.stamp() });
17596
+ }
17597
+ };
17598
+
17490
17599
  // src/audits/accessibility/runner.ts
17491
17600
  import { JSDOM } from "jsdom";
17492
17601
 
@@ -22998,9 +23107,11 @@ function discoverPages(homepageUrl, domain, rootFiles, homepage$, exclude, maxAd
22998
23107
  }
22999
23108
  return selected;
23000
23109
  }
23001
- async function runScan(url, onProgress, pageOverrides, signal) {
23002
- const progress = onProgress ?? (() => {
23003
- });
23110
+ async function runScan(url, options) {
23111
+ const onEvent = options?.onEvent;
23112
+ const pageOverrides = options?.pages;
23113
+ const signal = options?.signal;
23114
+ const tracker = new ProgressTracker((event) => onEvent?.(event));
23004
23115
  const start = performance.now();
23005
23116
  const fetcher = createFetcher();
23006
23117
  const baseUrl = new URL(url).origin;
@@ -23023,7 +23134,7 @@ async function runScan(url, onProgress, pageOverrides, signal) {
23023
23134
  }
23024
23135
  logger.debug({ url, domain }, "[orchestrator] Starting runScan");
23025
23136
  signal?.throwIfAborted();
23026
- await progress(5, "Fetching root files");
23137
+ tracker.scanStart(displayUrl);
23027
23138
  const rootFilePaths = [
23028
23139
  "/robots.txt",
23029
23140
  "/llms.txt",
@@ -23061,19 +23172,26 @@ async function runScan(url, onProgress, pageOverrides, signal) {
23061
23172
  "/our-story"
23062
23173
  ];
23063
23174
  logger.debug({ count: rootFilePaths.length }, "[orchestrator] Phase 1: Fetching root files");
23175
+ tracker.phaseStart("fetch-root", rootFilePaths.length);
23064
23176
  const rootResults = await Promise.all(
23065
- rootFilePaths.map((path) => fetcher.fetch({ url: `${baseUrl}${path}`, signal }))
23177
+ rootFilePaths.map(
23178
+ (path) => fetcher.fetch({ url: `${baseUrl}${path}`, signal }).then((result) => {
23179
+ tracker.unitDone(path);
23180
+ return result;
23181
+ })
23182
+ )
23066
23183
  );
23067
23184
  const rootFiles = {};
23068
23185
  rootFilePaths.forEach((path, i) => {
23069
23186
  rootFiles[path] = rootResults[i];
23070
23187
  });
23071
- await progress(25, "Root files fetched");
23188
+ tracker.phaseDone();
23072
23189
  logger.debug("[orchestrator] Phase 1 complete: Root files fetched");
23073
23190
  signal?.throwIfAborted();
23074
- await progress(30, "Fetching pages");
23075
23191
  logger.debug("[orchestrator] Phase 2: Fetching pages");
23192
+ tracker.phaseStart("fetch-pages", 1);
23076
23193
  const homepageResult = await fetcher.fetch({ url, signal });
23194
+ tracker.unitDone(displayUrl);
23077
23195
  const homepage$ = homepageResult.status === 200 && homepageResult.body ? parseHtml(homepageResult.body) : null;
23078
23196
  const discoverLimit = Math.max(0, MAX_PAGES_PER_SCAN - 1 - overrideUrls.length);
23079
23197
  const discoveredUrls = homepage$ ? discoverPages(url, domain, rootFiles, homepage$, new Set(overrideTypeByKey.keys()), discoverLimit) : [];
@@ -23082,12 +23200,22 @@ async function runScan(url, onProgress, pageOverrides, signal) {
23082
23200
  "[orchestrator] Page set: overrides + discovered URLs"
23083
23201
  );
23084
23202
  const extraUrls = [...overrideUrls, ...discoveredUrls];
23085
- await progress(40, `Analyzing ${1 + extraUrls.length} pages`);
23203
+ tracker.setPhaseTotal(1 + extraUrls.length);
23086
23204
  const extraResults = await Promise.all(
23087
- extraUrls.map((pageUrl) => fetcher.fetch({ url: pageUrl, signal }))
23205
+ extraUrls.map(
23206
+ (pageUrl) => fetcher.fetch({ url: pageUrl, signal }).then((result) => {
23207
+ tracker.unitDone(pageUrl);
23208
+ return result;
23209
+ })
23210
+ )
23088
23211
  );
23212
+ tracker.phaseDone();
23089
23213
  const allPageResults = [homepageResult, ...extraResults];
23090
23214
  const allPageUrls = [displayUrl, ...extraUrls];
23215
+ tracker.phaseStart(
23216
+ "analyze",
23217
+ allPageResults.filter((r) => r.status === 200 && r.body).length
23218
+ );
23091
23219
  const pages = allPageResults.map((r, i) => ({ result: r, url: allPageUrls[i], index: i })).filter((p) => p.result.status === 200 && p.result.body).map((p) => {
23092
23220
  const $ = parseHtml(p.result.body);
23093
23221
  const jsonLd = extractJsonLd($);
@@ -23095,6 +23223,7 @@ async function runScan(url, onProgress, pageOverrides, signal) {
23095
23223
  const meta = extractMetaTags($);
23096
23224
  const isFirstPage = p.index === 0;
23097
23225
  const forcedType = overrideTypeByKey.get(p.url.replace(/\/$/, ""));
23226
+ tracker.unitDone(p.url);
23098
23227
  return {
23099
23228
  url: p.url,
23100
23229
  pageType: forcedType ?? detectPageType(p.url, $, structuredData, meta, isFirstPage),
@@ -23112,13 +23241,12 @@ async function runScan(url, onProgress, pageOverrides, signal) {
23112
23241
  p.a11yResults = await runA11yForHtml(p.fetchResult.body, p.url, A11Y_RULES);
23113
23242
  })
23114
23243
  );
23115
- await progress(55, "Page analysis complete");
23244
+ tracker.phaseDone();
23116
23245
  logger.debug(
23117
23246
  { pagesAnalyzed: pages.length },
23118
23247
  "[orchestrator] Phase 2 complete: Page analysis complete"
23119
23248
  );
23120
23249
  signal?.throwIfAborted();
23121
- await progress(60, "Running audits");
23122
23250
  logger.debug("[orchestrator] Phase 3: Running audits");
23123
23251
  const wafProtection = detectWafProtection(url, homepageResult, rootFiles, pages.length);
23124
23252
  const ctx = {
@@ -23126,19 +23254,27 @@ async function runScan(url, onProgress, pageOverrides, signal) {
23126
23254
  pages,
23127
23255
  domain,
23128
23256
  baseUrl,
23129
- fetch: (options) => fetcher.fetch({ ...options, signal }),
23257
+ fetch: (options2) => fetcher.fetch({ ...options2, signal }),
23130
23258
  wafProtection: wafProtection ?? void 0
23131
23259
  };
23260
+ const auditPlan = planAudits(ctx, defaultConfig);
23261
+ tracker.phaseStart("audits", auditPlan.runnable.length);
23132
23262
  const {
23133
23263
  checks: allChecks,
23134
23264
  categories,
23135
23265
  overallScore
23136
- } = await runAudits(ctx, defaultConfig, (completed, total) => {
23137
- const checkProgress = 60 + Math.round(completed / total * 30);
23138
- void progress(checkProgress, `Running audits (${completed}/${total})`);
23139
- });
23266
+ } = await runAudits(
23267
+ ctx,
23268
+ defaultConfig,
23269
+ (event) => {
23270
+ if (event.type === "unit:done") tracker.unitDone(event.label);
23271
+ else tracker.unitFail(event.label, event.error);
23272
+ },
23273
+ auditPlan
23274
+ );
23275
+ tracker.phaseDone();
23140
23276
  logger.debug("[orchestrator] Phase 3 complete: Audits finished");
23141
- await progress(97, "Building report");
23277
+ tracker.phaseStart("report", 1);
23142
23278
  logger.debug("[orchestrator] Phase 4: Building final report");
23143
23279
  const durationMs = Math.round(performance.now() - start);
23144
23280
  const recommendations = allChecks.filter((c) => c.status !== "pass").slice().sort((a, b) => {
@@ -23155,7 +23291,6 @@ async function runScan(url, onProgress, pageOverrides, signal) {
23155
23291
  const topPasses = allChecks.filter((c) => c.status === "pass").slice().sort(
23156
23292
  (a, b) => (weightMap.get(b.id) ?? 1) - (weightMap.get(a.id) ?? 1)
23157
23293
  ).slice(0, 10);
23158
- await progress(100, "Complete");
23159
23294
  const readinessVitals = calculateReadinessVitals(allChecks);
23160
23295
  const readinessScore = Math.round(
23161
23296
  readinessVitals.commerce * READINESS_WEIGHTS.commerce + readinessVitals.content * READINESS_WEIGHTS.content + readinessVitals.botAccessibility * READINESS_WEIGHTS.botAccessibility + readinessVitals.technical * READINESS_WEIGHTS.technical
@@ -23185,6 +23320,9 @@ async function runScan(url, onProgress, pageOverrides, signal) {
23185
23320
  productFields: [...overrideTypeByKey.values()].includes("product") ? extractProductFieldVerification(pages) : void 0
23186
23321
  };
23187
23322
  report.summary = generateScanSummary(report);
23323
+ tracker.unitDone();
23324
+ tracker.phaseDone();
23325
+ tracker.scanDone(overallScore);
23188
23326
  logger.debug({ durationMs, score: overallScore }, "[orchestrator] runScan complete");
23189
23327
  return report;
23190
23328
  }
@@ -23369,7 +23507,9 @@ export {
23369
23507
  MAX_PAGES_PER_SCAN,
23370
23508
  MAX_RESPONSE_BODY_BYTES,
23371
23509
  PAGE_TYPE_LABELS,
23510
+ PHASE_WEIGHTS,
23372
23511
  PRESETS,
23512
+ ProgressTracker,
23373
23513
  READINESS_WEIGHTS,
23374
23514
  REQUEST_TIMEOUT_MS,
23375
23515
  SCANNER_USER_AGENT,
@@ -23413,6 +23553,7 @@ export {
23413
23553
  logger,
23414
23554
  normalizeUrl,
23415
23555
  parseHtml,
23556
+ planAudits,
23416
23557
  runAudits,
23417
23558
  runScan
23418
23559
  };