@decocms/parity 0.11.13 → 0.11.14

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 +17 -0
  2. package/dist/cli.js +97 -30
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ 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.14](https://github.com/decocms/parity/compare/v0.11.13...v0.11.14) (2026-06-17)
9
+
10
+ ### Added
11
+
12
+ * **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`.
13
+ * **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:
14
+ ```
15
+ flows breakdown (sides run in parallel within viewport)
16
+ purchase-journey max 1m32s · mobile/prod 52s · mobile/cand 58s · desk/prod 89s · desk/cand 90s
17
+ search max 1m48s · …
18
+ ```
19
+ Driven by the existing `FlowCapture.totalDurationMs` (already populated by `finalize` — just wasn't surfaced).
20
+
21
+ ### Changed
22
+
23
+ * **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.
24
+
8
25
  ## [0.11.13](https://github.com/decocms/parity/compare/v0.11.12...v0.11.13) (2026-06-17)
9
26
 
10
27
  ### Fixed
package/dist/cli.js CHANGED
@@ -54522,7 +54522,7 @@ class TimingRegistry {
54522
54522
  };
54523
54523
  }
54524
54524
  }
54525
- function formatTimingsSummary(timings) {
54525
+ function formatTimingsSummary(timings, flowBreakdown) {
54526
54526
  if (timings.phases.length === 0) {
54527
54527
  return `⏱ Run completed in ${formatMs(timings.totalMs)}`;
54528
54528
  }
@@ -54535,9 +54535,41 @@ function formatTimingsSummary(timings) {
54535
54535
  const pctOfTotal = timings.totalMs > 0 ? Math.round(p.durationMs / timings.totalMs * 100) : 0;
54536
54536
  lines.push(` ${p.phase.padEnd(maxLabel)} ${formatMs(p.durationMs).padStart(7)} ${bar} ${pctOfTotal}%`);
54537
54537
  }
54538
+ if (flowBreakdown && flowBreakdown.length > 0) {
54539
+ const maxFlowLabel = Math.max(...flowBreakdown.map((f) => f.flow.length));
54540
+ lines.push("");
54541
+ lines.push(" flows breakdown (sides run in parallel within viewport)");
54542
+ for (const f of flowBreakdown) {
54543
+ const sideDetail = f.sides.map((s) => `${s.viewport}/${s.side} ${formatMs(s.durationMs)}`).join(" · ");
54544
+ lines.push(` ${f.flow.padEnd(maxFlowLabel)} max ${formatMs(f.maxMs).padStart(6)} · ${sideDetail}`);
54545
+ }
54546
+ }
54538
54547
  return lines.join(`
54539
54548
  `);
54540
54549
  }
54550
+ function buildFlowBreakdown(captures) {
54551
+ const byFlow = new Map;
54552
+ for (const c of captures) {
54553
+ const existing = byFlow.get(c.flow);
54554
+ const entry = {
54555
+ viewport: c.viewport,
54556
+ side: c.side,
54557
+ durationMs: c.totalDurationMs
54558
+ };
54559
+ if (!existing) {
54560
+ byFlow.set(c.flow, {
54561
+ flow: c.flow,
54562
+ maxMs: c.totalDurationMs,
54563
+ sides: [entry]
54564
+ });
54565
+ } else {
54566
+ existing.sides.push(entry);
54567
+ if (c.totalDurationMs > existing.maxMs)
54568
+ existing.maxMs = c.totalDurationMs;
54569
+ }
54570
+ }
54571
+ return Array.from(byFlow.values());
54572
+ }
54541
54573
  function formatMs(ms) {
54542
54574
  const totalSec = Math.floor(ms / 1000);
54543
54575
  if (totalSec < 60)
@@ -55069,6 +55101,21 @@ async function runCommand(rawOpts) {
55069
55101
  let browser = null;
55070
55102
  const allFlowCaptures = [];
55071
55103
  const allPageCaptures = [];
55104
+ let currentSpinnerLabel = "Launching browser…";
55105
+ const renderSpinner = () => {
55106
+ const elapsedSec = Math.floor((Date.now() - startedAt) / 1000);
55107
+ const min = Math.floor(elapsedSec / 60);
55108
+ const sec = elapsedSec % 60;
55109
+ const mmss = `${min.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}`;
55110
+ spinner.text = `${chalk16.dim(`⏱ ${mmss} ·`)} ${currentSpinnerLabel}`;
55111
+ };
55112
+ const setSpinnerLabel = (label) => {
55113
+ currentSpinnerLabel = label;
55114
+ renderSpinner();
55115
+ };
55116
+ setSpinnerLabel("Launching browser…");
55117
+ const elapsedTicker = setInterval(renderSpinner, 1000);
55118
+ elapsedTicker.unref?.();
55072
55119
  const timings = new TimingRegistry;
55073
55120
  let phaseStart = performance.now();
55074
55121
  const stampPhase = (label) => {
@@ -55113,7 +55160,7 @@ async function runCommand(rawOpts) {
55113
55160
  process.stderr.write(` report parcial em ${paths.reportHtml}
55114
55161
  `);
55115
55162
  process.stderr.write(`
55116
- ${formatTimingsSummary(partialTimings)}
55163
+ ${formatTimingsSummary(partialTimings, buildFlowBreakdown(allFlowCaptures))}
55117
55164
  `);
55118
55165
  } catch (err) {
55119
55166
  process.stderr.write(` falha escrevendo report parcial: ${err.message}
@@ -55155,26 +55202,27 @@ ${formatTimingsSummary(partialTimings)}
55155
55202
  warmupSpinner.fail(`Warmup falhou — 0/${result.attempted} requests ok. Cache pode estar stale. Primeira falha: ${firstReason}`);
55156
55203
  }
