@decocms/parity 0.11.13 → 0.11.16

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/CHANGELOG.md +36 -0
  2. package/dist/cli.js +180 -61
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
+ ## [0.11.16](https://github.com/decocms/parity/compare/v0.11.15...v0.11.16) (2026-06-17)
9
+
10
+ ### Changed
11
+
12
+ * **All checks now run in parallel.** `runAllChecks` previously walked the ~27 checks sequentially with `for (const check of ALL_CHECKS) await check(ctx)`, taking ~4m26s on bagaggio. Almost every check is a pure CPU aggregation over already-captured `PageCapture[]` data (string diffs, regex matches, console-entry filtering); the 3 network-bound ones (`seo-deep-audit`, `footer-links-health`, `plp-pagination`) are I/O-bound and parallelize cleanly too. Now uses `Promise.all` — the whole checks phase is dominated by the slowest single check. **Expected speedup: ~4m26s → ~30-60s** on bagaggio.
13
+ * **Console errors now dedupe across pages.** Previously the same error message ("A chave utilizada não corresponde ao domínio…") that appeared on 4 pages produced 4 separate top-level issues, crowding the report. Now `consoleErrorsBaseline` groups by normalized error key across all page pairs and emits ONE issue per unique error with the affected-pages list inline:
14
+ ```
15
+ [high] [generic] novo erro de console em 4 páginas (/ · /s · /search · /:::desktop): A chave utilizada não corresponde…
16
+ ```
17
+ Direct response to user feedback ("console log não deveria ter um teste pra ele … fazer o dedup e descrever quais páginas tiveram aquele error"). The schema-level Issue shape is unchanged (page = first affected, details = full list).
18
+
19
+ ## [0.11.15](https://github.com/decocms/parity/compare/v0.11.14...v0.11.15) (2026-06-17)
20
+
21
+ ### Changed
22
+
23
+ * **Viewports now run in parallel during collect.** Building on 0.11.14's parallel-sides change, the outer `for (const viewport of viewports)` loop is now wrapped in `runWithConcurrency` so the default `mobile,desktop` set runs fully concurrent — 4 BrowserContexts simultaneously (mobile/prod + mobile/cand + desktop/prod + desktop/cand). **Expected collect speedup: another ~40% on top of 0.11.14** (~9min → ~5–6min on bagaggio).
24
+ * **New `--max-viewport-concurrency <n>` flag (default 2).** Lets memory-constrained machines fall back to serial-viewport behavior, and caps concurrency for runs that add a 3rd viewport (e.g. `--viewports mobile,tablet,desktop`).
25
+ * **LLM call concurrency cap.** With 4 sides running, recovery-budget × side count could push 8–12 simultaneous LLM calls — past Anthropic tier-1's 4 RPS. Added a process-global semaphore in `src/llm/client.ts` (3 concurrent slots, queue thereafter) so calls never blow up with 429s — they queue. Default of 3 leaves headroom for the post-collect aggregate call.
26
+
27
+ ## [0.11.14](https://github.com/decocms/parity/compare/v0.11.13...v0.11.14) (2026-06-17)
28
+
29
+ ### Added
30
+
31
+ * **Always-visible elapsed counter.** A 1-second ticker rewrites the spinner with `⏱ 04:32 · <current label>` from the moment "Launching browser…" appears until the report writes. No more "is it stuck?" minutes of silence — the user knows exactly how long the run has been going at any moment. Cleared in `finally` so it can't survive past `runCommand`.
32
+ * **Per-flow timing in the bottom summary.** New `flows breakdown` block lists each flow's `max` time (the parallel-wall-clock contribution) plus per-side detail:
33
+ ```
34
+ flows breakdown (sides run in parallel within viewport)
35
+ purchase-journey max 1m32s · mobile/prod 52s · mobile/cand 58s · desk/prod 89s · desk/cand 90s
36
+ search max 1m48s · …
37
+ ```
38
+ Driven by the existing `FlowCapture.totalDurationMs` (already populated by `finalize` — just wasn't surfaced).
39
+
40
+ ### Changed
41
+
42
+ * **Prod + cand now run in parallel within each viewport.** `parity run`'s collect loop was 100% sequential: `for viewport { for side { for flow {…} } }`. The two sides for a given viewport are already independent (separate BrowserContexts, separate HAR/trace paths), so the same `Promise.all([prod, cand])` pattern that `parity journey` has used for months now applies to `parity run` too. Extracted a `runOneSide(viewport, side)` helper, replaced the inner side loop with `Promise.all`, and deferred `promoteStepsFromFlow` (which mutates the shared `learned` object) until after `Promise.all` resolves so there's no race on selector promotion. **Expected speedup: ~50% on collect phase** (25m → ~17m total on bagaggio). Viewports still serialize for now — PR #2 will parallelize those too.
43
+
8
44
  ## [0.11.13](https://github.com/decocms/parity/compare/v0.11.12...v0.11.13) (2026-06-17)
9
45
 
10
46
  ### Fixed
package/dist/cli.js CHANGED
@@ -23409,19 +23409,42 @@ function makeTimeoutSignal(ms) {
23409
23409
  const t = setTimeout(() => controller.abort(new Error(`LLM call timed out after ${ms}ms`)), ms);
23410
23410
  return { signal: controller.signal, clear: () => clearTimeout(t) };
23411
23411
  }
23412
+ function acquireLlmSlot() {
23413
+ if (activeLlmCalls < MAX_CONCURRENT_LLM_CALLS) {
23414
+ activeLlmCalls++;
23415
+ return Promise.resolve();
23416
+ }
23417
+ return new Promise((resolve) => {
23418
+ llmQueue.push(() => {
23419
+ activeLlmCalls++;
23420
+ resolve();
23421
+ });
23422
+ });
23423
+ }
23424
+ function releaseLlmSlot() {
23425
+ activeLlmCalls--;
23426
+ const next2 = llmQueue.shift();
23427
+ if (next2)
23428
+ next2();
23429
+ }
23412
23430
  async function callTool(params) {
23413
23431
  const provider = getProvider();
23414
23432
  if (!provider)
23415
23433
  return null;
23416
23434
  const model = resolveModel(params.feature, provider);
23417
23435
  const timeoutMs = params.timeoutMs ?? defaultTimeout(params);
23418
- if (provider === "anthropic")
23419
- return callAnthropicTool(params, model, timeoutMs);
23420
- if (provider === "openrouter")
23421
- return callOpenRouterTool(params, model, timeoutMs);
23422
- if (provider === "claude-agent-sdk")
23423
- return callToolSdk({ ...params, model, timeoutMs });
23424
- return null;
23436
+ await acquireLlmSlot();
23437
+ try {
23438
+ if (provider === "anthropic")
23439
+ return await callAnthropicTool(params, model, timeoutMs);
23440
+ if (provider === "openrouter")
23441
+ return await callOpenRouterTool(params, model, timeoutMs);
23442
+ if (provider === "claude-agent-sdk")
23443
+ return await callToolSdk({ ...params, model, timeoutMs });
23444
+ return null;
23445
+ } finally {
23446
+ releaseLlmSlot();
23447
+ }
23425
23448
  }
23426
23449
  async function callAnthropicTool(params, model, timeoutMs) {
23427
23450
  const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY ?? "" });
