@decocms/parity 0.11.5 → 0.11.8

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/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ 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.8](https://github.com/decocms/parity/compare/v0.11.7...v0.11.8) (2026-06-17)
9
+
10
+ ### Fixed
11
+
12
+ * **CI on `main` was broken across the 0.11.x patch series.** Three issues converged: (a) the new `postinstall` script in 0.11.7 used CommonJS `require()` but the package is `"type": "module"`, so every fresh `bun install --frozen-lockfile` (including the CI install step) threw `ReferenceError: require is not defined`. (b) Several lint errors carried over from PRs that used `--admin` to bypass CI (template-literal-as-string, assign-in-while-conditions in the new `plp-pagination` check, `delete attrs[name]` flagged by `noDelete`). (c) The earlier `0.11.4` HTML-compaction code in `discover-selectors`/`recover-step` triggered the same `noDelete` rule.
13
+ * **`postinstall.js` renamed to `postinstall.cjs`** so it runs as CommonJS regardless of the package's `"type"`. `package.json` `files` list and the `postinstall` script invocation updated accordingly.
14
+ * **Lint clean across the repo.** `bun run lint` returns zero errors. Loop patterns rewritten from `while ((m = re.exec(s)))` to `for (;;)` + early-break (biome's `noAssignInExpressions`). Two intentional `delete` calls on cheerio attribs get a scoped `biome-ignore` with the rationale (cheerio serializes `undefined`-valued attrs as empty strings; only `delete` actually removes them).
15
+
16
+ ## [0.11.7](https://github.com/decocms/parity/compare/v0.11.6...v0.11.7) (2026-06-17)
17
+
18
+ ### Fixed
19
+
20
+ * **First-run UX after `npm install -g`.** Two papercuts:
21
+ 1. **Playwright Chromium wasn't auto-installed.** A fresh global install left users with `browserType.launch: Executable doesn't exist at ...` plus a stack trace on the first `parity run`. Now there's a `postinstall` script that runs `npx playwright install chromium` automatically (one-time, ~140 MB). Skippable via `PARITY_SKIP_PLAYWRIGHT_INSTALL=1` for CI / Docker / monorepos that manage browsers separately. Failures during postinstall (offline, corp proxy) are downgraded to a warning so the global install itself still completes.
22
+ 2. **`launchBrowser` now catches the missing-browser error** and prints a single clear instruction (`npx playwright install chromium`) instead of Playwright's ASCII banner + stack trace. Original error is preserved on `.cause` for debugging.
23
+
24
+ ### Known cosmetic warning
25
+
26
+ The `@anthropic-ai/claude-agent-sdk` peer-dep on `zod@^4.0.0` clashes with parity's `zod@^3.x`. The warning is harmless — npm nests the two versions and everything works. A migration to zod 4 is tracked separately (lots of schema API differences).
27
+
28
+ ## [0.11.6](https://github.com/decocms/parity/compare/v0.11.5...v0.11.6) (2026-06-17)
29
+
30
+ ### Added
31
+
32
+ * **New `plp-pagination` check.** Tests that `?page=2` and `?page=3` of every captured PLP return 200 AND show different products than page 1. Catches the classic migration regression: the new site silently ignores `?page=N` and returns the first page on every request (or caps pagination). Live tested against bagaggio TanStack — surfaces a real critical bug (`page=2` shows the same 10 products as `page=1`, 100% overlap). Falls back to scraping the home page for a category link when running standalone (`parity check plp-pagination`).
33
+ * **Cross-side count divergence detection.** When prod and cand both serve a paginated PLP but cand's `?page=2` product count differs from prod's by more than 30%, flag as medium-severity (likely sort-order or index pruning regression).
34
+
8
35
  ## [0.11.5](https://github.com/decocms/parity/compare/v0.11.4...v0.11.5) (2026-06-17)
9
36
 
10
37
  ### Fixed
package/README.md CHANGED
@@ -26,11 +26,15 @@ Three use cases drive the design:
26
26
  ## Quickstart
27
27
 
28
28
  ```bash
29
- # Install
29
+ # Install — auto-installs Playwright Chromium on postinstall (~140 MB, one-time)
30
30
  npm install -g @decocms/parity
31
31
  # or run without install
32
32
  npx @decocms/parity run --prod ... --cand ...
33
33
 
34
+ # In CI / behind a corp proxy? Skip the auto-install and do it later:
35
+ # PARITY_SKIP_PLAYWRIGHT_INSTALL=1 npm install -g @decocms/parity
36
+ # npx playwright install chromium
37
+
34
38
  # First-time smoke run (~30s, no LLM needed)
35
39
  parity run --prod https://oldsite.com --cand https://newsite.example.dev --preset smoke --open
36
40
 
package/dist/cli.js CHANGED
@@ -1790,11 +1790,29 @@ var VIEWPORT_PRESETS = {
1790
1790
  }
1791
1791
  };
1792
1792
  async function launchBrowser(opts = {}) {
1793
- return await chromium.launch({
1794
- headless: opts.headless ?? true,
1795
- slowMo: opts.slowMo ?? 0,
1796
- args: ["--disable-blink-features=AutomationControlled"]
1797
- });
1793
+ try {
1794
+ return await chromium.launch({
1795
+ headless: opts.headless ?? true,
1796
+ slowMo: opts.slowMo ?? 0,
1797
+ args: ["--disable-blink-features=AutomationControlled"]
1798
+ });
1799
+ } catch (err) {
1800
+ const msg = err.message ?? "";
1801
+ if (msg.includes("Executable doesn't exist")) {
1802
+ const friendly = new Error([
1803
+ "Playwright's Chromium binary is not installed yet. Run:",
1804
+ "",
1805
+ " npx playwright install chromium",
1806
+ "",
1807
+ "(one-time, ~140 MB download). Then re-run parity. This usually happens",
1808
+ "after a fresh `npm install -g @decocms/parity`."
1809
+ ].join(`
1810
+ `));
1811
+ friendly.cause = err;
1812
+ throw friendly;
1813
+ }
1814
+ throw err;
1815
+ }
1798
1816
  }
1799
1817
  async function newContext(browser, opts) {
1800
1818
  const baseContext = VIEWPORT_PRESETS[opts.viewport];
@@ -8534,6 +8552,232 @@ function hasReservedSpace($img) {
8534
8552
  return false;
8535
8553
  }
8536
8554
 
8555
+ // src/checks/plp-pagination.ts
8556
+ async function plpPagination(ctx) {
8557
+ const start = Date.now();
8558
+ const issues = [];
8559
+ let prodPlp = pickPlpUrl(ctx.prodFlows);
8560
+ let candPlp = pickPlpUrl(ctx.candFlows);
8561
+ if (!prodPlp && ctx.prodPages.length > 0) {
8562
+ prodPlp = await discoverPlpFromHome(ctx.prodPages[0].url);
8563
+ }
8564
+ if (!candPlp && ctx.candPages.length > 0) {
8565
+ candPlp = await discoverPlpFromHome(ctx.candPages[0].url);
8566
+ }
8567
+ if (!prodPlp && !candPlp) {
8568
+ return {
8569
+ name: "plp-pagination",
8570
+ status: "skipped",
8571
+ severity: "medium",
8572
+ durationMs: Date.now() - start,
8573
+ summary: "no PLP captured by purchase-journey step 2 AND no home page to discover one from — skipping",
8574
+ issues: []
8575
+ };
8576
+ }
8577
+ const data = {};
8578
+ for (const [side, plp] of [
8579
+ ["prod", prodPlp],
8580
+ ["cand", candPlp]
8581
+ ]) {
8582
+ if (!plp)
8583
+ continue;
8584
+ const page1 = await fetchPlpProducts(plp);
8585
+ const page2 = await fetchPlpProducts(withPage(plp, 2));
8586
+ const page3 = await fetchPlpProducts(withPage(plp, 3));
8587
+ data[`${side}_page_counts`] = {
8588
+ page1: { status: page1.status, products: page1.productPaths.length },
8589
+ page2: { status: page2.status, products: page2.productPaths.length },
8590
+ page3: { status: page3.status, products: page3.productPaths.length }
8591
+ };
8592
+ for (const [n, result] of [
8593
+ [2, page2],
8594
+ [3, page3]
8595
+ ]) {
8596
+ if (result.status !== 200) {
8597
+ issues.push({
8598
+ id: `plp-pagination:${side}:page${n}:status-${result.status}`,
8599
+ severity: "high",
8600
+ category: "functional",
8601
+ check: "plp-pagination",
8602
+ summary: `[${side}] page=${n} returned HTTP ${result.status} (expected 200) — pagination breaks under ?page=${n}`,
8603
+ page: withPage(plp, n)
8604
+ });
8605
+ }
8606
+ }
8607
+ if (page1.productPaths.length > 0 && page2.status === 200) {
8608
+ const overlap = setOverlap(page1.productPaths, page2.productPaths);
8609
+ if (overlap > 0.9) {
8610
+ issues.push({
8611
+ id: `plp-pagination:${side}:page2-identical`,
8612
+ severity: "critical",
8613
+ category: "functional",
8614
+ check: "plp-pagination",
8615
+ summary: `[${side}] page=2 shows the same ${page1.productPaths.length} products as page=1 (${(overlap * 100).toFixed(0)}% overlap) — ?page=N is being ignored`,
8616
+ page: withPage(plp, 2),
8617
+ details: `Page 1 first product: ${page1.productPaths[0] ?? "(none)"}
8618
+ Page 2 first product: ${page2.productPaths[0] ?? "(none)"}`
8619
+ });
8620
+ }
8621
+ }
8622
+ if (page2.productPaths.length > 0 && page3.status === 200) {
8623
+ const overlap = setOverlap(page2.productPaths, page3.productPaths);
8624
+ if (overlap > 0.9) {
8625
+ issues.push({
8626
+ id: `plp-pagination:${side}:page3-identical`,
8627
+ severity: "critical",
8628
+ category: "functional",
8629
+ check: "plp-pagination",
8630
+ summary: `[${side}] page=3 shows the same products as page=2 (${(overlap * 100).toFixed(0)}% overlap) — pagination capped or broken`,
8631
+ page: withPage(plp, 3)
8632
+ });
8633
+ }
8634
+ }
8635
+ }
8636
+ if (prodPlp && candPlp) {
8637
+ const prodCounts = data.prod_page_counts;
8638
+ const candCounts = data.cand_page_counts;
8639
+ if (prodCounts && candCounts) {
8640
+ const p = prodCounts.page2.products;
8641
+ const c = candCounts.page2.products;
8642
+ if (p > 0 && Math.abs(p - c) / p > 0.3) {
8643
+ issues.push({
8644
+ id: "plp-pagination:cross-side-count-divergence",
8645
+ severity: "medium",
8646
+ category: "functional",
8647
+ check: "plp-pagination",
8648
+ summary: `page=2 product count diverges: prod=${p}, cand=${c} (Δ ${Math.abs(p - c)}, >30%)`
8649
+ });
8650
+ }
8651
+ }
8652
+ }
8653
+ const status = issues.some((i) => i.severity === "critical") ? "fail" : issues.length > 0 ? "fail" : "pass";
8654
+ return {
8655
+ name: "plp-pagination",
8656
+ status,
8657
+ severity: "high",
8658
+ durationMs: Date.now() - start,
8659
+ summary: issues.length === 0 ? "PLP pagination working on both sides (tested page=2 + page=3)" : `${issues.length} pagination issue(s) — see details`,
8660
+ issues,
8661
+ data
8662
+ };
8663
+ }
8664
+ async function fetchPlpProducts(url) {
8665
+ try {
8666
+ const res = await fetch(url, {
8667
+ headers: {
8668
+ "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
8669
+ },
8670
+ signal: AbortSignal.timeout(15000)
8671
+ });
8672
+ if (res.status !== 200) {
8673
+ return { status: res.status, productPaths: [] };
8674
+ }
8675
+ const html = await res.text();
8676
+ const paths = new Set;
8677
+ const re = /href="([^"]+\/p(?:\?[^"]*|\/[^"]+|))"/gi;
8678
+ for (;; ) {
8679
+ const m = re.exec(html);
8680
+ if (!m)
8681
+ break;
8682
+ const path = m[1];
8683
+ if (!path)
8684
+ continue;
8685
+ const normalized = path.split("?")[0].replace(/\/$/, "");
8686
+ paths.add(normalized);
8687
+ }
8688
+ const re2 = /href="([^"]+\/products\/[^"]+)"/gi;
8689
+ for (;; ) {
8690
+ const m = re2.exec(html);
8691
+ if (!m)
8692
+ break;
8693
+ const path = m[1];
8694
+ if (path)
8695
+ paths.add(path.split("?")[0].replace(/\/$/, ""));
8696
+ }
8697
+ return { status: 200, productPaths: Array.from(paths) };
8698
+ } catch (err) {
8699
+ return { status: 0, productPaths: [] };
8700
+ }
8701
+ }
8702
+ function withPage(url, n) {
8703
+ try {
8704
+ const u = new URL(url);
8705
+ u.searchParams.set("page", String(n));
8706
+ return u.toString();
8707
+ } catch {
8708
+ const sep = url.includes("?") ? "&" : "?";
8709
+ return `${url}${sep}page=${n}`;
8710
+ }
8711
+ }
8712
+ async function discoverPlpFromHome(homeUrl) {
8713
+ try {
8714
+ const res = await fetch(homeUrl, {
8715
+ headers: {
8716
+ "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
8717
+ },
8718
+ signal: AbortSignal.timeout(15000)
8719
+ });
8720
+ if (!res.ok)
8721
+ return null;
8722
+ const html = await res.text();
8723
+ const candidates = new Set;
8724
+ const re = /href="([^"]+)"/gi;
8725
+ for (;; ) {
8726
+ const m = re.exec(html);
8727
+ if (!m)
8728
+ break;
8729
+ const href = m[1];
8730
+ if (!href)
8731
+ continue;
8732
+ if (/^https?:\/\//.test(href) || href.startsWith("#") || href === "/" || /\/(p|cart|carrinho|checkout|account|conta|login|wishlist|favoritos)(\/|$|\?)/i.test(href) || /\.(jpg|png|webp|css|js|svg|ico|woff)/i.test(href)) {
8733
+ continue;
8734
+ }
8735
+ const segments = href.split("?")[0].split("/").filter(Boolean);
8736
+ if (segments.length === 0 || segments.length > 3)
8737
+ continue;
8738
+ candidates.add(href.split("?")[0]);
8739
+ }
8740
+ const sorted = Array.from(candidates).sort((a, b) => a.length - b.length);
8741
+ const home = new URL(homeUrl);
8742
+ const pick = sorted[0];
8743
+ if (!pick)
8744
+ return null;
8745
+ return new URL(pick, home).toString();
8746
+ } catch {
8747
+ return null;
8748
+ }
8749
+ }
8750
+ function pickPlpUrl(flows) {
8751
+ for (const fc of flows) {
8752
+ if (fc.flow !== "purchase-journey")
8753
+ continue;
8754
+ const plpStep = fc.steps?.find((s) => s.name === "navigate-plp" && s.status === "ok");
8755
+ if (plpStep?.url)
8756
+ return plpStep.url;
8757
+ const plpPage = fc.pages.find((p) => {
8758
+ try {
8759
+ const u = new URL(p.url);
8760
+ return u.pathname.split("/").filter(Boolean).length <= 2 && !/\/p$|\/products\//.test(u.pathname);
8761
+ } catch {
8762
+ return false;
8763
+ }
8764
+ });
8765
+ if (plpPage)
8766
+ return plpPage.url;
8767
+ }
8768
+ return null;
8769
+ }
8770
+ function setOverlap(a, b) {
8771
+ if (a.length === 0 || b.length === 0)
8772
+ return 0;
8773
+ const sa = new Set(a);
8774
+ let common = 0;
8775
+ for (const x of b)
8776
+ if (sa.has(x))
8777
+ common++;
8778
+ return common / Math.max(a.length, b.length);
8779
+ }
8780
+
8537
8781
  // src/checks/purchase-journey-flow.ts