55157
55204
  }
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);
55205
+ const runOneSide = async (viewport, side) => {
55206
+ const baseUrl = side === "prod" ? opts.prod : opts.cand;
55207
+ const harPath = join16(paths.harDir, `${viewport}-${side}.har`);
55208
+ const tracePath = join16(paths.tracesDir, `${viewport}-${side}.zip`);
55209
+ const ctx = await newContext(browser, {
55210
+ viewport,
55211
+ harPath,
55212
+ tracesDir: paths.tracesDir,
55213
+ cohortCookieValue: "control",
55214
+ noCache: opts.bypassCache
55215
+ });
55216
+ await installVitalsCollector(ctx);
55217
+ const captures = [];
55218
+ const pages = [];
55219
+ try {
55172
55220
  for (const flow of flows) {
55173
55221
  const flowStart = Date.now();
55174
55222
  let lastStepTotal = 0;
55175
55223
  const sideTag = side === "prod" ? chalk16.cyan("prod") : chalk16.magenta("cand");
55176
55224
  const prefix = `${chalk16.dim(`[${viewport}/`)}${sideTag}${chalk16.dim("]")}`;
55177
- spinner.text = `${prefix} ${flow}: starting…`;
55225
+ setSpinnerLabel(`${prefix} ${flow}: starting…`);
55178
55226
  const cap = await runFlow(flow, {
55179
55227
  baseUrl,
55180
55228
  side,
@@ -55190,7 +55238,7 @@ ${formatTimingsSummary(partialTimings)}
55190
55238
  onStep: (event) => {
55191
55239
  if (event.phase === "start") {
55192
55240
  lastStepTotal = event.total;
55193
- spinner.text = `${prefix} ${flow} ${chalk16.dim(`${event.index}/${event.total}`)} ${event.name}…`;
55241
+ setSpinnerLabel(`${prefix} ${flow} ${chalk16.dim(`${event.index}/${event.total}`)} ${event.name}…`);
55194
55242
  }
55195
55243
  }
55196
55244
  });
@@ -55199,7 +55247,7 @@ ${formatTimingsSummary(partialTimings)}
55199
55247
  const target = lastStepTotal || recorded;
55200
55248
  const failed = cap.steps?.find((s) => s.status === "failed");
55201
55249
  const lastStep = cap.steps?.[cap.steps.length - 1];
55202
- const elapsed = chalk16.dim(`${((Date.now() - flowStart) / 1000).toFixed(1)}s`);
55250
+ const elapsedDim = chalk16.dim(`${((Date.now() - flowStart) / 1000).toFixed(1)}s`);
55203
55251
  const counts = target > 0 ? chalk16.dim(`${okSteps}/${target}`) : "";
55204
55252
  let glyph;
55205
55253
  let detail;
@@ -55213,22 +55261,38 @@ ${formatTimingsSummary(partialTimings)}
55213
55261
  glyph = chalk16.green("✓");
55214
55262
  detail = "";
55215
55263
  }
55216
- const summary = `${glyph} ${prefix} ${flow} ${counts} ${detail} ${elapsed}`.replace(/\s+/g, " ").trim();
55264
+ const summary = `${glyph} ${prefix} ${flow} ${counts} ${detail} ${elapsedDim}`.replace(/\s+/g, " ").trim();
55217
55265
  spinner.stopAndPersist({ symbol: "", text: summary });
55218
- spinner.start(`${prefix} ${flow} done — next…`);
55219
- allFlowCaptures.push(cap);
55266
+ setSpinnerLabel(`${prefix} ${flow} done — next…`);
55267
+ spinner.start();
55268
+ captures.push(cap);
55220
55269
  for (const p of cap.pages)
55221
- allPageCaptures.push(p);
55270
+ pages.push(p);
55271
+ }
55272
+ } finally {
55273
+ await stopTracing(ctx, tracePath).catch(() => {
55274
+ return;
55275
+ });
55276
+ await ctx.close();
55277
+ }
55278
+ return { captures, pages };
55279
+ };
55280
+ for (const viewport of viewports) {
55281
+ const [prodResult, candResult] = await Promise.all([
55282
+ runOneSide(viewport, "prod"),
55283
+ runOneSide(viewport, "cand")
55284
+ ]);
55285
+ for (const r of [prodResult, candResult]) {
55286
+ for (const cap of r.captures) {
55287
+ allFlowCaptures.push(cap);
55222
55288
  if (opts.learn !== false) {
55223
55289
  const result = promoteStepsFromFlow(learned, platform, prodHost, cap);
55224
55290
  promotedCount += result.promoted;
55225
55291
  deprecatedCount += result.deprecated;
55226
55292
  }
55227
55293
  }
55228
- await stopTracing(ctx, tracePath).catch(() => {
55229
- return;
55230
- });
55231
- await ctx.close();
55294
+ for (const p of r.pages)
55295
+ allPageCaptures.push(p);
55232
55296
  }
55233
55297
  }