@@ -23629,13 +23652,18 @@ async function callMessage(params) {
23629
23652
  return null;
23630
23653
  const model = resolveModel(params.feature, provider);
23631
23654
  const timeoutMs = params.timeoutMs ?? 60000;
23632
- if (provider === "anthropic")
23633
- return callAnthropicMessage(params, model, timeoutMs);
23634
- if (provider === "openrouter")
23635
- return callOpenRouterMessage(params, model, timeoutMs);
23636
- if (provider === "claude-agent-sdk")
23637
- return callMessageSdk({ ...params, model, timeoutMs });
23638
- return null;
23655
+ await acquireLlmSlot();
23656
+ try {
23657
+ if (provider === "anthropic")
23658
+ return await callAnthropicMessage(params, model, timeoutMs);
23659
+ if (provider === "openrouter")
23660
+ return await callOpenRouterMessage(params, model, timeoutMs);
23661
+ if (provider === "claude-agent-sdk")
23662
+ return await callMessageSdk({ ...params, model, timeoutMs });
23663
+ return null;
23664
+ } finally {
23665
+ releaseLlmSlot();
23666
+ }
23639
23667
  }
23640
23668
  async function callAnthropicMessage(params, model, timeoutMs) {
23641
23669
  const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY ?? "" });
@@ -23695,12 +23723,13 @@ async function callOpenRouterMessage(params, model, timeoutMs) {
23695
23723
  clear();
23696
23724
  }
23697
23725
  }
23698
- var LLM_MODEL_ANTHROPIC, LLM_MODEL_OPENROUTER, forcedProvider = null, llmDisabled = false, llmLanguage = "en";
23726
+ var LLM_MODEL_ANTHROPIC, LLM_MODEL_OPENROUTER, forcedProvider = null, llmDisabled = false, llmLanguage = "en", MAX_CONCURRENT_LLM_CALLS = 3, activeLlmCalls = 0, llmQueue;
23699
23727
  var init_client = __esm(() => {
23700
23728
  init_models();
23701
23729
  init_claude_agent_sdk();
23702
23730
  LLM_MODEL_ANTHROPIC = PROVIDER_MODELS.anthropic.sonnet;
23703
23731
  LLM_MODEL_OPENROUTER = PROVIDER_MODELS.openrouter.sonnet;
23732
+ llmQueue = [];
23704
23733
  });
