@decocms/parity 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/cli.js +123 -43
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,36 @@ 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.3.0](https://github.com/decocms/parity/compare/v0.2.0...v0.3.0) (2026-05-26)
9
+
10
+
11
+ ### Added
12
+
13
+ * **journey:** retry `go-checkout` via LLM recovery when the default selector clicks the wrong element and the URL never reaches `/checkout` ([5826d30](https://github.com/decocms/parity/commit/5826d309c6a910f8cf8017667cdcdcebd65f65d9))
14
+ * **journey:** LLM recovery on `cep-pdp` + `cep-cart` when defaults miss the CEP input ([624b0f5](https://github.com/decocms/parity/commit/624b0f5acbe1c50fce2bea69faa6906e29d36e64))
15
+ * **journey:** per-flow hard deadline so a single hung flow can't freeze the whole crawl ([e2d4a68](https://github.com/decocms/parity/commit/e2d4a681a5d4f0a9627251eceb1e733d12bdc68d))
16
+
17
+
18
+ ### Fixed
19
+
20
+ * **journey:** abort in-flight Playwright ops when the deadline fires, instead of letting them mutate the next flow's shared BrowserContext ([a21aa78](https://github.com/decocms/parity/commit/a21aa78a4431e7a2cff9aac26ac34c4ca2fa768c))
21
+ * **journey:** seal the timeout FlowCapture synchronously so Promise.race can't pick up the inner rejection caused by closing pages ([d2a0f1e](https://github.com/decocms/parity/commit/d2a0f1e7a08bf95a28a951bace322c5baf062bea))
22
+ * **journey:** await timeout cleanup before runFlow returns so the next flow on the same context isn't racing in-flight close()s ([a2fd6c2](https://github.com/decocms/parity/commit/a2fd6c2e7e7a9eb493d2cb75d08f3d5cc7cc3f91))
23
+
24
+ ## [0.2.0](https://github.com/decocms/parity/compare/v0.1.1...v0.2.0) (2026-05-26)
25
+
26
+
27
+ ### Added
28
+
29
+ * **css-trace:** inspect CSS rules affecting a DOM element ([1e323fa](https://github.com/decocms/parity/commit/1e323fa17564045d5f55bfbc5cbf2fcdb0112877))
30
+
31
+
32
+ ### Fixed
33
+
34
+ * **capture:** hard outer deadline so capturePage cannot exceed budget+10s ([966b9a5](https://github.com/decocms/parity/commit/966b9a59851536262b88510da6175a215c90d0b2))
35
+ * **capture:** hard outer deadline so capturePage cannot exceed budget+10s ([ea61116](https://github.com/decocms/parity/commit/ea61116c81fb1ec86e86ddb24216214380a81c81))
36
+ * **lint:** replace 3 template literals without interpolation with strings ([c60f19c](https://github.com/decocms/parity/commit/c60f19c4a56c8441ead829fca0998ee36a671872))
37
+
8
38
  ## [Unreleased]
9
39
 
10
40
  ## [0.1.1] — 2026-05-22
package/dist/cli.js CHANGED
@@ -5059,19 +5059,60 @@ var PURCHASE_JOURNEY_TOTAL_STEPS = 8;
5059
5059
  function selFor(ctx, key) {
5060
5060
  return selectorsFor(key, { rc: ctx.rc, learned: ctx.learned, platform: ctx.platform });
5061
5061
  }
5062
+ var FLOW_DEADLINE_MS = {
5063
+ homepage: 90000,
5064
+ plp: 180000,
5065
+ pdp: 240000,
5066
+ "purchase-journey": 360000
5067
+ };
5062
5068
  async function runFlow(flow, ctx) {
5063
5069
  const start = Date.now();
5064
- switch (flow) {
5065
- case "homepage":
5066
- return finalize(flow, ctx, await flowHomepage(ctx), [], start);
5067
- case "plp":
5068
- return finalize(flow, ctx, await flowPlp(ctx), [], start);
5069
- case "pdp":
5070
- return finalize(flow, ctx, await flowPdp(ctx), [], start);
5071
- case "purchase-journey": {
5072
- const { pages, steps } = await flowPurchaseJourney(ctx);
5073
- return finalize(flow, ctx, pages, steps, start);
5070
+ const deadlineMs = FLOW_DEADLINE_MS[flow];
5071
+ const inner = async () => {
5072
+ switch (flow) {
5073
+ case "homepage":
5074
+ return finalize(flow, ctx, await flowHomepage(ctx), [], start);
5075
+ case "plp":
5076
+ return finalize(flow, ctx, await flowPlp(ctx), [], start);
5077
+ case "pdp":
5078
+ return finalize(flow, ctx, await flowPdp(ctx), [], start);
5079
+ case "purchase-journey": {
5080
+ const { pages, steps } = await flowPurchaseJourney(ctx);
5081
+ return finalize(flow, ctx, pages, steps, start);
5082
+ }
5074
5083
  }
5084
+ };
5085
+ const innerPromise = inner();
5086
+ innerPromise.catch(() => {
5087
+ return;
5088
+ });
5089
+ let timer;
5090
+ let cleanup = Promise.resolve();
5091
+ const timeoutPromise = new Promise((resolve) => {
5092
+ timer = setTimeout(() => {
5093
+ const pages = ctx.ctx.pages();
5094
+ resolve(finalize(flow, ctx, [], [
5095
+ {
5096
+ step: 0,
5097
+ name: "visit-home",
5098
+ side: ctx.side,
5099
+ viewport: ctx.viewport,
5100
+ status: "failed",
5101
+ durationMs: deadlineMs,
5102
+ screenshotPath: "",
5103
+ actionDescription: `[flow-timeout] flow "${flow}" excedeu ${deadlineMs}ms — captura abortada pela safety net externa, ${pages.length} page(s) fechada(s) para liberar o contexto. Step interno provavelmente travou em uma operação Playwright que não respeitou seu timeout declarado.`
5104
+ }
5105
+ ], start));
5106
+ cleanup = Promise.allSettled(pages.map((p) => p.close()));
5107
+ }, deadlineMs);
5108
+ });
5109
+ try {
5110
+ const result = await Promise.race([innerPromise, timeoutPromise]);
5111
+ await cleanup;
5112
+ return result;
5113
+ } finally {
5114
+ if (timer !== undefined)
5115
+ clearTimeout(timer);
5075
5116
  }
5076
5117
  }
5077
5118
  function finalize(flow, ctx, pages, steps, start) {
@@ -5264,7 +5305,16 @@ async function flowPurchaseJourney(ctx) {
5264
5305
  steps[steps.length - 1].actionDescription = `Abriu PDP \`${pdpHit.url}\` (via \`${pdpHit.selector}\`)`;
5265
5306
  steps[steps.length - 1].beforeUrl = plpHit.url;
5266
5307
  reportStart(4, "shipping-calc-pdp");
5267
- const cepInputPdp = await firstVisible(page, selFor(ctx, "cepInputPdp"));
5308
+ let cepInputPdp = await firstVisible(page, selFor(ctx, "cepInputPdp"));
5309
+ let cepPdpRecovered = false;
5310
+ if (!cepInputPdp && recoveryBudget > 0) {
5311
+ const recovery = await attemptRecovery(page, ctx, "shipping-calc-pdp", "Achar o input de CEP / código postal nesta PDP (deve ser um input visível com label/placeholder relacionado a frete, entrega ou CEP)", selFor(ctx, "cepInputPdp"));
5312
+ if (recovery) {
5313
+ cepInputPdp = recovery.selector;
5314
+ cepPdpRecovered = true;
5315
+ recoveryBudget--;
5316
+ }
5317
+ }
5268
5318
  if (cepInputPdp) {
5269
5319
  const t4 = Date.now();
5270
5320
  const beforeUrl4 = page.url();
@@ -5289,14 +5339,15 @@ async function flowPurchaseJourney(ctx) {
5289
5339
  screenshotPath: sp,
5290
5340
  screenshotBeforePath: spBefore4,
5291
5341
  beforeUrl: beforeUrl4,
5292
- actionDescription: `Preencheu CEP '${ctx.rc.cep}' no input \`${cepInputPdp}\` e disparou cálculo de frete`,
5342
+ actionDescription: `Preencheu CEP '${ctx.rc.cep}' no input \`${cepInputPdp}\` e disparou cálculo de frete${cepPdpRecovered ? " (selector via LLM recovery)" : ""}`,
5293
5343
  detail: { cepUsed: ctx.rc.cep },
5294
5344
  selectorKey: "cepInputPdp",
5295
- usedSelector: cepInputPdp
5345
+ usedSelector: cepInputPdp,
5346
+ recoveredByLlm: cepPdpRecovered || undefined
5296
5347
  });
5297
5348
  reportEnd(4, "shipping-calc-pdp", step4Status, Date.now() - t4);
5298
5349
  } else {
5299
- steps.push(makeSkipStep(4, "shipping-calc-pdp", ctx, "no CEP input on PDP"));
5350
+ steps.push(makeSkipStep(4, "shipping-calc-pdp", ctx, "no CEP input on PDP (recovery exhausted)"));
5300
5351
  reportEnd(4, "shipping-calc-pdp", "skipped", 0, "no CEP input on PDP");
5301
5352
  }
5302
5353
  reportStart(5, "add-to-cart");
@@ -5394,7 +5445,16 @@ async function flowPurchaseJourney(ctx) {
5394
5445
  });
5395
5446
  reportEnd(6, "open-minicart", "ok", Date.now() - t6);
5396
5447
  reportStart(7, "shipping-calc-cart");
5397
- const cepInputCart = await firstVisible(page, selFor(ctx, "cepInputCart"));
5448
+ let cepInputCart = await firstVisible(page, selFor(ctx, "cepInputCart"));
5449
+ let cepCartRecovered = false;
5450
+ if (!cepInputCart && recoveryBudget > 0) {
5451
+ const recovery = await attemptRecovery(page, ctx, "shipping-calc-cart", "Achar o input de CEP / código postal dentro do carrinho ou minicart aberto agora (input visível com label/placeholder de frete, entrega ou CEP)", selFor(ctx, "cepInputCart"));
5452
+ if (recovery) {
5453
+ cepInputCart = recovery.selector;
5454
+ cepCartRecovered = true;
5455
+ recoveryBudget--;
5456
+ }
5457
+ }
5398
5458
  if (cepInputCart) {
5399
5459
  const t7 = Date.now();
5400
5460
  const beforeUrl7 = page.url();
@@ -5419,14 +5479,15 @@ async function flowPurchaseJourney(ctx) {
5419
5479
  screenshotPath: sp7,
5420
5480
  screenshotBeforePath: spBefore7,
5421
5481
  beforeUrl: beforeUrl7,
5422
- actionDescription: `Preencheu CEP '${ctx.rc.cep}' no carrinho (\`${cepInputCart}\`)`,
5482
+ actionDescription: `Preencheu CEP '${ctx.rc.cep}' no carrinho (\`${cepInputCart}\`)${cepCartRecovered ? " (selector via LLM recovery)" : ""}`,
5423
5483
  detail: { cepUsed: ctx.rc.cep },
5424
5484
  selectorKey: "cepInputCart",
5425
- usedSelector: cepInputCart
5485
+ usedSelector: cepInputCart,
5486
+ recoveredByLlm: cepCartRecovered || undefined
5426
5487
  });
5427
5488
  reportEnd(7, "shipping-calc-cart", step7Status, Date.now() - t7);
5428
5489
  } else {
5429
- steps.push(makeSkipStep(7, "shipping-calc-cart", ctx, "no CEP input in cart"));
5490
+ steps.push(makeSkipStep(7, "shipping-calc-cart", ctx, "no CEP input in cart (recovery exhausted)"));
5430
5491
  reportEnd(7, "shipping-calc-cart", "skipped", 0, "no CEP input in cart");
5431
5492
  }
5432
5493
  reportStart(8, "go-checkout");
@@ -5445,28 +5506,47 @@ async function flowPurchaseJourney(ctx) {
5445
5506
  reportEnd(8, "go-checkout", "skipped", 0, "no checkout button found");
5446
5507
  return { pages, steps };
5447
5508
  }
5448
- const t8 = Date.now();
5449
- const beforeUrl8 = page.url();
5450
- const spBefore8 = screenshotPath(ctx, "pj-8-checkout-before");
5451
- await page.screenshot({ path: spBefore8, fullPage: false }).catch(() => {
5452
- return;
5453
- });
5454
- const checkoutText = await checkoutHit.locator.innerText().catch(() => "");
5455
- await Promise.all([
5456
- page.waitForURL(/checkout/i, { timeout: 1e4 }).catch(() => {
5509
+ const tryCheckoutClick = async (hit, attempt2) => {
5510
+ const spBefore = screenshotPath(ctx, `pj-8-checkout-before-${attempt2}`);
5511
+ await page.screenshot({ path: spBefore, fullPage: false }).catch(() => {
5457
5512
  return;
5458
- }),
5459
- checkoutHit.locator.click({ timeout: 5000 }).catch(() => {
5513
+ });
5514
+ const clickedText2 = await hit.locator.innerText().catch(() => "");
5515
+ await Promise.all([
5516
+ page.waitForURL(/checkout/i, { timeout: 1e4 }).catch(() => {
5517
+ return;
5518
+ }),
5519
+ hit.locator.click({ timeout: 5000 }).catch(() => {
5520
+ return;
5521
+ })
5522
+ ]);
5523
+ await page.waitForTimeout(1500);
5524
+ const spAfter = screenshotPath(ctx, `pj-8-checkout-reached-${attempt2}`);
5525
+ await page.screenshot({ path: spAfter, fullPage: false }).catch(() => {
5460
5526
  return;
5461
- })
5462
- ]);
5463
- await page.waitForTimeout(1500);
5464
- const sp8 = screenshotPath(ctx, "pj-8-checkout-reached");
5465
- await page.screenshot({ path: sp8, fullPage: false }).catch(() => {
5466
- return;
5467
- });
5468
- const checkoutUrl = page.url();
5469
- const reachedCheckout = /\/checkout/i.test(checkoutUrl);
5527
+ });
5528
+ return { url: page.url(), spBefore, spAfter, clickedText: clickedText2 };
5529
+ };
5530
+ const t8 = Date.now();
5531
+ const beforeUrl8 = page.url();
5532
+ let attempt = 1;
5533
+ let result = await tryCheckoutClick(checkoutHit, attempt);
5534
+ let reachedCheckout = /\/checkout/i.test(result.url);
5535
+ let usedSelector = checkoutHit.selector;
5536
+ let clickedText = result.clickedText;
5537
+ if (!reachedCheckout && recoveryBudget > 0 && !/\/checkout/i.test(beforeUrl8)) {
5538
+ const retrySuggestion = await attemptRecovery(page, ctx, "go-checkout-retry", `Cliquei em '${clickedText.slice(0, 40).trim()}' (selector \`${usedSelector}\`), mas a URL ficou em ${result.url} e não foi pra /checkout. Achar o botão que de fato navega pra /checkout neste cart/minicart aberto.`, [usedSelector, ...selFor(ctx, "checkoutButton")]);
5539
+ if (retrySuggestion) {
5540
+ recoveryBudget--;
5541
+ checkoutRecovered = true;
5542
+ attempt++;
5543
+ const retryResult = await tryCheckoutClick(retrySuggestion, attempt);
5544
+ result = retryResult;
5545
+ usedSelector = retrySuggestion.selector;
5546
+ clickedText = retryResult.clickedText;
5547
+ reachedCheckout = /\/checkout/i.test(retryResult.url);
5548
+ }
5549
+ }
5470
5550
  const step8Status = reachedCheckout ? "ok" : "failed";
5471
5551
  steps.push({
5472
5552
  step: 8,
@@ -5475,13 +5555,13 @@ async function flowPurchaseJourney(ctx) {
5475
5555
  viewport: ctx.viewport,
5476
5556
  status: step8Status,
5477
5557
  durationMs: Date.now() - t8,
5478
- url: checkoutUrl,
5479
- screenshotPath: sp8,
5480
- screenshotBeforePath: spBefore8,
5558
+ url: result.url,
5559
+ screenshotPath: result.spAfter,
5560
+ screenshotBeforePath: result.spBefore,
5481
5561
  beforeUrl: beforeUrl8,
5482
- actionDescription: `Clicou em${checkoutText ? ` '${checkoutText.slice(0, 30).trim()}'` : ""} (\`${checkoutHit.selector}\`); URL final: ${checkoutUrl}${reachedCheckout ? " ✓ atingiu /checkout" : " ✗ não foi pra checkout"}`,
5562
+ actionDescription: `Clicou em${clickedText ? ` '${clickedText.slice(0, 30).trim()}'` : ""} (\`${usedSelector}\`); URL final: ${result.url}${reachedCheckout ? " ✓ atingiu /checkout" : " ✗ não foi pra checkout"}${attempt > 1 ? ` (após ${attempt} tentativas com recovery LLM)` : ""}`,
5483
5563
  selectorKey: "checkoutButton",
5484
- usedSelector: checkoutHit.selector,
5564
+ usedSelector,
5485
5565
  recoveredByLlm: checkoutRecovered || undefined
5486
5566
  });
5487
5567
  reportEnd(8, "go-checkout", step8Status, Date.now() - t8);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/parity",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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",