55234
55298
  stampPhase("collect");
@@ -55449,7 +55513,8 @@ ${formatTimingsSummary(partialTimings)}
55449
55513
  }
55450
55514
  }
55451
55515
  currentPhase = "checks";
55452
- spinner.start("Rodando checks…");
55516
+ setSpinnerLabel("Rodando checks…");
55517
+ spinner.start();
55453
55518
  const checksHb = attachSpinnerHeartbeat(spinner, { baseText: "Rodando checks…" });
55454
55519
  const cacheDir = join16(opts.output, "cache");
55455
55520
  const checkCtx = {
@@ -55474,7 +55539,8 @@ ${formatTimingsSummary(partialTimings)}
55474
55539
  }
55475
55540
  const allIssues = checks.flatMap((c) => c.issues);
55476
55541
  currentPhase = "llm-aggregate";
55477
- spinner.start(isLlmAvailable() ? `Agregando issues via LLM (${providerLabel()}, timeout=${llmTimeoutSec}s)…` : "Agregando issues (modo offline — sem LLM keys)…");
55542
+ setSpinnerLabel(isLlmAvailable() ? `Agregando issues via LLM (${providerLabel()}, timeout=${llmTimeoutSec}s)…` : "Agregando issues (modo offline — sem LLM keys)…");
55543
+ spinner.start();
55478
55544
  const llmHb = attachSpinnerHeartbeat(spinner, {
55479
55545
  baseText: isLlmAvailable() ? "Agregando issues via LLM (Sonnet 4.6)…" : "Agregando issues (modo offline)…"
55480
55546
  });
@@ -55556,7 +55622,7 @@ ${formatTimingsSummary(partialTimings)}
55556
55622
  });
55557
55623
  printSummary5(run, paths.reportHtml, { promotedCount, deprecatedCount, platform });
55558
55624
  console.log(`
55559
- ${formatTimingsSummary(runTimings)}`);
55625
+ ${formatTimingsSummary(runTimings, buildFlowBreakdown(allFlowCaptures))}`);
55560
55626
  if (opts.ci) {
55561
55627
  const blocking = allIssues.filter((i) => failOn.includes(i.severity));
55562
55628
  if (blocking.length > 0) {
@@ -55586,6 +55652,7 @@ ${formatTimingsSummary(runTimings)}`);
55586
55652
  });
55587
55653
  return 2;
55588
55654
  } finally {
55655
+ clearInterval(elapsedTicker);
55589
55656
  clearTimeout(globalTimeoutTimer);
55590
55657
  process.removeListener("SIGINT", onSignal);
55591
55658
  process.removeListener("SIGTERM", onSignal);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/parity",
3
- "version": "0.11.13",
3
+ "version": "0.11.14",
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",