23705
23734
 
23706
23735
  // src/cli.ts
@@ -43862,7 +43891,7 @@ function detailFor(prod, cand, viewport) {
43862
43891
  function consoleErrorsBaseline(ctx) {
43863
43892
  const start = Date.now();
43864
43893
  const { pairs } = pairCaptures(ctx.prodPages, ctx.candPages);
43865
- const issues = [];
43894
+ const byKey = new Map;
43866
43895
  let totalNew = 0;
43867
43896
  for (const pair of pairs) {
43868
43897
  const diff = diffConsole(pair.prod.console, pair.cand.console, {
@@ -43871,24 +43900,45 @@ function consoleErrorsBaseline(ctx) {
43871
43900
  });
43872
43901
  totalNew += diff.newInCand.length;
43873
43902
  for (const e of diff.newInCand) {
43874
- issues.push({
43875
- id: `console:${pair.key}:${hash(e.key)}`,
43876
- severity: e.cls === "hydration" ? "critical" : "high",
43877
- category: "console",
43878
- page: pair.key,
43879
- check: "console-errors-baseline",
43880
- summary: `[${e.cls}] novo erro de console em ${pair.key}: ${truncate4(e.entry.text, 160)}`,
43881
- details: e.entry.text,
43882
- evidence: [{ kind: "screenshot", path: pair.cand.screenshotPath, label: "cand" }]
43883
- });
43903
+ const existing = byKey.get(e.key);
43904
+ if (!existing) {
43905
+ byKey.set(e.key, {
43906
+ cls: e.cls,
43907
+ sampleText: e.entry.text,
43908
+ pages: [pair.key],
43909
+ sampleEvidence: [
43910
+ { kind: "screenshot", path: pair.cand.screenshotPath, label: "cand" }
43911
+ ]
43912
+ });
43913
+ } else if (!existing.pages.includes(pair.key)) {
43914
+ existing.pages.push(pair.key);
43915
+ }
43884
43916
  }
43885
43917
  }
43918
+ const issues = [];
43919
+ for (const [key, agg] of byKey) {
43920
+ const pageList = agg.pages.length <= 5 ? agg.pages.join(" · ") : `${agg.pages.slice(0, 5).join(" · ")} +${agg.pages.length - 5} more`;
43921
+ const pageSuffix = agg.pages.length === 1 ? `em ${agg.pages[0]}` : `em ${agg.pages.length} páginas (${pageList})`;
43922
+ const severity = agg.cls === "hydration" ? "critical" : "high";
43923
+ issues.push({
43924
+ id: `console:${hash(key)}`,
43925
+ severity,
43926
+ category: "console",
43927
+ page: agg.pages[0],
43928
+ check: "console-errors-baseline",
43929
+ summary: `[${agg.cls}] novo erro de console ${pageSuffix}: ${truncate4(agg.sampleText, 160)}`,
43930
+ details: `${agg.sampleText}
43931
+
43932
+ Observed on: ${agg.pages.join(", ")}`,
43933
+ evidence: agg.sampleEvidence
43934
+ });
43935
+ }
43886
43936
  return {
43887
43937
  name: "console-errors-baseline",
43888
43938
  status: issues.length > 0 ? "fail" : "pass",
43889
43939
  severity: "critical",
43890
43940
  durationMs: Date.now() - start,
43891
- summary: `${totalNew} novo(s) erro(s) de console em cand não presentes em prod`,
43941
+ summary: issues.length > 0 ? `${issues.length} erro(s) único(s) novo(s) em cand (${totalNew} ocorrência(s) somando todas as páginas)` : `${totalNew} novo(s) erro(s) de console em cand não presentes em prod`,
43892
43942
  issues
43893
43943
  };
43894
43944
  }
@@ -47155,7 +47205,7 @@ function getCheckByName(name) {
47155
47205
  }
47156
47206
  async function runAllChecks(ctx, outResults) {
47157
47207
  const results = outResults ?? [];
47158
- for (const check of ALL_CHECKS) {
47208
+ await Promise.all(ALL_CHECKS.map(async (check) => {
47159
47209
  const start = Date.now();
47160
47210
  try {
47161
47211
  const r = await check(ctx);
@@ -47170,7 +47220,7 @@ async function runAllChecks(ctx, outResults) {
47170
47220
  issues: []
47171
47221
  });
47172
47222
  }
47173
- }
47223
+ }));
47174
47224
  return results;
47175
47225
  }
47176
47226
 
@@ -54522,7 +54572,7 @@ class TimingRegistry {
54522
54572
  };
54523
54573
  }