8538
8782
  var STEP_LABELS3 = {
8539
8783
  "visit-home": "Visitar home",
@@ -10515,7 +10759,8 @@ var ALL_CHECKS = [
10515
10759
  pdpGalleryRelated,
10516
10760
  footerLinksHealth,
10517
10761
  loginFlow,
10518
- pictureMissingDims
10762
+ pictureMissingDims,
10763
+ plpPagination
10519
10764
  ];
10520
10765
  var ALL_CHECKS_BY_NAME = {
10521
10766
  "http-status-parity": httpStatusParity,
@@ -10542,7 +10787,8 @@ var ALL_CHECKS_BY_NAME = {
10542
10787
  "pdp-gallery-related": pdpGalleryRelated,
10543
10788
  "footer-links-health": footerLinksHealth,
10544
10789
  "login-flow": loginFlow,
10545
- "picture-missing-dims": pictureMissingDims
10790
+ "picture-missing-dims": pictureMissingDims,
10791
+ "plp-pagination": plpPagination
10546
10792
  };
10547
10793
  var FLOW_DEPENDENT_CHECKS = new Set([
10548
10794
  "purchase-journey-flow",
@@ -11262,9 +11508,12 @@ function compactHtmlForRecovery(html, maxChars = 12000) {
11262
11508
  if (attrs.class) {
11263
11509
  const tokens = attrs.class.split(/\s+/).filter(Boolean);
11264
11510
  const kept = tokens.filter((t) => isSemanticClassToken(t));
11265
- attrs.class = kept.join(" ");
11266
- if (!attrs.class)
11511
+ const joined = kept.join(" ");
11512
+ if (joined) {
11513
+ attrs.class = joined;
11514
+ } else {
11267
11515
  delete attrs.class;
11516
+ }
11268
11517
  }
11269
11518
  });
11270
11519
  const allowed = $("header, nav, main, footer, dialog, [role='dialog'], form, button, a, input, [data-buy-button], [data-checkout], [data-minicart], [data-product-list], [role='button'], [aria-label]").map((_, el) => $.html(el)).get().join(`
@@ -11551,9 +11800,12 @@ function compactHtmlForSelectors(html, maxChars = 30000) {
11551
11800
  if (attrs.class) {
11552
11801
  const tokens = attrs.class.split(/\s+/).filter(Boolean);
11553
11802
  const kept = tokens.filter((t) => isSemanticClass(t));
11554
- attrs.class = kept.join(" ");
11555
- if (!attrs.class)
11803
+ const joined2 = kept.join(" ");
11804
+ if (joined2) {
11805
+ attrs.class = joined2;
11806
+ } else {
11556
11807
  delete attrs.class;
11808
+ }
11557
11809
  }
11558
11810
  });
11559
11811
  const sections = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/parity",
3
- "version": "0.11.5",
3
+ "version": "0.11.8",
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",
@@ -32,7 +32,7 @@
32
32
  "bin": {
33
33
  "parity": "dist/cli.js"
34
34
  },
35
- "files": ["dist", "README.md", "AGENTS.md", "CHANGELOG.md", "LICENSE"],
35
+ "files": ["dist", "scripts/postinstall.cjs", "README.md", "AGENTS.md", "CHANGELOG.md", "LICENSE"],
36
36
  "scripts": {
37
37
  "dev": "bun run --hot src/cli.ts",
38
38
  "build": "bun build src/cli.ts --target=node --outfile=dist/cli.js --packages=external && bun run scripts/postbuild.ts",
@@ -44,7 +44,8 @@
44
44
  "deadcode": "knip",
45
45
  "lint": "biome lint .",
46
46
  "fmt": "biome format --write .",
47
- "prepublishOnly": "bun run check && bun run build"
47
+ "prepublishOnly": "bun run check && bun run build",
48
+ "postinstall": "node scripts/postinstall.cjs"
48
49
  },
49
50
  "engines": {
50
51
  "node": ">=20",
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Best-effort Playwright Chromium install on first `npm install` of
4
+ * `@decocms/parity`. Skipped when:
5
+ * - `PARITY_SKIP_PLAYWRIGHT_INSTALL=1` is set (CI / Docker / monorepos
6
+ * that manage the browser separately).
7
+ * - The binary is already present.
8
+ *
9
+ * Failures are downgraded to a warning so a `npm install -g` that
10
+ * happens to be offline / behind a corp proxy doesn't block the whole
11
+ * install. The runtime path in `engine/browser.ts:launchBrowser` catches
12
+ * the missing-browser case and tells the user exactly what to run.
13
+ */
14
+
15
+ if (process.env.PARITY_SKIP_PLAYWRIGHT_INSTALL === "1") {
16
+ console.log("[parity] PARITY_SKIP_PLAYWRIGHT_INSTALL=1 set — skipping Chromium install");
17
+ process.exit(0);
18
+ }
19
+
20
+ const { spawnSync } = require("node:child_process");
21
+
22
+ // Probe Playwright to find out if Chromium is already extracted. The cheap
23
+ // way: try to resolve the binary via `npx playwright install --dry-run`.
24
+ // `--dry-run` exits 0 when everything is already installed and non-zero
25
+ // when downloads are needed. We don't actually rely on the exit code; we
26
+ // always attempt `install chromium` (idempotent) and let Playwright print
27
+ // "already up to date" when there's nothing to do.
28
+ try {
29
+ console.log("[parity] Installing Playwright Chromium (one-time, ~140 MB)…");
30
+ const result = spawnSync("npx", ["--yes", "playwright", "install", "chromium"], {
31
+ stdio: "inherit",
32
+ env: process.env,
33
+ });
34
+ if (result.status === 0) {
35
+ console.log("[parity] Chromium ready.");
36
+ } else {
37
+ console.log(
38
+ "[parity] Chromium install returned a non-zero exit. " +
39
+ "If `parity run` later fails with 'Executable doesn't exist', " +
40
+ "run `npx playwright install chromium` manually.",
41
+ );
42
+ }
43
+ } catch (err) {
44
+ const reason = err?.message ?? "unknown error";
45
+ console.log(
46
+ `[parity] Playwright install skipped (${reason}). Run \`npx playwright install chromium\` before your first \`parity run\`.`,
47
+ );
48
+ }