54524
54574
  }
54525
- function formatTimingsSummary(timings) {
54575
+ function formatTimingsSummary(timings, flowBreakdown) {
54526
54576
  if (timings.phases.length === 0) {
54527
54577
  return `⏱ Run completed in ${formatMs(timings.totalMs)}`;
54528
54578
  }
@@ -54535,9 +54585,41 @@ function formatTimingsSummary(timings) {
54535
54585
  const pctOfTotal = timings.totalMs > 0 ? Math.round(p.durationMs / timings.totalMs * 100) : 0;
54536
54586
  lines.push(` ${p.phase.padEnd(maxLabel)} ${formatMs(p.durationMs).padStart(7)} ${bar} ${pctOfTotal}%`);
54537
54587
  }
54588
+ if (flowBreakdown && flowBreakdown.length > 0) {
54589
+ const maxFlowLabel = Math.max(...flowBreakdown.map((f) => f.flow.length));
54590
+ lines.push("");
54591
+ lines.push(" flows breakdown (sides run in parallel within viewport)");
54592
+ for (const f of flowBreakdown) {
54593
+ const sideDetail = f.sides.map((s) => `${s.viewport}/${s.side} ${formatMs(s.durationMs)}`).join(" · ");
54594
+ lines.push(` ${f.flow.padEnd(maxFlowLabel)} max ${formatMs(f.maxMs).padStart(6)} · ${sideDetail}`);
54595
+ }
54596
+ }
54538
54597
  return lines.join(`
54539
54598
  `);
54540
54599
  }
54600
+ function buildFlowBreakdown(captures) {
54601
+ const byFlow = new Map;
54602
+ for (const c of captures) {
54603
+ const existing = byFlow.get(c.flow);
54604
+ const entry = {
54605
+ viewport: c.viewport,
54606
+ side: c.side,
54607
+ durationMs: c.totalDurationMs
54608
+ };
54609
+ if (!existing) {
54610
+ byFlow.set(c.flow, {
54611
+ flow: c.flow,
54612
+ maxMs: c.totalDurationMs,
54613
+ sides: [entry]
54614
+ });
54615
+ } else {
54616
+ existing.sides.push(entry);
54617
+ if (c.totalDurationMs > existing.maxMs)
54618
+ existing.maxMs = c.totalDurationMs;
54619
+ }
54620
+ }
54621
+ return Array.from(byFlow.values());
54622
+ }
54541
54623
  function formatMs(ms) {
54542
54624
  const totalSec = Math.floor(ms / 1000);
54543
54625
  if (totalSec < 60)
@@ -55069,6 +55151,21 @@ async function runCommand(rawOpts) {
55069
55151
  let browser = null;
55070
55152
  const allFlowCaptures = [];
55071
55153
  const allPageCaptures = [];
55154
+ let currentSpinnerLabel = "Launching browser…";
55155
+ const renderSpinner = () => {
55156
+ const elapsedSec = Math.floor((Date.now() - startedAt) / 1000);
55157
+ const min = Math.floor(elapsedSec / 60);
55158
+ const sec = elapsedSec % 60;
55159
+ const mmss = `${min.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}`;
55160
+ spinner.text = `${chalk16.dim(`⏱ ${mmss} ·`)} ${currentSpinnerLabel}`;
55161
+ };
55162
+ const setSpinnerLabel = (label) => {
55163
+ currentSpinnerLabel = label;
55164
+ renderSpinner();
55165
+ };
55166
+ setSpinnerLabel("Launching browser…");
55167
+ const elapsedTicker = setInterval(renderSpinner, 1000);
55168
+ elapsedTicker.unref?.();
55072
55169
  const timings = new TimingRegistry;
55073
55170
  let phaseStart = performance.now();
55074
55171
  const stampPhase = (label) => {
@@ -55113,7 +55210,7 @@ async function runCommand(rawOpts) {
55113
55210
  process.stderr.write(` report parcial em ${paths.reportHtml}
55114
55211
  `);
55115
55212
  process.stderr.write(`
55116
- ${formatTimingsSummary(partialTimings)}
55213
+ ${formatTimingsSummary(partialTimings, buildFlowBreakdown(allFlowCaptures))}
55117
55214
  `);
55118
55215
  } catch (err) {
55119
55216
  process.stderr.write(` falha escrevendo report parcial: ${err.message}
@@ -55155,26 +55252,27 @@ ${formatTimingsSummary(partialTimings)}
55155
55252
  warmupSpinner.fail(`Warmup falhou — 0/${result.attempted} requests ok. Cache pode estar stale. Primeira falha: ${firstReason}`);
55156
55253
  }
55157
55254
  }
55158
- for (const viewport of viewports) {
55159
- for (const side of ["prod", "cand"]) {
55160
- const baseUrl = side === "prod" ? opts.prod : opts.cand;
55161
- spinner.text = `[${viewport}/${side}] preparing context…`;
55162
- const harPath = join16(paths.harDir, `${viewport}-${side}.har`);
55163
- const tracePath = join16(paths.tracesDir, `${viewport}-${side}.zip`);
55164
- const ctx = await newContext(browser, {
55165
- viewport,
55166
- harPath,
55167
- tracesDir: paths.tracesDir,
55168
- cohortCookieValue: "control",
55169
- noCache: opts.bypassCache
55170
- });
55171
- await installVitalsCollector(ctx);
55255
+ const runOneSide = async (viewport, side) => {
55256
+ const baseUrl = side === "prod" ? opts.prod : opts.cand;
55257
+ const harPath = join16(paths.harDir, `${viewport}-${side}.har`);
55258
+ const tracePath = join16(paths.tracesDir, `${viewport}-${side}.zip`);
55259
+ const ctx = await newContext(browser, {
55260
+ viewport,
55261
+ harPath,
55262
+ tracesDir: paths.tracesDir,
55263
+ cohortCookieValue: "control",
55264
+ noCache: opts.bypassCache
55265
+ });
55266
+ await installVitalsCollector(ctx);
55267
+ const captures = [];
55268
+ const pages = [];
55269
+ try {
55172
55270
  for (const flow of flows) {
55173
55271
  const flowStart = Date.now();
55174
55272
  let lastStepTotal = 0;
55175
55273
  const sideTag = side === "prod" ? chalk16.cyan("prod") : chalk16.magenta("cand");
55176
55274
  const prefix = `${chalk16.dim(`[${viewport}/`)}${sideTag}${chalk16.dim("]")}`;
55177
- spinner.text = `${prefix} ${flow}: starting…`;
55275
+ setSpinnerLabel(`${prefix} ${flow}: starting…`);
55178
55276
  const cap = await runFlow(flow, {
55179
55277
  baseUrl,
55180
55278
  side,
@@ -55190,7 +55288,7 @@ ${formatTimingsSummary(partialTimings)}
55190
55288
  onStep: (event) => {
55191
55289
  if (event.phase === "start") {
55192
55290
  lastStepTotal = event.total;
55193
- spinner.text = `${prefix} ${flow} ${chalk16.dim(`${event.index}/${event.total}`)} ${event.name}…`;
55291
+ setSpinnerLabel(`${prefix} ${flow} ${chalk16.dim(`${event.index}/${event.total}`)} ${event.name}…`);
55194
55292
  }
55195
55293
  }
55196
55294
  });
@@ -55199,7 +55297,7 @@ ${formatTimingsSummary(partialTimings)}
55199
55297
  const target = lastStepTotal || recorded;
55200
55298
  const failed = cap.steps?.find((s) => s.status === "failed");
55201
55299
  const lastStep = cap.steps?.[cap.steps.length - 1];
55202
- const elapsed = chalk16.dim(`${((Date.now() - flowStart) / 1000).toFixed(1)}s`);
55300
+ const elapsedDim = chalk16.dim(`${((Date.now() - flowStart) / 1000).toFixed(1)}s`);
55203
55301
  const counts = target > 0 ? chalk16.dim(`${okSteps}/${target}`) : "";
55204
55302
  let glyph;
55205
55303
  let detail;
@@ -55213,24 +55311,42 @@ ${formatTimingsSummary(partialTimings)}
55213
55311
  glyph = chalk16.green("✓");
55214
55312
  detail = "";
55215
55313
  }
55216
- const summary = `${glyph} ${prefix} ${flow} ${counts} ${detail} ${elapsed}`.replace(/\s+/g, " ").trim();
55314
+ const summary = `${glyph} ${prefix} ${flow} ${counts} ${detail} ${elapsedDim}`.replace(/\s+/g, " ").trim();
55217
55315
  spinner.stopAndPersist({ symbol: "", text: summary });
55218
- spinner.start(`${prefix} ${flow} done — next…`);
55219
- allFlowCaptures.push(cap);
55316
+ setSpinnerLabel(`${prefix} ${flow} done — next…`);
55317
+ spinner.start();
55318
+ captures.push(cap);
55220
55319
  for (const p of cap.pages)
55221
- allPageCaptures.push(p);
55320
+ pages.push(p);
55321
+ }
55322
+ } finally {
55323
+ await stopTracing(ctx, tracePath).catch(() => {
55324
+ return;
55325
+ });
55326
+ await ctx.close();
55327
+ }
55328
+ return { captures, pages };
55329
+ };
55330
+ const runOneViewport = async (viewport) => {
55331
+ const [prodResult, candResult] = await Promise.all([
55332
+ runOneSide(viewport, "prod"),
55333
+ runOneSide(viewport, "cand")
55334
+ ]);
55335
+ for (const r of [prodResult, candResult]) {
55336
+ for (const cap of r.captures) {
55337
+ allFlowCaptures.push(cap);
55222
55338
  if (opts.learn !== false) {
55223
55339
  const result = promoteStepsFromFlow(learned, platform, prodHost, cap);
55224
55340
  promotedCount += result.promoted;
55225
55341
  deprecatedCount += result.deprecated;
55226
55342
  }
55227
55343
  }
55228
- await stopTracing(ctx, tracePath).catch(() => {
55229
- return;
55230
- });
55231
- await ctx.close();
55344
+ for (const p of r.pages)
55345
+ allPageCaptures.push(p);
55232
55346
  }
55233
- }
55347
+ };
55348
+ const maxViewportConcurrency = Math.max(1, opts.maxViewportConcurrency ?? 2);
55349
+ await runWithConcurrency2(viewports, maxViewportConcurrency, runOneViewport);
55234
55350
  stampPhase("collect");
55235
55351
  spinner.succeed("Coleta concluída");
55236
55352
  if (isLlmAvailable()) {
@@ -55449,7 +55565,8 @@ ${formatTimingsSummary(partialTimings)}
55449
55565
  }
55450
55566
  }
55451
55567
  currentPhase = "checks";
55452
- spinner.start("Rodando checks…");
55568
+ setSpinnerLabel("Rodando checks…");
55569
+ spinner.start();
55453
55570
  const checksHb = attachSpinnerHeartbeat(spinner, { baseText: "Rodando checks…" });
55454
55571
  const cacheDir = join16(opts.output, "cache");
55455
55572
  const checkCtx = {
@@ -55474,7 +55591,8 @@ ${formatTimingsSummary(partialTimings)}
55474
55591
  }
55475
55592
  const allIssues = checks.flatMap((c) => c.issues);
55476
55593
  currentPhase = "llm-aggregate";
55477
- spinner.start(isLlmAvailable() ? `Agregando issues via LLM (${providerLabel()}, timeout=${llmTimeoutSec}s)…` : "Agregando issues (modo offline — sem LLM keys)…");
55594
+ setSpinnerLabel(isLlmAvailable() ? `Agregando issues via LLM (${providerLabel()}, timeout=${llmTimeoutSec}s)…` : "Agregando issues (modo offline — sem LLM keys)…");
55595
+ spinner.start();
55478
55596
  const llmHb = attachSpinnerHeartbeat(spinner, {
55479
55597
  baseText: isLlmAvailable() ? "Agregando issues via LLM (Sonnet 4.6)…" : "Agregando issues (modo offline)…"
55480
55598
  });
@@ -55556,7 +55674,7 @@ ${formatTimingsSummary(partialTimings)}
55556
55674
  });
55557
55675
  printSummary5(run, paths.reportHtml, { promotedCount, deprecatedCount, platform });
55558
55676
  console.log(`
55559
- ${formatTimingsSummary(runTimings)}`);
55677
+ ${formatTimingsSummary(runTimings, buildFlowBreakdown(allFlowCaptures))}`);
55560
55678
  if (opts.ci) {
55561
55679
  const blocking = allIssues.filter((i) => failOn.includes(i.severity));
55562
55680
  if (blocking.length > 0) {
@@ -55586,6 +55704,7 @@ ${formatTimingsSummary(runTimings)}`);
55586
55704
  });
55587
55705
  return 2;
55588
55706
  } finally {
55707
+ clearInterval(elapsedTicker);
55589
55708
  clearTimeout(globalTimeoutTimer);
55590
55709
  process.removeListener("SIGINT", onSignal);
55591
55710
  process.removeListener("SIGTERM", onSignal);
@@ -56416,7 +56535,7 @@ program.command("run").description([
56416
56535
  " auto-selectors=ON (if LLM), learn=ON, cache=ON, visual-diff=ON,",
56417
56536
  " warmup=OFF, bypass-cache=OFF, ci=OFF."
56418
56537
  ].join(`
56419
- `)).requiredOption("--prod <url>", "Production URL (source of truth, e.g. Fresh site)").requiredOption("--cand <url>", "Candidate URL (migrated site, e.g. TanStack)").option("--preset <name>", "Bundle of defaults: smoke (fast, ~30s, no LLM) | full (deep audit) | ci (CI-tuned)").option("--flows <list>", "Comma-separated flows: homepage,plp,pdp,purchase-journey", "purchase-journey").option("--viewports <list>", "Comma-separated viewports: mobile,desktop", "mobile,desktop").option("--cep <cep>", "CEP for shipping calculation", "01310-100").option("--runs <n>", "Repeat each measurement N times (median)", "1").option("--baseline <name>", "Compare against a saved baseline").option("--output <dir>", "Output directory", "./parity-output").option("--ci", "CI mode: stricter exit codes", false).option("--fail-on <severities>", "Comma-separated severities that cause exit 1", "critical").option("--open", "Open the HTML report after the run completes", false).option("--no-auto-selectors", "Disable LLM-based selector discovery (uses defaults instead)").option("--refresh-selectors", "Bypass selector cache and re-run discovery", false).option("--no-learn", "Don't write to learned-selectors.json (read-only mode)").option("--vitals-pages <n>", "Extra pages from sitemap to crawl for Vitals coverage (default 10)", (v) => Number(v), 10).option("--visual-pages <n>", "Pages to compare visually via LLM (home + sampled PLPs/PDPs from sitemap, default 5)", (v) => Number(v), 5).option("--pages <list>", 'Comma-separated paths to compare visually (overrides sitemap discovery). E.g. "/,/account,/p/some-product". Use this when you want deterministic coverage instead of sampled.').option("--pages-file <path>", "Read paths to compare visually from a text file (one path per line). Lines starting with # are ignored. Overrides --pages when both are present.").option("--no-visual-diff", "Skip the visual diff capture pass entirely").option("--no-cache", "Disable the cross-run visual-diff cache (forces a fresh LLM judgment on every page).").option("--clear-cache", "Wipe the visual-diff verdict cache (parity-output/cache/verdicts.json) before the run.", false).option("--bypass-cache", "Bypass CDN/edge caches: append a cache-busting query param and send Cache-Control: no-cache on every request. Use right after a deploy to avoid false failures from stale CF edge content.", false).option("--warmup", "Before measurement, hit each target URL once (per viewport) with a cache-buster so the Worker serves a fresh response. Recommended after deploys.", false).option("--accept-prod-quirks", "Demote prod-side cart-empty journey failures (VTEX session quirk) from failed to skipped. The cart-reveal-mode-divergence check still emits critical if prod/cand markup intents differ, so this flag never masks a real regression. See issue #12.", false).option("--llm-timeout <seconds>", "Hard timeout for the LLM aggregation call (seconds). The run completes in offline mode if the LLM hangs past this. Default: 60.", (v) => Number(v), 60).option("--timeout <minutes>", "Hard wall-clock cap for the whole run (minutes). On expiry, parity writes a partial report and exits 130. Default: 30. Issue #56.", (v) => Number(v), 30).option("--flow <name>", "Alias for --flows when running a single flow (e.g. --flow cart). Issue #53.").option("--json <path|->", "Emit JSON-Lines (one line per check) to the given file path, or '-' for stdout. Schema versioned via leading metadata line. Issue #53.").option("--pt", "Tell the LLM to respond in Brazilian Portuguese. Only affects LLM-generated content (issues, explain, prompts) — the static HTML report stays in English. Issue #67.").option("--llm <provider>", "Force LLM provider: anthropic | openrouter | claude-code | none. Default: auto-detect (anthropic key → openrouter key → local claude CLI → none). Issue #66.").option("--llm-model <overrides>", "Per-feature model override, e.g. visual-diff=claude-opus-4-7,explain=claude-opus-4-7. Features: selector-discovery, step-recovery, search-terms, plp-matching, pdp-matching, section-understanding, visual-diff, issue-aggregation, explain. Issue #66.").option("--llm-tier-default <tier>", "Override the default tier (haiku | sonnet | opus) for every feature that doesn't have a per-feature override. Issue #66.").option("--llm-model-default <model>", "Force every LLM call to use this exact model ID, ignoring per-feature defaults. Issue #66.").option("--no-interactive", "Disable the interactive selector prompt that auto-fires in a TTY without an LLM provider. Use in scripts and CI where stdin is technically a TTY but you don't want to pause. Issue #72.").action(async (opts) => {
56538
+ `)).requiredOption("--prod <url>", "Production URL (source of truth, e.g. Fresh site)").requiredOption("--cand <url>", "Candidate URL (migrated site, e.g. TanStack)").option("--preset <name>", "Bundle of defaults: smoke (fast, ~30s, no LLM) | full (deep audit) | ci (CI-tuned)").option("--flows <list>", "Comma-separated flows: homepage,plp,pdp,purchase-journey", "purchase-journey").option("--viewports <list>", "Comma-separated viewports: mobile,desktop", "mobile,desktop").option("--cep <cep>", "CEP for shipping calculation", "01310-100").option("--runs <n>", "Repeat each measurement N times (median)", "1").option("--baseline <name>", "Compare against a saved baseline").option("--output <dir>", "Output directory", "./parity-output").option("--ci", "CI mode: stricter exit codes", false).option("--fail-on <severities>", "Comma-separated severities that cause exit 1", "critical").option("--open", "Open the HTML report after the run completes", false).option("--no-auto-selectors", "Disable LLM-based selector discovery (uses defaults instead)").option("--refresh-selectors", "Bypass selector cache and re-run discovery", false).option("--no-learn", "Don't write to learned-selectors.json (read-only mode)").option("--vitals-pages <n>", "Extra pages from sitemap to crawl for Vitals coverage (default 10)", (v) => Number(v), 10).option("--max-viewport-concurrency <n>", "How many viewports run concurrently during collect (default 2: mobile+desktop together). Set to 1 if you hit OOM with many viewports.", (v) => Number(v), 2).option("--visual-pages <n>", "Pages to compare visually via LLM (home + sampled PLPs/PDPs from sitemap, default 5)", (v) => Number(v), 5).option("--pages <list>", 'Comma-separated paths to compare visually (overrides sitemap discovery). E.g. "/,/account,/p/some-product". Use this when you want deterministic coverage instead of sampled.').option("--pages-file <path>", "Read paths to compare visually from a text file (one path per line). Lines starting with # are ignored. Overrides --pages when both are present.").option("--no-visual-diff", "Skip the visual diff capture pass entirely").option("--no-cache", "Disable the cross-run visual-diff cache (forces a fresh LLM judgment on every page).").option("--clear-cache", "Wipe the visual-diff verdict cache (parity-output/cache/verdicts.json) before the run.", false).option("--bypass-cache", "Bypass CDN/edge caches: append a cache-busting query param and send Cache-Control: no-cache on every request. Use right after a deploy to avoid false failures from stale CF edge content.", false).option("--warmup", "Before measurement, hit each target URL once (per viewport) with a cache-buster so the Worker serves a fresh response. Recommended after deploys.", false).option("--accept-prod-quirks", "Demote prod-side cart-empty journey failures (VTEX session quirk) from failed to skipped. The cart-reveal-mode-divergence check still emits critical if prod/cand markup intents differ, so this flag never masks a real regression. See issue #12.", false).option("--llm-timeout <seconds>", "Hard timeout for the LLM aggregation call (seconds). The run completes in offline mode if the LLM hangs past this. Default: 60.", (v) => Number(v), 60).option("--timeout <minutes>", "Hard wall-clock cap for the whole run (minutes). On expiry, parity writes a partial report and exits 130. Default: 30. Issue #56.", (v) => Number(v), 30).option("--flow <name>", "Alias for --flows when running a single flow (e.g. --flow cart). Issue #53.").option("--json <path|->", "Emit JSON-Lines (one line per check) to the given file path, or '-' for stdout. Schema versioned via leading metadata line. Issue #53.").option("--pt", "Tell the LLM to respond in Brazilian Portuguese. Only affects LLM-generated content (issues, explain, prompts) — the static HTML report stays in English. Issue #67.").option("--llm <provider>", "Force LLM provider: anthropic | openrouter | claude-code | none. Default: auto-detect (anthropic key → openrouter key → local claude CLI → none). Issue #66.").option("--llm-model <overrides>", "Per-feature model override, e.g. visual-diff=claude-opus-4-7,explain=claude-opus-4-7. Features: selector-discovery, step-recovery, search-terms, plp-matching, pdp-matching, section-understanding, visual-diff, issue-aggregation, explain. Issue #66.").option("--llm-tier-default <tier>", "Override the default tier (haiku | sonnet | opus) for every feature that doesn't have a per-feature override. Issue #66.").option("--llm-model-default <model>", "Force every LLM call to use this exact model ID, ignoring per-feature defaults. Issue #66.").option("--no-interactive", "Disable the interactive selector prompt that auto-fires in a TTY without an LLM provider. Use in scripts and CI where stdin is technically a TTY but you don't want to pause. Issue #72.").action(async (opts) => {
56420
56539
  if (opts.flow) {
56421
56540
  opts.flows = opts.flow;
56422
56541
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/parity",
3
- "version": "0.11.13",
3
+ "version": "0.11.16",
4
4
  "description": "E2E parity validator for site migrations. Compares prod vs cand and reports UI, functional, SEO, visual, and Web Vitals deltas with an LLM-ranked HTML report.",
5
5
  "type": "module",
6
6
  "license": "MIT",