@decocms/parity 0.7.0 → 0.8.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/README.md +23 -0
  2. package/dist/cli.js +2294 -1625
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -8473,6 +8473,9 @@ async function tracePage(page, url, selector, settleMs) {
8473
8473
  return;
8474
8474
  });
8475
8475
  await page.waitForTimeout(settleMs);
8476
+ return traceLoadedPage(page, page.url(), selector);
8477
+ }
8478
+ async function traceLoadedPage(page, url, selector) {
8476
8479
  const cdp = await page.context().newCDPSession(page);
8477
8480
  await cdp.send("DOM.enable");
8478
8481
  await cdp.send("CSS.enable");
@@ -8825,820 +8828,1892 @@ ${JSON.stringify(checks.map((c) => ({ name: c.name, status: c.status, summary: c
8825
8828
  return text ? 0 : 1;
8826
8829
  }
8827
8830
 
8828
- // src/commands/journey.ts
8829
- import { writeFileSync as writeFileSync8 } from "node:fs";
8830
- import { join as join10 } from "node:path";
8831
+ // src/commands/section.ts
8832
+ import { mkdirSync as mkdirSync4 } from "node:fs";
8833
+ import { resolve } from "node:path";
8831
8834
  import chalk10 from "chalk";
8832
- import ora3 from "ora";
8835
+ import * as cheerio5 from "cheerio";
8836
+ import * as diff2 from "diff";
8837
+ import prettier2 from "prettier";
8833
8838
 
8834
- // src/llm/pick-plp.ts
8835
- var PATH_BLOCKLIST = [
8836
- "/atendimento",
8837
- "/lojas",
8838
- "/empresa",
8839
- "/sobre",
8840
- "/blog",
8841
- "/vale-presente",
8842
- "/contato",
8843
- "/ajuda",
8844
- "/trabalhe-conosco",
8845
- "/imprensa",
8846
- "/institucional",
8847
- "/privacidade",
8848
- "/termos",
8849
- "/quem-somos",
8850
- "/loja-fisica",
8851
- "/franquia"
8839
+ // src/engine/computed-styles.ts
8840
+ var SECTION_STYLE_KEYS = [
8841
+ "display",
8842
+ "visibility",
8843
+ "opacity",
8844
+ "position",
8845
+ "z-index",
8846
+ "top",
8847
+ "right",
8848
+ "bottom",
8849
+ "left",
8850
+ "width",
8851
+ "height",
8852
+ "max-width",
8853
+ "max-height",
8854
+ "min-width",
8855
+ "min-height",
8856
+ "transform",
8857
+ "clip-path",
8858
+ "overflow",
8859
+ "overflow-x",
8860
+ "overflow-y",
8861
+ "margin",
8862
+ "padding",
8863
+ "border",
8864
+ "font-size",
8865
+ "font-weight",
8866
+ "color",
8867
+ "background-color",
8868
+ "background-image",
8869
+ "flex-direction",
8870
+ "justify-content",
8871
+ "align-items",
8872
+ "gap",
8873
+ "grid-template-columns",
8874
+ "grid-template-rows"
8852
8875
  ];
8853
- async function pickCategoryLink(candidates) {
8854
- if (candidates.length === 0)
8855
- return null;
8856
- if (candidates.length === 1)
8857
- return candidates[0];
8858
- const filtered = candidates.filter((c) => !isBlocked(c.href));
8859
- if (filtered.length === 0)
8860
- return candidates[0] ?? null;
8861
- if (filtered.length === 1)
8862
- return filtered[0];
8863
- const strong = filtered.filter((c) => /\/c\/|\/category\/|\/collections\//.test(c.href));
8864
- if (strong.length === 1)
8865
- return strong[0];
8866
- if (strong.length > 0 && strong.length < filtered.length) {
8867
- return await llmPick(strong) ?? strong[0];
8876
+ async function readComputedStyles(page, selector) {
8877
+ const keys = SECTION_STYLE_KEYS;
8878
+ let exists = false;
8879
+ try {
8880
+ exists = await page.locator(selector).count() > 0;
8881
+ } catch (err) {
8882
+ return { found: false, error: `seletor inválido: ${err.message}` };
8868
8883
  }
8869
- return await llmPick(filtered) ?? filtered[0];
8870
- }
8871
- function isBlocked(href) {
8884
+ if (!exists) {
8885
+ return { found: false, error: `seletor '${selector}' não casou nenhum elemento` };
8886
+ }
8887
+ const hiddenByPlaywright = !await page.locator(selector).first().isVisible({ timeout: 1000 }).catch(() => false);
8888
+ let got;
8872
8889
  try {
8873
- const path = new URL(href, "http://placeholder.invalid").pathname.toLowerCase();
8874
- return PATH_BLOCKLIST.some((b) => path.startsWith(b));
8875
- } catch {
8876
- return false;
8890
+ got = await page.evaluate(({ sel, keys: keys2 }) => {
8891
+ try {
8892
+ const el = document.querySelector(sel);
8893
+ if (!el || !(el instanceof HTMLElement)) {
8894
+ return { ok: false, reason: "querySelector retornou null ou non-HTMLElement" };
8895
+ }
8896
+ const cs = window.getComputedStyle(el);
8897
+ const out = {};
8898
+ for (const k of keys2)
8899
+ out[k] = cs.getPropertyValue(k) || "";
8900
+ const r = el.getBoundingClientRect();
8901
+ return {
8902
+ ok: true,
8903
+ styles: out,
8904
+ rect: { x: r.x, y: r.y, width: r.width, height: r.height }
8905
+ };
8906
+ } catch (err) {
8907
+ return {
8908
+ ok: false,
8909
+ reason: `document.querySelector inválido em '${sel}': ${err.message ?? "syntax error"}`
8910
+ };
8911
+ }
8912
+ }, { sel: selector, keys });
8913
+ } catch (err) {
8914
+ return {
8915
+ found: false,
8916
+ error: `falha no page.evaluate de '${selector}': ${err.message ?? "unknown"}. Se o seletor usa sintaxe Playwright-only (text=..., :visible, xpath=...), use um seletor CSS puro.`
8917
+ };
8877
8918
  }
8919
+ if (!got.ok) {
8920
+ return { found: false, error: got.reason };
8921
+ }
8922
+ return { found: true, styles: got.styles, rect: got.rect, hiddenByPlaywright };
8878
8923
  }
8879
- async function llmPick(candidates) {
8880
- const input = await callTool({
8881
- systemPrompt: "Você escolhe links de categoria reais em sites de e-commerce. Categoria real = página que lista produtos à venda. Evite: institucional, atendimento, blog, vale-presente, lojas físicas. Prefira URLs com /c/, /category/, /collections/ ou claramente vinculadas a tipos de produto.",
8882
- userText: `Candidatos:
8883
- ${candidates.map((c, i) => `${i}. "${c.text}" → ${c.href}`).join(`
8884
- `)}`,
8885
- maxTokens: 200,
8886
- tool: {
8887
- name: "pick_category",
8888
- description: "Pick the index of the best category link",
8889
- inputSchema: {
8890
- type: "object",
8891
- properties: {
8892
- index: { type: "number", description: "Index in the input list (0-based)" },
8893
- reasoning: { type: "string", description: "1 sentence rationale" }
8894
- },
8895
- required: ["index"]
8924
+
8925
+ // src/engine/css-source-resolver.ts
8926
+ var INHERITABLE_PROPERTIES = new Set([
8927
+ "color",
8928
+ "font-family",
8929
+ "font-size",
8930
+ "font-weight",
8931
+ "font-style",
8932
+ "line-height",
8933
+ "letter-spacing",
8934
+ "text-align",
8935
+ "text-transform",
8936
+ "text-indent",
8937
+ "visibility",
8938
+ "direction"
8939
+ ]);
8940
+ function resolveFromTrace(trace, props) {
8941
+ const result = new Map;
8942
+ const wanted = new Set;
8943
+ for (const p of props)
8944
+ wanted.add(p.toLowerCase());
8945
+ const candidates = new Map;
8946
+ for (const rule of trace.rules) {
8947
+ for (const prop of rule.properties) {
8948
+ const name = prop.name.toLowerCase();
8949
+ if (!wanted.has(name))
8950
+ continue;
8951
+ const dist = rule.inheritedFromDistance ?? 0;
8952
+ if (dist > 0 && !INHERITABLE_PROPERTIES.has(name))
8953
+ continue;
8954
+ const list = candidates.get(name) ?? [];
8955
+ list.push({ rule, value: prop.value, important: prop.important });
8956
+ candidates.set(name, list);
8957
+ }
8958
+ }
8959
+ for (const prop of wanted) {
8960
+ const list = candidates.get(prop);
8961
+ if (!list || list.length === 0) {
8962
+ result.set(prop, null);
8963
+ continue;
8964
+ }
8965
+ let winner = list[list.length - 1];
8966
+ for (let i = list.length - 1;i >= 0; i--) {
8967
+ if (list[i]?.important) {
8968
+ winner = list[i];
8969
+ break;
8896
8970
  }
8897
8971
  }
8898
- });
8899
- const idx = input?.index;
8900
- if (typeof idx === "number" && idx >= 0 && idx < candidates.length) {
8901
- return candidates[idx];
8972
+ if (!winner) {
8973
+ result.set(prop, null);
8974
+ continue;
8975
+ }
8976
+ result.set(prop, {
8977
+ source: winner.rule.source,
8978
+ selector: winner.rule.selector,
8979
+ value: winner.value,
8980
+ important: winner.important,
8981
+ inheritedFromDistance: winner.rule.inheritedFromDistance ?? 0
8982
+ });
8902
8983
  }
8903
- return null;
8984
+ return result;
8904
8985
  }
8905
8986
 
8906
- // src/llm/recover-step.ts
8907
- import * as cheerio5 from "cheerio";
8908
- var RECOVER_STEP_INPUT_SCHEMA = {
8909
- type: "object",
8910
- properties: {
8911
- selector: { type: "string", description: "CSS or Playwright selector that targets the element" },
8912
- action: { type: "string", enum: ["click", "fill", "press"] },
8913
- value: { type: "string", description: "For 'fill' or 'press', the text/key to use" },
8914
- reasoning: { type: "string" }
8915
- },
8916
- required: ["selector", "action"]
8917
- };
8918
- function compactHtmlForRecovery(html, maxChars = 12000) {
8919
- try {
8920
- const $ = cheerio5.load(html);
8921
- $("script, style, noscript, svg, picture source, link, meta").remove();
8922
- const allowed = $("header, nav, main, footer, dialog, [role='dialog'], form, button, a, input, [data-buy-button], [data-checkout], [data-minicart], [role='button']").map((_, el) => $.html(el)).get().join(`
8987
+ // src/diff/heatmap-regions.ts
8988
+ import { readFileSync as readFileSync7 } from "node:fs";
8989
+ import { PNG as PNG3 } from "pngjs";
8990
+ var DEFAULT_MAX_HOTSPOTS = 5;
8991
+ var DEFAULT_MIN_COMPONENT_PIXELS = 50;
8992
+ function analyzeHeatmapRegions(pngPath, opts = {}) {
8993
+ const png = PNG3.sync.read(readFileSync7(pngPath));
8994
+ return analyzeHeatmapBuffer(png.data, png.width, png.height, opts);
8995
+ }
8996
+ function analyzeHeatmapBuffer(data, width, height, opts = {}) {
8997
+ const maxHotspots = opts.maxHotspots ?? DEFAULT_MAX_HOTSPOTS;
8998
+ const minPixels = opts.minComponentPixels ?? DEFAULT_MIN_COMPONENT_PIXELS;
8999
+ const mask = new Uint8Array(width * height);
9000
+ let diffPixels = 0;
9001
+ for (let i = 0, p = 0;i < data.length; i += 4, p++) {
9002
+ const r = data[i] ?? 0;
9003
+ const g = data[i + 1] ?? 0;
9004
+ const b = data[i + 2] ?? 0;
9005
+ if (r > 100 && r > g + 40 && r > b + 40) {
9006
+ mask[p] = 1;
9007
+ diffPixels++;
9008
+ }
9009
+ }
9010
+ const totalPixels = width * height;
9011
+ const pctDiff = totalPixels > 0 ? diffPixels / totalPixels : 0;
9012
+ if (diffPixels === 0) {
9013
+ return {
9014
+ imageWidth: width,
9015
+ imageHeight: height,
9016
+ diffPixels: 0,
9017
+ pctDiff: 0,
9018
+ boundingBox: null,
9019
+ hotspots: []
9020
+ };
9021
+ }
9022
+ const components = [];
9023
+ const stack = [];
9024
+ let minX = width;
9025
+ let minY = height;
9026
+ let maxX = -1;
9027
+ let maxY = -1;
9028
+ for (let p = 0;p < mask.length; p++) {
9029
+ if (mask[p] !== 1)
9030
+ continue;
9031
+ const componentMinX = p % width;
9032
+ const componentMinY = Math.floor(p / width);
9033
+ let cMinX = componentMinX;
9034
+ let cMinY = componentMinY;
9035
+ let cMaxX = componentMinX;
9036
+ let cMaxY = componentMinY;
9037
+ let cPixels = 0;
9038
+ stack.push(p);
9039
+ mask[p] = 2;
9040
+ while (stack.length > 0) {
9041
+ const cur = stack.pop();
9042
+ cPixels++;
9043
+ const cx = cur % width;
9044
+ const cy = Math.floor(cur / width);
9045
+ if (cx < cMinX)
9046
+ cMinX = cx;
9047
+ if (cx > cMaxX)
9048
+ cMaxX = cx;
9049
+ if (cy < cMinY)
9050
+ cMinY = cy;
9051
+ if (cy > cMaxY)
9052
+ cMaxY = cy;
9053
+ for (let dy = -1;dy <= 1; dy++) {
9054
+ for (let dx = -1;dx <= 1; dx++) {
9055
+ if (dx === 0 && dy === 0)
9056
+ continue;
9057
+ const nx = cx + dx;
9058
+ const ny = cy + dy;
9059
+ if (nx < 0 || nx >= width || ny < 0 || ny >= height)
9060
+ continue;
9061
+ const np = ny * width + nx;
9062
+ if (mask[np] !== 1)
9063
+ continue;
9064
+ mask[np] = 2;
9065
+ stack.push(np);
9066
+ }
9067
+ }
9068
+ }
9069
+ if (cPixels >= minPixels) {
9070
+ components.push({
9071
+ x: cMinX,
9072
+ y: cMinY,
9073
+ width: cMaxX - cMinX + 1,
9074
+ height: cMaxY - cMinY + 1,
9075
+ pixelCount: cPixels
9076
+ });
9077
+ }
9078
+ if (cMinX < minX)
9079
+ minX = cMinX;
9080
+ if (cMinY < minY)
9081
+ minY = cMinY;
9082
+ if (cMaxX > maxX)
9083
+ maxX = cMaxX;
9084
+ if (cMaxY > maxY)
9085
+ maxY = cMaxY;
9086
+ }
9087
+ const sorted = components.sort((a, b) => b.width * b.height - a.width * a.height).slice(0, maxHotspots);
9088
+ const boundingBox = maxX >= 0 ? {
9089
+ x: minX,
9090
+ y: minY,
9091
+ width: maxX - minX + 1,
9092
+ height: maxY - minY + 1,
9093
+ pixelCount: diffPixels
9094
+ } : { x: 0, y: 0, width: 0, height: 0, pixelCount: 0 };
9095
+ return {
9096
+ imageWidth: width,
9097
+ imageHeight: height,
9098
+ diffPixels,
9099
+ pctDiff,
9100
+ boundingBox,
9101
+ hotspots: sorted
9102
+ };
9103
+ }
9104
+
9105
+ // src/diff/section-bundle.ts
9106
+ import { writeFileSync as writeFileSync6 } from "node:fs";
9107
+ import { basename as basename2, dirname, join as join8, relative as relative3 } from "node:path";
9108
+ function buildStyleDeltas(input) {
9109
+ const cs = input.computedStyles;
9110
+ if (!cs)
9111
+ return [];
9112
+ const prod = cs.prod;
9113
+ const cand = cs.cand;
9114
+ if (!("found" in prod) || !("found" in cand) || !prod.found || !cand.found)
9115
+ return [];
9116
+ const deltas = [];
9117
+ for (const key of SECTION_STYLE_KEYS) {
9118
+ const p = prod.styles[key] ?? "";
9119
+ const c = cand.styles[key] ?? "";
9120
+ if (p === c)
9121
+ continue;
9122
+ deltas.push({
9123
+ property: key,
9124
+ prod: p,
9125
+ cand: c,
9126
+ prodSource: input.cssSources?.prod.get(key) ?? null,
9127
+ candSource: input.cssSources?.cand.get(key) ?? null
9128
+ });
9129
+ }
9130
+ return deltas;
9131
+ }
9132
+ function assembleSectionDiffBundle(input) {
9133
+ const styleDeltas = buildStyleDeltas(input);
9134
+ const json = {
9135
+ selector: input.selector,
9136
+ pageKey: input.pageKey ?? null,
9137
+ viewport: input.viewport,
9138
+ prodUrl: input.prodUrl,
9139
+ candUrl: input.candUrl,
9140
+ html: input.html ? {
9141
+ diffPatch: input.html.diffPatch,
9142
+ prodHtmlBytes: input.html.prod.length,
9143
+ candHtmlBytes: input.html.cand.length
9144
+ } : null,
9145
+ screenshots: input.screenshots ?? null,
9146
+ heatmap: input.heatmap ?? null,
9147
+ boundingRects: styleResultsToRects(input.computedStyles),
9148
+ styleDeltas
9149
+ };
9150
+ const jsonPath = join8(input.outDir, `${input.filePrefix}-bundle.json`);
9151
+ writeFileSync6(jsonPath, `${JSON.stringify(json, mapReplacer, 2)}
9152
+ `, "utf8");
9153
+ const markdown = renderMarkdownBundle(input, styleDeltas);
9154
+ const markdownPath = join8(input.outDir, `${input.filePrefix}-prompt.md`);
9155
+ writeFileSync6(markdownPath, markdown, "utf8");
9156
+ const summary = oneLineSummary(input, styleDeltas);
9157
+ return { jsonPath, markdownPath, summary };
9158
+ }
9159
+ function mapReplacer(_key, value) {
9160
+ if (value instanceof Map)
9161
+ return Object.fromEntries(value);
9162
+ return value;
9163
+ }
9164
+ function styleResultsToRects(cs) {
9165
+ if (!cs)
9166
+ return null;
9167
+ const p = cs.prod;
9168
+ const c = cs.cand;
9169
+ if (!("found" in p) || !("found" in c) || !p.found || !c.found)
9170
+ return null;
9171
+ return { prod: p.rect, cand: c.rect };
9172
+ }
9173
+ function oneLineSummary(input, deltas) {
9174
+ const parts = [];
9175
+ if (input.heatmap && input.heatmap.diffPixels > 0) {
9176
+ const pct = (input.heatmap.pctDiff * 100).toFixed(1);
9177
+ parts.push(`${pct}% pixels differ`);
9178
+ }
9179
+ if (deltas.length > 0)
9180
+ parts.push(`${deltas.length} style delta(s)`);
9181
+ if (input.html?.diffPatch) {
9182
+ const diffLines2 = input.html.diffPatch.split(`
9183
+ `).filter((l) => /^[-+](?![-+])/.test(l)).length;
9184
+ if (diffLines2 > 0)
9185
+ parts.push(`${diffLines2} HTML line(s) changed`);
9186
+ }
9187
+ if (parts.length === 0)
9188
+ return "no diffs detected for this section";
9189
+ return parts.join(" · ");
9190
+ }
9191
+ function renderMarkdownBundle(input, deltas) {
9192
+ const lines = [];
9193
+ const md = lines.push.bind(lines);
9194
+ md(`# Pixel-perfect fix request: \`${input.selector}\``);
9195
+ md("");
9196
+ md('You are reviewing a migrated page. The **"prod"** version is the');
9197
+ md('source of truth. The **"cand"** version has divergences that need');
9198
+ md("to be fixed. Your job is to identify the smallest possible change");
9199
+ md("to make `cand` match `prod` for this section.");
9200
+ md("");
9201
+ md("## Section identification");
9202
+ md(`- **Selector**: \`${input.selector}\``);
9203
+ if (input.pageKey)
9204
+ md(`- **Page**: \`${input.pageKey}\``);
9205
+ md(`- **Viewport**: ${input.viewport}`);
9206
+ md(`- **prod URL**: ${input.prodUrl}`);
9207
+ md(`- **cand URL**: ${input.candUrl}`);
9208
+ md("");
9209
+ if (input.screenshots || input.heatmap) {
9210
+ md("## Visual diff");
9211
+ if (input.heatmap) {
9212
+ const pct = (input.heatmap.pctDiff * 100).toFixed(2);
9213
+ md(`- **${pct}% of pixels differ** (${input.heatmap.diffPixels} / ${input.heatmap.imageWidth * input.heatmap.imageHeight})`);
9214
+ if (input.heatmap.boundingBox) {
9215
+ const bb = input.heatmap.boundingBox;
9216
+ md(`- Bounding box of divergence: \`x=${bb.x} y=${bb.y} w=${bb.width} h=${bb.height}\``);
9217
+ }
9218
+ if (input.heatmap.hotspots.length > 0) {
9219
+ md("- Hotspots (top regions by area):");
9220
+ for (const h of input.heatmap.hotspots.slice(0, 5)) {
9221
+ md(` - \`(${h.x}, ${h.y}) ${h.width}×${h.height}\` — ${h.pixelCount} px`);
9222
+ }
9223
+ }
9224
+ }
9225
+ if (input.screenshots) {
9226
+ md("");
9227
+ md("**prod** (source of truth):");
9228
+ md(`![prod](${relativeOrAbs(input.outDir, input.screenshots.prodPath)})`);
9229
+ md("");
9230
+ md("**cand** (needs to match prod):");
9231
+ md(`![cand](${relativeOrAbs(input.outDir, input.screenshots.candPath)})`);
9232
+ if (input.screenshots.heatmapPath) {
9233
+ md("");
9234
+ md("**Heatmap** (red = differs):");
9235
+ md(`![heatmap](${relativeOrAbs(input.outDir, input.screenshots.heatmapPath)})`);
9236
+ }
9237
+ }
9238
+ md("");
9239
+ }
9240
+ const cs = input.computedStyles;
9241
+ if (cs && "found" in cs.prod && "found" in cs.cand && cs.prod.found && cs.cand.found) {
9242
+ const pr = cs.prod.rect;
9243
+ const cr = cs.cand.rect;
9244
+ if (pr && cr && (pr.width !== cr.width || pr.height !== cr.height)) {
9245
+ md("## Layout");
9246
+ md(`- prod section box: ${pr.width}×${pr.height} at (${pr.x}, ${pr.y})`);
9247
+ md(`- cand section box: ${cr.width}×${cr.height} at (${cr.x}, ${cr.y})`);
9248
+ const dw = cr.width - pr.width;
9249
+ const dh = cr.height - pr.height;
9250
+ if (dw !== 0)
9251
+ md(` - cand width Δ = ${dw > 0 ? "+" : ""}${dw}px`);
9252
+ if (dh !== 0)
9253
+ md(` - cand height Δ = ${dh > 0 ? "+" : ""}${dh}px`);
9254
+ md("");
9255
+ }
9256
+ }
9257
+ if (deltas.length > 0) {
9258
+ md("## Computed-styles deltas");
9259
+ md("");
9260
+ md("| Property | prod | cand | Likely source |");
9261
+ md("|---|---|---|---|");
9262
+ for (const d of deltas) {
9263
+ const source = formatSource(d.candSource);
9264
+ md(`| \`${d.property}\` | \`${truncate4(d.prod, 60)}\` | \`${truncate4(d.cand, 60)}\` | ${source} |`);
9265
+ }
9266
+ md("");
9267
+ }
9268
+ if (input.html?.diffPatch) {
9269
+ md("## HTML diff");
9270
+ md("");
9271
+ md("```diff");
9272
+ const cappedDiff = capDiff(input.html.diffPatch, 200);
9273
+ md(cappedDiff);
9274
+ md("```");
9275
+ md("");
9276
+ }
9277
+ md("## What I want you to do");
9278
+ md("");
9279
+ md("**Step 1: confirm understanding (this turn).** In ONE paragraph,");
9280
+ md("summarize what you see:");
9281
+ md("- What is the most visible difference in the heatmap?");
9282
+ md("- Which CSS property is most likely responsible?");
9283
+ md("- Which file/component would you edit?");
9284
+ md("");
9285
+ md("Do **NOT** write code yet. Just confirm what you understood.");
9286
+ md("");
9287
+ md("**Step 2 (next turn, after I confirm).** I will ask you to write");
9288
+ md("the patch. Plan to make the smallest change possible. Reuse");
9289
+ md("existing utilities/classes when the diff suggests it; avoid net-new");
9290
+ md("CSS rules unless absolutely needed.");
9291
+ md("");
9292
+ return lines.join(`
8923
9293
  `);
8924
- const truncated = allowed.length > maxChars ? `${allowed.slice(0, maxChars)}
8925
- <!-- TRUNCATED -->` : allowed;
8926
- return truncated;
9294
+ }
9295
+ function relativeOrAbs(outDir, absPath) {
9296
+ try {
9297
+ if (dirname(absPath) === outDir)
9298
+ return basename2(absPath);
9299
+ return relative3(outDir, absPath);
8927
9300
  } catch {
8928
- return html.slice(0, maxChars);
9301
+ return absPath;
8929
9302
  }
8930
9303
  }
8931
- var RECOVERY_SYSTEM_PROMPT = `
8932
- Você ajuda a recuperar passos de teste E2E (Playwright) que falharam contra sites de e-commerce.
8933
-
8934
- Receba o nome do step, a ação desejada e o HTML compactado da página ATUAL. Sugira UM seletor + ação ('click' | 'fill' | 'press') que provavelmente funciona NESTE HTML específico.
8935
-
8936
- REGRAS CRÍTICAS:
8937
-
8938
- 1. **Baseie no HTML fornecido.** Encontre o elemento literal no markup. Confirme mentalmente que o seletor que você vai retornar casa um elemento VISÍVEL no HTML compactado.
8939
-
8940
- 2. **Se o step é 'go-checkout' ou 'go-checkout-retry':**
8941
- - O alvo é um BOTÃO/LINK CTA com texto visível contendo "Finalizar", "Finalizar compra", "Continuar", "Continuar para pagamento", "Ir para o checkout", "Checkout", "Concluir", "Pagar", "Ir para o pagamento", "Avançar", "Próxima etapa".
8942
- - **NUNCA** sugira o ícone genérico de carrinho do HEADER (texto curto "0", "1", badge counter, ou aria-label "Carrinho" sozinho). Esse é o gatilho que ABRE o drawer/cart-page, não o que finaliza compra.
8943
- - **Selectors com href de checkout (\`a[href*="checkout"]\`, \`a[href="/checkout/#/cart?v=1"]\`) PODEM funcionar** mas SOMENTE quando qualificados — ou seja, garanta uma das duas:
8944
- a. Qualificação por **texto**: \`a[href*="checkout"]:has-text('Finalizar')\`, \`a[href*="checkout"]:has-text('Continuar')\`
8945
- b. Qualificação por **scope**: \`[role='dialog'] a[href*="checkout"]\`, \`[class*='minicart' i] a[href*="checkout"]\`, \`.cart-drawer a[href*="checkout"]\`
8946
- Selectors GENÉRICOS sem qualificador (apenas \`a[href*="checkout"]\` sozinho) casam o ícone do header e devem ser EVITADOS.
8947
- - Se NENHUM elemento com texto/scope plausível existe no HTML, retorne selector="" para sinalizar incerteza honestamente.
9304
+ function formatSource(s) {
9305
+ if (!s)
9306
+ return "_(no rule found / user-agent default)_";
9307
+ const tag = s.important ? " **!important**" : "";
9308
+ const inh = s.inheritedFromDistance > 0 ? " _(inherited)_" : "";
9309
+ return `\`${s.selector}\` in ${s.source}${tag}${inh}`;
9310
+ }
9311
+ function truncate4(s, max) {
9312
+ if (!s)
9313
+ return "";
9314
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
9315
+ }
9316
+ function capDiff(diff2, maxLines) {
9317
+ const lines = diff2.split(`
9318
+ `);
9319
+ if (lines.length <= maxLines)
9320
+ return diff2;
9321
+ const kept = lines.slice(0, maxLines);
9322
+ kept.push("");
9323
+ kept.push(`... [${lines.length - maxLines} more lines truncated]`);
9324
+ return kept.join(`
9325
+ `);
9326
+ }
8948
9327
 
8949
- 3. **Se o step é 'shipping-calc-cart' ou 'shipping-calc-pdp':**
8950
- - O alvo é um \`<input>\` (não button/link) visível com label/placeholder mencionando "CEP", "Frete", "Calcular", "Entrega", "Zip".
8951
- - Prefira inputs cujo container/pai está visível (não escondido em accordion fechado).
8952
-
8953
- 4. **Seletores preferidos** (nesta ordem):
8954
- - Text-based Playwright: \`button:has-text('Finalizar')\`, \`a:has-text('Continuar para pagamento')\`
8955
- - data-attributes específicos: \`[data-checkout-button]\`, \`[data-cart-finalize]\`, \`[data-fs-cart-button]\`
8956
- - ARIA: \`[aria-label*='finalizar' i]\`, \`[role='button'][aria-label*='checkout' i]\`
8957
- - Classes específicas de plataforma: \`.vtex-minicart-2-x-checkoutButton\`, \`.fs-cart__checkout\`
8958
- - Por último, escopo hierárquico curto: \`[role='dialog'] button.cta\`, \`.minicart-drawer button[type='submit']\`
8959
-
8960
- 5. **EVITE:**
8961
- - IDs hash-gerados (\`#r-abc123\`)
8962
- - Tailwind utility classes encadeadas (\`.flex.items-center.bg-blue\`)
8963
- - Seletores genéricos sem qualificador (\`a[href*=...]\`, \`button[type=submit]\` sozinho)
8964
- - Seletores que casam o ELEMENTO ERRADO em outra parte do DOM (ex: link de "checkout your reviews" no footer)
8965
- - Seletores muito profundos (>4 níveis hierárquicos)
8966
-
8967
- 6. **Quando incerto, retorne selector="" (string vazia).** É preferível que o flow declare a falha honestamente a chutar um seletor errado que apenas clica em coisa aleatória.
8968
-
8969
- Responda SEMPRE via tool_use suggest_recovery.
9328
+ // src/llm/section-understanding.ts
9329
+ import { readFileSync as readFileSync8 } from "node:fs";
9330
+ var SUMMARIZE_TOOL = {
9331
+ name: "summarize_understanding",
9332
+ description: "Return a single paragraph describing what you understood from the section diff signals. NO code only diagnosis.",
9333
+ inputSchema: {
9334
+ type: "object",
9335
+ properties: {
9336
+ summary: {
9337
+ type: "string",
9338
+ description: "ONE paragraph (4-6 sentences). Cover: (a) the most visible visual difference, (b) the CSS property most likely responsible, (c) which file/component the user should probably edit. Be concrete — name the property, name the file. NO code blocks. NO patches."
9339
+ }
9340
+ },
9341
+ required: ["summary"]
9342
+ }
9343
+ };
9344
+ var SYSTEM_PROMPT2 = `
9345
+ You are a senior frontend engineer reviewing a migrated e-commerce page
9346
+ (Fresh TanStack Start). The user shows you a Markdown bundle with
9347
+ production vs candidate signals for ONE section: heatmap, screenshots,
9348
+ computed-style deltas, and an HTML diff.
9349
+
9350
+ Your ONLY job in this turn is to confirm what you understood. Do NOT
9351
+ write code. Do NOT propose a patch. The user will explicitly ask for
9352
+ the patch in a follow-up turn after they sanity-check your reading.
9353
+
9354
+ Format: ONE paragraph. Be concrete about which property/rule is the
9355
+ likely culprit and which file/component the user should edit. If the
9356
+ signals don't agree (e.g. heatmap says diff but computed-styles match),
9357
+ say so — that's a useful diagnosis too.
8970
9358
  `.trim();
8971
- async function suggestRecovery(input) {
8972
- const compacted = compactHtmlForRecovery(input.html);
8973
- const inp = await callTool({
8974
- systemPrompt: RECOVERY_SYSTEM_PROMPT,
8975
- userText: `Step: ${input.stepName}
8976
- Ação desejada: ${input.intendedAction}
8977
- ${input.alreadyTried?.length ? `Já tentei (não repita estes): ${input.alreadyTried.join(", ")}
8978
- ` : ""}
8979
- HTML compactado da página NESTE momento:
8980
- \`\`\`html
8981
- ${compacted}
8982
- \`\`\``,
8983
- maxTokens: 400,
9359
+ function isUnderstandingAvailable() {
9360
+ return isLlmAvailable();
9361
+ }
9362
+ async function invokeUnderstandingSummary(input) {
9363
+ if (!isLlmAvailable())
9364
+ return null;
9365
+ let markdown;
9366
+ try {
9367
+ markdown = readFileSync8(input.markdownPath, "utf8");
9368
+ } catch (err) {
9369
+ console.error(`[section-understanding] failed to read markdown: ${err.message}`);
9370
+ return null;
9371
+ }
9372
+ const images = [];
9373
+ for (const p of [input.prodScreenshotPath, input.candScreenshotPath, input.heatmapPath]) {
9374
+ if (!p)
9375
+ continue;
9376
+ try {
9377
+ const buf = readFileSync8(p);
9378
+ images.push({ base64: buf.toString("base64"), mediaType: "image/png" });
9379
+ } catch (err) {
9380
+ console.error(`[section-understanding] skipping image ${p}: ${err.message}`);
9381
+ }
9382
+ }
9383
+ const result = await callTool({
9384
+ systemPrompt: SYSTEM_PROMPT2,
9385
+ userText: markdown,
9386
+ userImages: images,
9387
+ maxTokens: 600,
8984
9388
  tool: {
8985
- name: "suggest_recovery",
8986
- description: "Suggest a selector and action to recover a failed E2E step",
8987
- inputSchema: RECOVER_STEP_INPUT_SCHEMA
9389
+ name: SUMMARIZE_TOOL.name,
9390
+ description: SUMMARIZE_TOOL.description,
9391
+ inputSchema: SUMMARIZE_TOOL.inputSchema
8988
9392
  }
8989
9393
  });
8990
- if (inp?.selector?.trim() && inp.action) {
8991
- return {
8992
- selector: inp.selector.trim(),
8993
- action: inp.action,
8994
- value: inp.value,
8995
- reasoning: inp.reasoning
8996
- };
8997
- }
8998
- return null;
9394
+ if (!result?.summary)
9395
+ return null;
9396
+ return { summary: result.summary.trim() };
8999
9397
  }
9000
9398
 
9001
- // src/learned/repo.ts
9002
- import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync7, renameSync, writeFileSync as writeFileSync6 } from "node:fs";
9003
- import { dirname } from "node:path";
9004
- import { z as z2 } from "zod";
9005
- var SelectorKey = z2.enum([
9006
- "categoryLink",
9007
- "productCard",
9008
- "buyButton",
9009
- "minicartTrigger",
9010
- "cepInputPdp",
9011
- "cepInputCart",
9012
- "checkoutButton",
9013
- "sizeSwatch",
9014
- "colorSwatch",
9015
- "variantRow",
9016
- "quantityIncrement",
9017
- "quantityInput",
9018
- "minicartCount"
9019
- ]);
9020
- var SelectorEntry = z2.object({
9021
- selector: z2.string(),
9022
- confirmedHosts: z2.array(z2.string()),
9023
- successRate: z2.number().min(0).max(1),
9024
- totalAttempts: z2.number().int().nonnegative(),
9025
- lastValidated: z2.string(),
9026
- deprecated: z2.boolean().optional()
9027
- });
9028
- var LearnedSelectors = z2.object({
9029
- schemaVersion: z2.literal("0.1"),
9030
- platforms: z2.record(z2.string(), z2.record(SelectorKey, z2.array(SelectorEntry)))
9031
- });
9032
- var DEFAULT_PATH = "learned-selectors.json";
9033
- function emptyLib() {
9034
- return { schemaVersion: "0.1", platforms: {} };
9399
+ // src/commands/section.ts
9400
+ async function sectionCommand(opts) {
9401
+ const viewport = parseViewport4(opts.viewport);
9402
+ if (!viewport) {
9403
+ console.error(chalk10.red(`viewport inválido: ${opts.viewport} (use mobile|desktop|tablet)`));
9404
+ return 2;
9405
+ }
9406
+ const waitMs = Number.parseInt(opts.wait, 10);
9407
+ if (!Number.isFinite(waitMs) || waitMs < 0) {
9408
+ console.error(chalk10.red(`--wait inválido: ${opts.wait}`));
9409
+ return 2;
9410
+ }
9411
+ if (!opts.selector || opts.selector.trim().length === 0) {
9412
+ console.error(chalk10.red("--selector é obrigatório"));
9413
+ return 2;
9414
+ }
9415
+ if (!isValidUrl2(opts.prod) || !isValidUrl2(opts.cand)) {
9416
+ console.error(chalk10.red("--prod ou --cand inválido"));
9417
+ return 2;
9418
+ }
9419
+ const facetsAsked = Boolean(opts.outputHtml) || Boolean(opts.screenshot) || Boolean(opts.computedStyles);
9420
+ const want = {
9421
+ html: facetsAsked ? Boolean(opts.outputHtml) : true,
9422
+ screenshot: facetsAsked ? Boolean(opts.screenshot) : true,
9423
+ styles: facetsAsked ? Boolean(opts.computedStyles) : true,
9424
+ heatmap: Boolean(opts.heatmap),
9425
+ cssSource: Boolean(opts.cssSource),
9426
+ prompt: Boolean(opts.prompt) || Boolean(opts.llmSummary),
9427
+ llmSummary: Boolean(opts.llmSummary)
9428
+ };
9429
+ if (want.heatmap)
9430
+ want.screenshot = true;
9431
+ mkdirSync4(opts.outDir, { recursive: true });
9432
+ const hash2 = hashSelector(opts.selector);
9433
+ const filePrefix = `section-${hash2}-${viewport}`;
9434
+ const screenshotPaths = {
9435
+ prod: resolve(opts.outDir, `${filePrefix}-prod.png`),
9436
+ cand: resolve(opts.outDir, `${filePrefix}-cand.png`)
9437
+ };
9438
+ const heatmapPath = resolve(opts.outDir, `${filePrefix}-heatmap.png`);
9439
+ const browser = await launchBrowser({ headless: true });
9440
+ try {
9441
+ const [prodSide, candSide] = await Promise.all([
9442
+ gatherSide(browser, {
9443
+ url: opts.prod,
9444
+ viewport,
9445
+ waitMs,
9446
+ selector: opts.selector,
9447
+ wantScreenshot: want.screenshot,
9448
+ wantStyles: want.styles,
9449
+ wantHtml: want.html,
9450
+ wantCssSource: want.cssSource,
9451
+ screenshotPath: screenshotPaths.prod
9452
+ }),
9453
+ gatherSide(browser, {
9454
+ url: opts.cand,
9455
+ viewport,
9456
+ waitMs,
9457
+ selector: opts.selector,
9458
+ wantScreenshot: want.screenshot,
9459
+ wantStyles: want.styles,
9460
+ wantHtml: want.html,
9461
+ wantCssSource: want.cssSource,
9462
+ screenshotPath: screenshotPaths.cand
9463
+ })
9464
+ ]);
9465
+ const heatmap = want.heatmap ? computeHeatmap(prodSide, candSide, screenshotPaths, heatmapPath) : null;
9466
+ const bundlePaths = want.prompt ? await buildPromptBundle({
9467
+ opts,
9468
+ viewport,
9469
+ prodSide,
9470
+ candSide,
9471
+ screenshotPaths,
9472
+ heatmapPath: heatmap?.wrote ? heatmapPath : undefined,
9473
+ heatmap: heatmap?.analysis ?? undefined,
9474
+ filePrefix
9475
+ }) : null;
9476
+ let llmSummary = null;
9477
+ if (want.llmSummary && bundlePaths) {
9478
+ llmSummary = await maybeRunLlmSummary({
9479
+ markdownPath: bundlePaths.markdownPath,
9480
+ screenshotPaths,
9481
+ heatmapPath: heatmap?.wrote ? heatmapPath : undefined
9482
+ });
9483
+ }
9484
+ if (opts.json) {
9485
+ console.log(JSON.stringify({
9486
+ prod: opts.prod,
9487
+ cand: opts.cand,
9488
+ selector: opts.selector,
9489
+ viewport,
9490
+ prodSide,
9491
+ candSide,
9492
+ screenshotPaths: want.screenshot ? screenshotPaths : null,
9493
+ heatmap: heatmap?.analysis ?? null,
9494
+ heatmapPath: heatmap?.wrote ? heatmapPath : null,
9495
+ bundle: bundlePaths,
9496
+ llmSummary
9497
+ }));
9498
+ return verdict(prodSide, candSide);
9499
+ }
9500
+ await printResults2({ opts, viewport, prodSide, candSide, screenshotPaths, want });
9501
+ if (heatmap)
9502
+ printHeatmap(heatmap, heatmapPath);
9503
+ if (bundlePaths)
9504
+ printBundlePaths(bundlePaths);
9505
+ if (llmSummary)
9506
+ printLlmSummary(llmSummary);
9507
+ return verdict(prodSide, candSide);
9508
+ } finally {
9509
+ await browser.close().catch(() => {
9510
+ return;
9511
+ });
9512
+ }
9035
9513
  }
9036
- function loadLearned(path = DEFAULT_PATH) {
9037
- if (!existsSync6(path))
9038
- return emptyLib();
9514
+ async function gatherSide(browser, opts) {
9515
+ const ctx = await newContext(browser, { viewport: opts.viewport });
9516
+ const page = await ctx.newPage();
9517
+ const result = {
9518
+ html: null,
9519
+ styles: null,
9520
+ screenshotTaken: false
9521
+ };
9039
9522
  try {
9040
- const raw = JSON.parse(readFileSync7(path, "utf8"));
9041
- return LearnedSelectors.parse(raw);
9523
+ await page.goto(opts.url, { waitUntil: "domcontentloaded", timeout: 30000 });
9524
+ await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => {
9525
+ return;
9526
+ });
9527
+ if (opts.waitMs > 0)
9528
+ await page.waitForTimeout(opts.waitMs);
9529
+ await Promise.race([
9530
+ stabilizeCarousels(page).catch(() => {
9531
+ return;
9532
+ }),
9533
+ new Promise((resolve2) => setTimeout(resolve2, 3000))
9534
+ ]);
9535
+ if (opts.wantHtml) {
9536
+ try {
9537
+ const fullHtml = await page.content();
9538
+ const $ = cheerio5.load(fullHtml);
9539
+ const matches = $(opts.selector);
9540
+ if (matches.length === 0) {
9541
+ result.htmlError = `seletor '${opts.selector}' não casou nenhum elemento`;
9542
+ } else {
9543
+ result.html = $.html(matches.first());
9544
+ }
9545
+ } catch (err) {
9546
+ result.htmlError = `falha lendo HTML: ${err.message}`;
9547
+ }
9548
+ }
9549
+ if (opts.wantStyles) {
9550
+ result.styles = await readComputedStyles(page, opts.selector);
9551
+ }
9552
+ if (opts.wantCssSource) {
9553
+ try {
9554
+ const trace = await traceLoadedPage(page, opts.url, opts.selector);
9555
+ if (trace.found) {
9556
+ result.cssSources = resolveFromTrace(trace, SECTION_STYLE_KEYS);
9557
+ } else {
9558
+ result.cssSourceError = `tracePage não encontrou '${opts.selector}'`;
9559
+ }
9560
+ } catch (err) {
9561
+ result.cssSourceError = `tracePage falhou: ${err.message}`;
9562
+ }
9563
+ }
9564
+ if (opts.wantScreenshot) {
9565
+ await captureSectionScreenshot(page, opts.selector, opts.screenshotPath).then((err) => {
9566
+ if (err)
9567
+ result.screenshotError = err;
9568
+ else
9569
+ result.screenshotTaken = true;
9570
+ });
9571
+ }
9572
+ } finally {
9573
+ await page.close().catch(() => {
9574
+ return;
9575
+ });
9576
+ await ctx.close().catch(() => {
9577
+ return;
9578
+ });
9579
+ }
9580
+ return result;
9581
+ }
9582
+ async function captureSectionScreenshot(page, selector, outPath) {
9583
+ try {
9584
+ const loc = page.locator(selector).first();
9585
+ if (await loc.count() === 0) {
9586
+ return `seletor '${selector}' não casou nenhum elemento`;
9587
+ }
9588
+ await loc.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {
9589
+ return;
9590
+ });
9591
+ await loc.screenshot({ path: outPath, timeout: 8000 });
9592
+ return null;
9042
9593
  } catch (err) {
9043
- console.warn(`[learned] failed to parse ${path}: ${err.message}, starting fresh`);
9044
- return emptyLib();
9594
+ return `falha no screenshot: ${err.message}`;
9045
9595
  }
9046
9596
  }
9047
- function saveLearned(lib, path = DEFAULT_PATH) {
9048
- const dir = dirname(path);
9049
- if (dir && dir !== "." && !existsSync6(dir))
9050
- mkdirSync4(dir, { recursive: true });
9051
- const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
9052
- writeFileSync6(tmp, `${JSON.stringify(lib, null, 2)}
9053
- `, "utf8");
9054
- renameSync(tmp, path);
9597
+ function isValidUrl2(s) {
9598
+ try {
9599
+ new URL(s);
9600
+ return true;
9601
+ } catch {
9602
+ return false;
9603
+ }
9055
9604
  }
9056
- function getLearnedSelectors(lib, platform, key) {
9057
- const platformEntries = lib.platforms[platform];
9058
- if (!platformEntries)
9059
- return [];
9060
- const entries = platformEntries[key] ?? [];
9061
- return entries.filter((e) => !e.deprecated).sort((a, b) => b.successRate - a.successRate);
9605
+ function parseViewport4(raw) {
9606
+ if (raw === "mobile" || raw === "desktop" || raw === "tablet")
9607
+ return raw;
9608
+ return null;
9062
9609
  }
9063
- function ensureSlot(lib, platform, key) {
9064
- if (!lib.platforms[platform])
9065
- lib.platforms[platform] = {};
9066
- const platformEntries = lib.platforms[platform];
9067
- if (!platformEntries[key])
9068
- platformEntries[key] = [];
9069
- return platformEntries[key];
9610
+ function hashSelector(selector) {
9611
+ let h = 0;
9612
+ for (let i = 0;i < selector.length; i++) {
9613
+ h = (h << 5) - h + selector.charCodeAt(i);
9614
+ h |= 0;
9615
+ }
9616
+ return (h >>> 0).toString(16).padStart(8, "0").slice(0, 8);
9070
9617
  }
9071
- function recordSuccess(lib, platform, key, selector, host) {
9072
- const list = ensureSlot(lib, platform, key);
9073
- let entry = list.find((e) => e.selector === selector);
9074
- if (!entry) {
9075
- entry = {
9076
- selector,
9077
- confirmedHosts: [host],
9078
- successRate: 1,
9079
- totalAttempts: 1,
9080
- lastValidated: new Date().toISOString()
9618
+ function verdict(prod, cand) {
9619
+ if (prod.htmlError || cand.htmlError)
9620
+ return 1;
9621
+ if (prod.styles && "found" in prod.styles && cand.styles && "found" in cand.styles) {
9622
+ if (prod.styles.found && cand.styles.found) {
9623
+ for (const k of SECTION_STYLE_KEYS) {
9624
+ if (prod.styles.styles[k] !== cand.styles.styles[k])
9625
+ return 1;
9626
+ }
9627
+ if (prod.styles.hiddenByPlaywright !== cand.styles.hiddenByPlaywright)
9628
+ return 1;
9629
+ const pr = prod.styles.rect;
9630
+ const cr = cand.styles.rect;
9631
+ if (pr && cr && (pr.width !== cr.width || pr.height !== cr.height))
9632
+ return 1;
9633
+ } else if (prod.styles.found !== cand.styles.found) {
9634
+ return 1;
9635
+ }
9636
+ }
9637
+ if (prod.html && cand.html) {
9638
+ if (prod.html !== cand.html)
9639
+ return 1;
9640
+ }
9641
+ return 0;
9642
+ }
9643
+ async function printResults2(args) {
9644
+ const { opts, viewport, prodSide, candSide, screenshotPaths, want } = args;
9645
+ console.log(chalk10.bold(`
9646
+ parity section`));
9647
+ console.log(chalk10.dim(` selector: ${opts.selector}`));
9648
+ console.log(chalk10.dim(` viewport: ${viewport}`));
9649
+ console.log(chalk10.dim(` prod: ${opts.prod}`));
9650
+ console.log(chalk10.dim(` cand: ${opts.cand}`));
9651
+ console.log("");
9652
+ if (want.html)
9653
+ await printHtmlDiff(prodSide, candSide, opts.selector);
9654
+ if (want.styles)
9655
+ printStylesDiff(prodSide, candSide);
9656
+ if (want.screenshot)
9657
+ printScreenshotPaths(prodSide, candSide, screenshotPaths);
9658
+ }
9659
+ async function printHtmlDiff(prod, cand, selector) {
9660
+ console.log(chalk10.bold(" HTML diff"));
9661
+ if (prod.htmlError) {
9662
+ console.log(chalk10.red(` prod: ${prod.htmlError}`));
9663
+ return;
9664
+ }
9665
+ if (cand.htmlError) {
9666
+ console.log(chalk10.red(` cand: ${cand.htmlError}`));
9667
+ return;
9668
+ }
9669
+ if (!prod.html || !cand.html) {
9670
+ console.log(chalk10.dim(" (nenhum lado tem HTML disponível)"));
9671
+ return;
9672
+ }
9673
+ const [prodFmt, candFmt] = await Promise.all([formatHtml(prod.html), formatHtml(cand.html)]);
9674
+ if (prodFmt === candFmt) {
9675
+ console.log(chalk10.green(" ✓ idêntico após pretty-print"));
9676
+ console.log("");
9677
+ return;
9678
+ }
9679
+ const patch = diff2.createPatch(selector, prodFmt, candFmt, "prod", "cand");
9680
+ console.log(colorPatch(patch));
9681
+ console.log("");
9682
+ }
9683
+ function printStylesDiff(prod, cand) {
9684
+ console.log(chalk10.bold(" Computed styles diff"));
9685
+ if (!prod.styles || !cand.styles) {
9686
+ console.log(chalk10.dim(" (styles não foram coletados)"));
9687
+ return;
9688
+ }
9689
+ if (!prod.styles.found) {
9690
+ console.log(chalk10.red(` prod: ${prod.styles.error}`));
9691
+ return;
9692
+ }
9693
+ if (!cand.styles.found) {
9694
+ console.log(chalk10.red(` cand: ${cand.styles.error}`));
9695
+ return;
9696
+ }
9697
+ const rows = [];
9698
+ for (const k of SECTION_STYLE_KEYS) {
9699
+ const p = prod.styles.styles[k] ?? "";
9700
+ const c = cand.styles.styles[k] ?? "";
9701
+ if (p !== c)
9702
+ rows.push({ key: k, prod: p, cand: c });
9703
+ }
9704
+ if (prod.styles.hiddenByPlaywright !== cand.styles.hiddenByPlaywright) {
9705
+ rows.unshift({
9706
+ key: "(playwright isVisible)",
9707
+ prod: prod.styles.hiddenByPlaywright ? "hidden" : "visible",
9708
+ cand: cand.styles.hiddenByPlaywright ? "hidden" : "visible"
9709
+ });
9710
+ }
9711
+ const prodRect = prod.styles.rect;
9712
+ const candRect = cand.styles.rect;
9713
+ if (prodRect && candRect && (prodRect.width !== candRect.width || prodRect.height !== candRect.height)) {
9714
+ rows.unshift({
9715
+ key: "(boundingRect)",
9716
+ prod: `${prodRect.width}×${prodRect.height} @ ${prodRect.x},${prodRect.y}`,
9717
+ cand: `${candRect.width}×${candRect.height} @ ${candRect.x},${candRect.y}`
9718
+ });
9719
+ }
9720
+ if (rows.length === 0) {
9721
+ console.log(chalk10.green(" ✓ todos os SECTION_STYLE_KEYS são iguais"));
9722
+ console.log("");
9723
+ return;
9724
+ }
9725
+ const keyWidth = Math.max(...rows.map((r) => r.key.length));
9726
+ for (const r of rows) {
9727
+ console.log(` ${chalk10.cyan(r.key.padEnd(keyWidth))} ${chalk10.red(`prod=${r.prod || "(vazio)"}`)} ${chalk10.green(`cand=${r.cand || "(vazio)"}`)}`);
9728
+ }
9729
+ console.log("");
9730
+ }
9731
+ function printScreenshotPaths(prod, cand, paths) {
9732
+ console.log(chalk10.bold(" Screenshots"));
9733
+ if (prod.screenshotError) {
9734
+ console.log(chalk10.red(` prod: ${prod.screenshotError}`));
9735
+ } else if (prod.screenshotTaken) {
9736
+ console.log(` prod: ${chalk10.dim(paths.prod)}`);
9737
+ }
9738
+ if (cand.screenshotError) {
9739
+ console.log(chalk10.red(` cand: ${cand.screenshotError}`));
9740
+ } else if (cand.screenshotTaken) {
9741
+ console.log(` cand: ${chalk10.dim(paths.cand)}`);
9742
+ }
9743
+ console.log("");
9744
+ }
9745
+ async function formatHtml(raw) {
9746
+ try {
9747
+ return await prettier2.format(raw, {
9748
+ parser: "html",
9749
+ printWidth: 100,
9750
+ htmlWhitespaceSensitivity: "ignore"
9751
+ });
9752
+ } catch {
9753
+ return raw;
9754
+ }
9755
+ }
9756
+ function colorPatch(patch) {
9757
+ const lines = patch.split(`
9758
+ `);
9759
+ const out = [];
9760
+ for (const line of lines) {
9761
+ if (line.startsWith("Index:") || line.startsWith("==="))
9762
+ continue;
9763
+ if (line.startsWith("---") || line.startsWith("+++"))
9764
+ out.push(chalk10.dim(line));
9765
+ else if (line.startsWith("@@"))
9766
+ out.push(chalk10.cyan(line));
9767
+ else if (line.startsWith("+"))
9768
+ out.push(chalk10.green(line));
9769
+ else if (line.startsWith("-"))
9770
+ out.push(chalk10.red(line));
9771
+ else
9772
+ out.push(line);
9773
+ }
9774
+ return out.join(`
9775
+ `);
9776
+ }
9777
+ function computeHeatmap(prod, cand, paths, heatmapPath) {
9778
+ if (!prod.screenshotTaken || !cand.screenshotTaken) {
9779
+ return {
9780
+ wrote: false,
9781
+ analysis: null,
9782
+ error: "heatmap pulado: um dos screenshots falhou"
9081
9783
  };
9082
- list.push(entry);
9083
- return entry;
9084
9784
  }
9085
- const successes = entry.successRate * entry.totalAttempts + 1;
9086
- entry.totalAttempts += 1;
9087
- entry.successRate = successes / entry.totalAttempts;
9088
- if (!entry.confirmedHosts.includes(host))
9089
- entry.confirmedHosts.push(host);
9090
- entry.lastValidated = new Date().toISOString();
9091
- if (entry.deprecated && entry.successRate > 0.5)
9092
- entry.deprecated = undefined;
9093
- return entry;
9785
+ try {
9786
+ diffScreenshots(paths.prod, paths.cand, heatmapPath, { maxPctDiff: 1 });
9787
+ const analysis = analyzeHeatmapRegions(heatmapPath);
9788
+ return { wrote: true, analysis };
9789
+ } catch (err) {
9790
+ return { wrote: false, analysis: null, error: err.message };
9791
+ }
9792
+ }
9793
+ async function buildPromptBundle(args) {
9794
+ const { opts, viewport, prodSide, candSide, screenshotPaths, heatmapPath, heatmap, filePrefix } = args;
9795
+ let htmlBundle;
9796
+ if (prodSide.html && candSide.html) {
9797
+ const [pFmt, cFmt] = await Promise.all([formatHtml(prodSide.html), formatHtml(candSide.html)]);
9798
+ htmlBundle = {
9799
+ prod: pFmt,
9800
+ cand: cFmt,
9801
+ diffPatch: diff2.createPatch(opts.selector, pFmt, cFmt, "prod", "cand")
9802
+ };
9803
+ }
9804
+ const computedStyles = prodSide.styles && candSide.styles ? { prod: prodSide.styles, cand: candSide.styles } : undefined;
9805
+ const cssSources = prodSide.cssSources && candSide.cssSources ? { prod: prodSide.cssSources, cand: candSide.cssSources } : undefined;
9806
+ const screenshots = prodSide.screenshotTaken && candSide.screenshotTaken ? {
9807
+ prodPath: screenshotPaths.prod,
9808
+ candPath: screenshotPaths.cand,
9809
+ heatmapPath
9810
+ } : undefined;
9811
+ return assembleSectionDiffBundle({
9812
+ selector: opts.selector,
9813
+ pageKey: `${new URL(opts.prod).pathname}::${viewport}`,
9814
+ viewport,
9815
+ prodUrl: opts.prod,
9816
+ candUrl: opts.cand,
9817
+ html: htmlBundle,
9818
+ screenshots,
9819
+ heatmap,
9820
+ computedStyles,
9821
+ cssSources,
9822
+ outDir: opts.outDir,
9823
+ filePrefix
9824
+ });
9094
9825
  }
9095
- function recordFailure(lib, platform, key, selector, host) {
9096
- const list = ensureSlot(lib, platform, key);
9097
- const entry = list.find((e) => e.selector === selector);
9098
- if (!entry)
9826
+ async function maybeRunLlmSummary(args) {
9827
+ if (!isUnderstandingAvailable()) {
9828
+ console.warn(chalk10.yellow(" --llm-summary pulado: nenhum ANTHROPIC_API_KEY ou OPENROUTER_API_KEY configurado."));
9099
9829
  return null;
9100
- const successes = entry.successRate * entry.totalAttempts;
9101
- entry.totalAttempts += 1;
9102
- entry.successRate = successes / entry.totalAttempts;
9103
- if (entry.successRate < 0.3 && entry.totalAttempts >= 3) {
9104
- entry.deprecated = true;
9105
9830
  }
9106
- entry.lastValidated = new Date().toISOString();
9107
- return entry;
9108
- }
9109
- function promoteFromLlm(lib, platform, key, selector, host) {
9110
- const list = ensureSlot(lib, platform, key);
9111
- const existing = list.find((e) => e.selector === selector);
9112
- if (existing)
9113
- return recordSuccess(lib, platform, key, selector, host);
9114
- const entry = {
9115
- selector,
9116
- confirmedHosts: [host],
9117
- successRate: 0.5,
9118
- totalAttempts: 1,
9119
- lastValidated: new Date().toISOString()
9120
- };
9121
- list.push(entry);
9122
- return entry;
9831
+ const result = await invokeUnderstandingSummary({
9832
+ markdownPath: args.markdownPath,
9833
+ prodScreenshotPath: args.screenshotPaths.prod,
9834
+ candScreenshotPath: args.screenshotPaths.cand,
9835
+ heatmapPath: args.heatmapPath
9836
+ });
9837
+ return result?.summary ?? null;
9123
9838
  }
9124
- function statsFromLib(lib) {
9125
- const out = { platforms: [] };
9126
- for (const [platform, byKey] of Object.entries(lib.platforms)) {
9127
- let totalSelectors = 0;
9128
- let deprecated = 0;
9129
- const topByKey = [];
9130
- for (const [key, entries] of Object.entries(byKey)) {
9131
- for (const e of entries) {
9132
- totalSelectors += 1;
9133
- if (e.deprecated)
9134
- deprecated += 1;
9135
- }
9136
- const top = entries.filter((e) => !e.deprecated).sort((a, b) => b.successRate - a.successRate)[0];
9137
- if (top) {
9138
- topByKey.push({
9139
- key,
9140
- selector: top.selector,
9141
- successRate: top.successRate,
9142
- hosts: top.confirmedHosts.length
9143
- });
9144
- }
9839
+ function printHeatmap(state, heatmapPath) {
9840
+ console.log(chalk10.bold(" Heatmap"));
9841
+ if (state.error) {
9842
+ console.log(chalk10.yellow(` ${state.error}`));
9843
+ console.log("");
9844
+ return;
9845
+ }
9846
+ if (!state.analysis) {
9847
+ console.log(chalk10.dim(" (sem dados de heatmap)"));
9848
+ console.log("");
9849
+ return;
9850
+ }
9851
+ const a = state.analysis;
9852
+ const pct = (a.pctDiff * 100).toFixed(2);
9853
+ console.log(` ${chalk10.cyan("diffPixels")}: ${a.diffPixels} (${pct}%)`);
9854
+ if (a.boundingBox) {
9855
+ const bb = a.boundingBox;
9856
+ console.log(` ${chalk10.cyan("boundingBox")}: x=${bb.x} y=${bb.y} w=${bb.width} h=${bb.height}`);
9857
+ }
9858
+ if (a.hotspots.length > 0) {
9859
+ console.log(` ${chalk10.cyan("hotspots")}:`);
9860
+ for (const h of a.hotspots.slice(0, 5)) {
9861
+ console.log(` (${h.x}, ${h.y}) ${h.width}×${h.height} — ${h.pixelCount} px`);
9145
9862
  }
9146
- out.platforms.push({
9147
- platform,
9148
- totalSelectors,
9149
- activeSelectors: totalSelectors - deprecated,
9150
- deprecatedSelectors: deprecated,
9151
- topByKey
9152
- });
9153
9863
  }
9154
- return out;
9864
+ console.log(` ${chalk10.dim(`heatmap: ${heatmapPath}`)}`);
9865
+ console.log("");
9155
9866
  }
9156
- var LEARNED_PATH = DEFAULT_PATH;
9157
-
9158
- // src/engine/selectors.ts
9159
- var DEFAULT_SELECTORS = {
9160
- categoryLink: [
9161
- "header nav a[href*='/c/']",
9162
- "header nav a[href*='/category/']",
9163
- "header nav a[href*='/collections/']",
9164
- "[data-mega-menu] a[href*='/c/']",
9165
- "header a[href]:not([href='/']):not([href='#']):not([href*='login']):not([href*='cart'])"
9166
- ],
9167
- productCard: [
9168
- "[data-product-card] a",
9169
- "[data-deco='view-product'] a",
9170
- "[data-testid='product-card'] a",
9171
- ".product-card a",
9172
- "article a[href*='/p/']",
9173
- "a[href*='/products/']"
9174
- ],
9175
- buyButton: [
9176
- "button:has-text('Comprar')",
9177
- "button:has-text('Adicionar')",
9178
- "button:has-text('Add to cart')",
9179
- "button:has-text('Add to bag')",
9180
- "[data-buy-button]",
9181
- "[data-testid='add-to-cart']",
9182
- "button[type='submit']:has-text('Comprar')"
9183
- ],
9184
- minicartTrigger: [
9185
- "[data-minicart-trigger]",
9186
- "[data-testid='minicart-trigger']",
9187
- "[aria-label*='carrinho' i]",
9188
- "[aria-label*='cart' i]",
9189
- "header button:has([data-cart-icon])",
9190
- "header a[href='/checkout']"
9191
- ],
9192
- cepInputPdp: [
9193
- "input[name='shipping-zipcode']",
9194
- "input[name='zipcode']",
9195
- "input[name='cep']",
9196
- "input[placeholder*='CEP' i]",
9197
- "input[placeholder*='Postal' i]",
9198
- "[data-shipping-input] input"
9199
- ],
9200
- cepInputCart: [
9201
- "input[name='cart-zipcode']",
9202
- "[data-minicart] input[name*='zip' i]",
9203
- "[data-cart] input[name*='zip' i]",
9204
- "[role='dialog'] input[name*='cep' i]",
9205
- "[role='dialog'] input[placeholder*='CEP' i]"
9206
- ],
9207
- checkoutButton: [
9208
- "a:has-text('Finalizar compra')",
9209
- "button:has-text('Finalizar compra')",
9210
- "a:has-text('Ir para o checkout')",
9211
- "button:has-text('Ir para o checkout')",
9212
- "[role='dialog'] a:has-text('Finalizar')",
9213
- "[role='dialog'] button:has-text('Finalizar')",
9214
- "[data-minicart] a:has-text('Finalizar')",
9215
- "[data-minicart] button:has-text('Finalizar')",
9216
- "[data-cart-drawer] a:has-text('Finalizar')",
9217
- "[data-cart-drawer] button:has-text('Finalizar')",
9218
- "a:has-text('Checkout')",
9219
- "[data-checkout-button]",
9220
- "a:text-is('Finalizar')",
9221
- "button:text-is('Finalizar')"
9222
- ],
9223
- sizeSwatch: [
9224
- "[data-size]:not([disabled]):not([aria-disabled='true']):not(.unavailable):not(.sold-out)",
9225
- "[data-variant-size]:not([disabled]):not([aria-disabled='true']):not(.unavailable)",
9226
- "button[aria-label*='tamanho' i]:not([aria-disabled='true']):not([disabled])",
9227
- "button[aria-label*='size' i]:not([aria-disabled='true']):not([disabled])",
9228
- "[data-testid='size-selector'] button:not([disabled]):not(.unavailable)",
9229
- ".size-selector button:not([disabled]):not(.unavailable):not(.sold-out)",
9230
- "[class*='size'] button:not([disabled]):not(.unavailable)",
9231
- "label:has(input[name*='size' i]:not([disabled])) "
9232
- ],
9233
- colorSwatch: [
9234
- "[data-color]:not([disabled]):not([aria-disabled='true']):not(.unavailable):not(.sold-out)",
9235
- "[data-variant-color]:not([disabled]):not([aria-disabled='true']):not(.unavailable)",
9236
- "button[aria-label*='cor' i]:not([aria-disabled='true']):not([disabled])",
9237
- "button[aria-label*='color' i]:not([aria-disabled='true']):not([disabled])",
9238
- "[data-testid='color-selector'] button:not([disabled]):not(.unavailable)",
9239
- ".color-swatch:not(.unavailable):not(.sold-out)",
9240
- "[class*='color'] button:not([disabled]):not(.unavailable)"
9241
- ],
9242
- variantRow: [
9243
- "[data-sku-row]",
9244
- "[data-variant-row]",
9245
- "tr[data-variant]",
9246
- "tr:has([aria-label*='quantidade' i])",
9247
- "tbody tr:has(input[type='number'])"
9248
- ],
9249
- quantityIncrement: [
9250
- "[data-qty-plus]",
9251
- "[data-quantity-plus]",
9252
- "button[aria-label*='aumentar' i]",
9253
- "button[aria-label*='increase' i]",
9254
- "button[aria-label*='increment' i]",
9255
- "button:has-text('+')",
9256
- "[class*='qty'] button:has-text('+')"
9257
- ],
9258
- quantityInput: [
9259
- "input[type='number'][min='0'][value='0']",
9260
- "input[id*='quantity-input' i]",
9261
- "input[name*='qty' i]",
9262
- "input[name*='quantity' i]",
9263
- "input[name*='quantidade' i]",
9264
- "input[aria-label*='quantidade' i]",
9265
- "[data-qty-input]",
9266
- "input[type='number'][min='0']",
9267
- "input[type='number'][min='1']"
9268
- ],
9269
- minicartCount: [
9270
- "[data-minicart-count]",
9271
- "[data-cart-count]",
9272
- ".cart-count",
9273
- ".minicart__count",
9274
- "[class*='cart-count']",
9275
- "[class*='CartCount']",
9276
- "[aria-label*='itens' i][role='status']",
9277
- "header [data-minicart-trigger] [class*='badge']",
9278
- "header [aria-label*='cart' i] [class*='count']",
9279
- "header [aria-label*='sacola' i] [class*='badge']",
9280
- "header [aria-label*='sacola' i] [class*='count']",
9281
- "header a[href*='/cart'] [class*='count']",
9282
- "header a[href*='/checkout'] [class*='count']",
9283
- "[data-fs-cart-icon] + [class*='count']",
9284
- "[class*='Minicart'] [class*='count']",
9285
- "[class*='minicart'] [class*='count']"
9286
- ]
9287
- };
9288
- function selectorsFor(key, ctxOrRc = {}) {
9289
- const ctx = isResolutionContext(ctxOrRc) ? ctxOrRc : { rc: ctxOrRc };
9290
- const out = [];
9291
- const override = ctx.rc?.selectors?.[key];
9292
- if (override)
9293
- out.push(override);
9294
- if (ctx.learned && ctx.platform) {
9295
- for (const entry of getLearnedSelectors(ctx.learned, ctx.platform, key)) {
9296
- out.push(entry.selector);
9297
- }
9298
- }
9299
- out.push(...DEFAULT_SELECTORS[key]);
9300
- return [...new Set(out)];
9867
+ function printBundlePaths(b) {
9868
+ console.log(chalk10.bold(" Bundle"));
9869
+ console.log(` ${chalk10.cyan("summary")}: ${b.summary}`);
9870
+ console.log(` ${chalk10.cyan("json")}: ${chalk10.dim(b.jsonPath)}`);
9871
+ console.log(` ${chalk10.cyan("prompt")}: ${chalk10.dim(b.markdownPath)}`);
9872
+ console.log("");
9301
9873
  }
9302
- function isResolutionContext(v) {
9303
- if (!v || typeof v !== "object")
9304
- return false;
9305
- const k = Object.keys(v);
9306
- return k.length === 0 || k.some((x) => x === "rc" || x === "learned" || x === "platform");
9874
+ function printLlmSummary(summary) {
9875
+ console.log(chalk10.bold(" What the LLM understood"));
9876
+ console.log(` ${summary.split(`
9877
+ `).join(`
9878
+ `)}`);
9879
+ console.log("");
9307
9880
  }
9308
9881
 
9309
- // src/engine/flows.ts
9310
- async function screenshotStable(page, opts) {
9311
- await Promise.race([
9312
- stabilizeCarousels(page).catch(() => {
9313
- return;
9314
- }),
9315
- new Promise((resolve) => setTimeout(resolve, 3000))
9316
- ]);
9317
- await page.screenshot({ path: opts.path, fullPage: opts.fullPage ?? false }).catch(() => {
9318
- return;
9319
- });
9320
- }
9321
- var PURCHASE_JOURNEY_TOTAL_STEPS = 9;
9322
- var DEBUG_PARITY2 = process.env.DEBUG_PARITY === "1" || process.env.DEBUG_PARITY === "true";
9323
- var DEBUG_START2 = Date.now();
9324
- function dlog2(ctx, msg) {
9325
- if (!DEBUG_PARITY2)
9326
- return;
9327
- const elapsed = ((Date.now() - DEBUG_START2) / 1000).toFixed(1);
9328
- process.stderr.write(`[+${elapsed}s ${ctx.viewport}/${ctx.side}] ${msg}
9329
- `);
9330
- }
9331
- function withCap(p, capMs, fallback) {
9332
- return Promise.race([
9333
- p.catch(() => fallback),
9334
- new Promise((resolve) => setTimeout(() => resolve(fallback), capMs))
9335
- ]);
9882
+ // src/commands/fix.ts
9883
+ async function fixCommand(opts) {
9884
+ const sectionOpts = {
9885
+ prod: opts.prod,
9886
+ cand: opts.cand,
9887
+ selector: opts.selector,
9888
+ viewport: opts.viewport,
9889
+ wait: opts.wait,
9890
+ outDir: opts.outDir,
9891
+ json: opts.json,
9892
+ outputHtml: true,
9893
+ screenshot: true,
9894
+ computedStyles: true,
9895
+ heatmap: true,
9896
+ cssSource: true,
9897
+ prompt: true,
9898
+ llmSummary: !opts.noLlm
9899
+ };
9900
+ return sectionCommand(sectionOpts);
9336
9901
  }
9337
- var VARIANT_REQUIRED_TEXT_PATTERNS = [
9338
- /selecione um produto/i,
9339
- /selecione um tamanho/i,
9340
- /selecione uma cor/i,
9341
- /selecione uma op[cç][aã]o/i,
9342
- /select a size/i,
9343
- /select a color/i,
9344
- /select an option/i,
9345
- /choose an option/i,
9346
- /please select/i,
9347
- /select size/i,
9348
- /select color/i
9349
- ];
9350
- var ADD_TO_CART_ERROR_PATTERNS = [
9351
- ...VARIANT_REQUIRED_TEXT_PATTERNS,
9352
- /estoque esgotado/i,
9353
- /out of stock/i,
9354
- /indispon[ií]vel/i,
9355
- /unavailable/i
9356
- ];
9357
- var ADD_TO_CART_SUCCESS_PATTERNS = [
9358
- /produto adicionado/i,
9359
- /adicionado ao carrinho/i,
9360
- /adicionado [aà]\s+sacola/i,
9361
- /added to cart/i,
9362
- /added to bag/i,
9363
- /item added/i,
9364
- /successfully added/i
9902
+
9903
+ // src/commands/journey.ts
9904
+ import { writeFileSync as writeFileSync9 } from "node:fs";
9905
+ import { join as join11 } from "node:path";
9906
+ import chalk11 from "chalk";
9907
+ import ora3 from "ora";
9908
+
9909
+ // src/llm/pick-plp.ts
9910
+ var PATH_BLOCKLIST = [
9911
+ "/atendimento",
9912
+ "/lojas",
9913
+ "/empresa",
9914
+ "/sobre",
9915
+ "/blog",
9916
+ "/vale-presente",
9917
+ "/contato",
9918
+ "/ajuda",
9919
+ "/trabalhe-conosco",
9920
+ "/imprensa",
9921
+ "/institucional",
9922
+ "/privacidade",
9923
+ "/termos",
9924
+ "/quem-somos",
9925
+ "/loja-fisica",
9926
+ "/franquia"
9365
9927
  ];
9366
- function selFor(ctx, key) {
9367
- return selectorsFor(key, { rc: ctx.rc, learned: ctx.learned, platform: ctx.platform });
9928
+ async function pickCategoryLink(candidates) {
9929
+ if (candidates.length === 0)
9930
+ return null;
9931
+ if (candidates.length === 1)
9932
+ return candidates[0];
9933
+ const filtered = candidates.filter((c) => !isBlocked(c.href));
9934
+ if (filtered.length === 0)
9935
+ return candidates[0] ?? null;
9936
+ if (filtered.length === 1)
9937
+ return filtered[0];
9938
+ const strong = filtered.filter((c) => /\/c\/|\/category\/|\/collections\//.test(c.href));
9939
+ if (strong.length === 1)
9940
+ return strong[0];
9941
+ if (strong.length > 0 && strong.length < filtered.length) {
9942
+ return await llmPick(strong) ?? strong[0];
9943
+ }
9944
+ return await llmPick(filtered) ?? filtered[0];
9368
9945
  }
9369
- var FLOW_DEADLINE_MS = {
9370
- homepage: 90000,
9371
- plp: 180000,
9372
- pdp: 240000,
9373
- "purchase-journey": 420000
9374
- };
9375
- async function runFlow(flow, ctx) {
9376
- const start = Date.now();
9377
- const deadlineMs = FLOW_DEADLINE_MS[flow];
9378
- const inner = async () => {
9379
- switch (flow) {
9380
- case "homepage":
9381
- return finalize(flow, ctx, await flowHomepage(ctx), [], start);
9382
- case "plp":
9383
- return finalize(flow, ctx, await flowPlp(ctx), [], start);
9384
- case "pdp":
9385
- return finalize(flow, ctx, await flowPdp(ctx), [], start);
9386
- case "purchase-journey": {
9387
- const { pages, steps } = await flowPurchaseJourney(ctx);
9388
- return finalize(flow, ctx, pages, steps, start);
9946
+ function isBlocked(href) {
9947
+ try {
9948
+ const path = new URL(href, "http://placeholder.invalid").pathname.toLowerCase();
9949
+ return PATH_BLOCKLIST.some((b) => path.startsWith(b));
9950
+ } catch {
9951
+ return false;
9952
+ }
9953
+ }
9954
+ async function llmPick(candidates) {
9955
+ const input = await callTool({
9956
+ systemPrompt: "Você escolhe links de categoria reais em sites de e-commerce. Categoria real = página que lista produtos à venda. Evite: institucional, atendimento, blog, vale-presente, lojas físicas. Prefira URLs com /c/, /category/, /collections/ ou claramente vinculadas a tipos de produto.",
9957
+ userText: `Candidatos:
9958
+ ${candidates.map((c, i) => `${i}. "${c.text}" → ${c.href}`).join(`
9959
+ `)}`,
9960
+ maxTokens: 200,
9961
+ tool: {
9962
+ name: "pick_category",
9963
+ description: "Pick the index of the best category link",
9964
+ inputSchema: {
9965
+ type: "object",
9966
+ properties: {
9967
+ index: { type: "number", description: "Index in the input list (0-based)" },
9968
+ reasoning: { type: "string", description: "1 sentence rationale" }
9969
+ },
9970
+ required: ["index"]
9389
9971
  }
9390
9972
  }
9391
- };
9392
- const innerPromise = inner();
9393
- innerPromise.catch(() => {
9394
- return;
9395
- });
9396
- let timer;
9397
- let cleanup = Promise.resolve();
9398
- const timeoutPromise = new Promise((resolve) => {
9399
- timer = setTimeout(() => {
9400
- const pages = ctx.ctx.pages();
9401
- resolve(finalize(flow, ctx, [], [
9402
- {
9403
- step: 0,
9404
- name: "flow-timeout",
9405
- side: ctx.side,
9406
- viewport: ctx.viewport,
9407
- status: "failed",
9408
- durationMs: deadlineMs,
9409
- screenshotPath: "",
9410
- actionDescription: `[flow-timeout] flow "${flow}" excedeu ${deadlineMs}ms — captura abortada pela safety net externa, ${pages.length} page(s) fechada(s) para liberar o contexto.`
9411
- }
9412
- ], start));
9413
- const CLOSE_CAP_MS = 5000;
9414
- const cappedClose = (p) => Promise.race([
9415
- p.close().catch(() => {
9416
- return;
9417
- }),
9418
- new Promise((resolveClose) => setTimeout(resolveClose, CLOSE_CAP_MS))
9419
- ]);
9420
- cleanup = Promise.allSettled(pages.map(cappedClose));
9421
- }, deadlineMs);
9422
9973
  });
9423
- try {
9424
- const result = await Promise.race([innerPromise, timeoutPromise]);
9425
- await cleanup;
9426
- return result;
9427
- } finally {
9428
- if (timer !== undefined)
9429
- clearTimeout(timer);
9974
+ const idx = input?.index;
9975
+ if (typeof idx === "number" && idx >= 0 && idx < candidates.length) {
9976
+ return candidates[idx];
9430
9977
  }
9978
+ return null;
9431
9979
  }
9432
- function finalize(flow, ctx, pages, steps, start) {
9433
- return {
9434
- flow,
9435
- side: ctx.side,
9436
- viewport: ctx.viewport,
9437
- pages,
9438
- steps,
9439
- totalDurationMs: Date.now() - start
9440
- };
9441
- }
9442
- function screenshotPath(ctx, label) {
9443
- const safe = label.replace(/[^a-z0-9_-]+/gi, "-").toLowerCase();
9444
- return `${ctx.outDir}/${safe}-${ctx.viewport}-${ctx.side}.png`;
9445
- }
9446
- async function flowHomepage(ctx) {
9447
- const page = await ctx.ctx.newPage();
9980
+
9981
+ // src/llm/recover-step.ts
9982
+ import * as cheerio6 from "cheerio";
9983
+ var RECOVER_STEP_INPUT_SCHEMA = {
9984
+ type: "object",
9985
+ properties: {
9986
+ selector: { type: "string", description: "CSS or Playwright selector that targets the element" },
9987
+ action: { type: "string", enum: ["click", "fill", "press"] },
9988
+ value: { type: "string", description: "For 'fill' or 'press', the text/key to use" },
9989
+ reasoning: { type: "string" }
9990
+ },
9991
+ required: ["selector", "action"]
9992
+ };
9993
+ function compactHtmlForRecovery(html, maxChars = 12000) {
9448
9994
  try {
9449
- const cap = await capturePage(page, {
9450
- url: ctx.baseUrl,
9451
- side: ctx.side,
9452
- viewport: ctx.viewport,
9453
- screenshotPath: screenshotPath(ctx, "home")
9454
- });
9455
- return [cap];
9456
- } finally {
9457
- await page.close();
9995
+ const $ = cheerio6.load(html);
9996
+ $("script, style, noscript, svg, picture source, link, meta").remove();
9997
+ const allowed = $("header, nav, main, footer, dialog, [role='dialog'], form, button, a, input, [data-buy-button], [data-checkout], [data-minicart], [role='button']").map((_, el) => $.html(el)).get().join(`
9998
+ `);
9999
+ const truncated = allowed.length > maxChars ? `${allowed.slice(0, maxChars)}
10000
+ <!-- TRUNCATED -->` : allowed;
10001
+ return truncated;
10002
+ } catch {
10003
+ return html.slice(0, maxChars);
9458
10004
  }
9459
10005
  }
9460
- async function flowPlp(ctx) {
9461
- const home = await ctx.ctx.newPage();
9462
- const homeCap = await capturePage(home, {
9463
- url: ctx.baseUrl,
9464
- side: ctx.side,
9465
- viewport: ctx.viewport,
9466
- screenshotPath: screenshotPath(ctx, "home")
10006
+ var RECOVERY_SYSTEM_PROMPT = `
10007
+ Você ajuda a recuperar passos de teste E2E (Playwright) que falharam contra sites de e-commerce.
10008
+
10009
+ Receba o nome do step, a ação desejada e o HTML compactado da página ATUAL. Sugira UM seletor + ação ('click' | 'fill' | 'press') que provavelmente funciona NESTE HTML específico.
10010
+
10011
+ REGRAS CRÍTICAS:
10012
+
10013
+ 1. **Baseie no HTML fornecido.** Encontre o elemento literal no markup. Confirme mentalmente que o seletor que você vai retornar casa um elemento VISÍVEL no HTML compactado.
10014
+
10015
+ 2. **Se o step é 'go-checkout' ou 'go-checkout-retry':**
10016
+ - O alvo é um BOTÃO/LINK CTA com texto visível contendo "Finalizar", "Finalizar compra", "Continuar", "Continuar para pagamento", "Ir para o checkout", "Checkout", "Concluir", "Pagar", "Ir para o pagamento", "Avançar", "Próxima etapa".
10017
+ - **NUNCA** sugira o ícone genérico de carrinho do HEADER (texto curto "0", "1", badge counter, ou aria-label "Carrinho" sozinho). Esse é o gatilho que ABRE o drawer/cart-page, não o que finaliza compra.
10018
+ - **Selectors com href de checkout (\`a[href*="checkout"]\`, \`a[href="/checkout/#/cart?v=1"]\`) PODEM funcionar** mas SOMENTE quando qualificados — ou seja, garanta uma das duas:
10019
+ a. Qualificação por **texto**: \`a[href*="checkout"]:has-text('Finalizar')\`, \`a[href*="checkout"]:has-text('Continuar')\`
10020
+ b. Qualificação por **scope**: \`[role='dialog'] a[href*="checkout"]\`, \`[class*='minicart' i] a[href*="checkout"]\`, \`.cart-drawer a[href*="checkout"]\`
10021
+ Selectors GENÉRICOS sem qualificador (apenas \`a[href*="checkout"]\` sozinho) casam o ícone do header e devem ser EVITADOS.
10022
+ - Se NENHUM elemento com texto/scope plausível existe no HTML, retorne selector="" para sinalizar incerteza honestamente.
10023
+
10024
+ 3. **Se o step é 'shipping-calc-cart' ou 'shipping-calc-pdp':**
10025
+ - O alvo é um \`<input>\` (não button/link) visível com label/placeholder mencionando "CEP", "Frete", "Calcular", "Entrega", "Zip".
10026
+ - Prefira inputs cujo container/pai está visível (não escondido em accordion fechado).
10027
+
10028
+ 4. **Seletores preferidos** (nesta ordem):
10029
+ - Text-based Playwright: \`button:has-text('Finalizar')\`, \`a:has-text('Continuar para pagamento')\`
10030
+ - data-attributes específicos: \`[data-checkout-button]\`, \`[data-cart-finalize]\`, \`[data-fs-cart-button]\`
10031
+ - ARIA: \`[aria-label*='finalizar' i]\`, \`[role='button'][aria-label*='checkout' i]\`
10032
+ - Classes específicas de plataforma: \`.vtex-minicart-2-x-checkoutButton\`, \`.fs-cart__checkout\`
10033
+ - Por último, escopo hierárquico curto: \`[role='dialog'] button.cta\`, \`.minicart-drawer button[type='submit']\`
10034
+
10035
+ 5. **EVITE:**
10036
+ - IDs hash-gerados (\`#r-abc123\`)
10037
+ - Tailwind utility classes encadeadas (\`.flex.items-center.bg-blue\`)
10038
+ - Seletores genéricos sem qualificador (\`a[href*=...]\`, \`button[type=submit]\` sozinho)
10039
+ - Seletores que casam o ELEMENTO ERRADO em outra parte do DOM (ex: link de "checkout your reviews" no footer)
10040
+ - Seletores muito profundos (>4 níveis hierárquicos)
10041
+
10042
+ 6. **Quando incerto, retorne selector="" (string vazia).** É preferível que o flow declare a falha honestamente a chutar um seletor errado que apenas clica em coisa aleatória.
10043
+
10044
+ Responda SEMPRE via tool_use suggest_recovery.
10045
+ `.trim();
10046
+ async function suggestRecovery(input) {
10047
+ const compacted = compactHtmlForRecovery(input.html);
10048
+ const inp = await callTool({
10049
+ systemPrompt: RECOVERY_SYSTEM_PROMPT,
10050
+ userText: `Step: ${input.stepName}
10051
+ Ação desejada: ${input.intendedAction}
10052
+ ${input.alreadyTried?.length ? `Já tentei (não repita estes): ${input.alreadyTried.join(", ")}
10053
+ ` : ""}
10054
+ HTML compactado da página NESTE momento:
10055
+ \`\`\`html
10056
+ ${compacted}
10057
+ \`\`\``,
10058
+ maxTokens: 400,
10059
+ tool: {
10060
+ name: "suggest_recovery",
10061
+ description: "Suggest a selector and action to recover a failed E2E step",
10062
+ inputSchema: RECOVER_STEP_INPUT_SCHEMA
10063
+ }
9467
10064
  });
9468
- const plpHit = ctx.rc.plpUrlHint ? { url: new URL(ctx.rc.plpUrlHint, ctx.baseUrl).toString(), selector: "__hint__" } : await findCategoryUrl(home, ctx);
9469
- await home.close();
9470
- if (!plpHit)
9471
- return [homeCap];
9472
- const plp = await ctx.ctx.newPage();
9473
- try {
9474
- const cap = await capturePage(plp, {
9475
- url: plpHit.url,
9476
- side: ctx.side,
9477
- viewport: ctx.viewport,
9478
- screenshotPath: screenshotPath(ctx, "plp")
9479
- });
9480
- return [homeCap, cap];
9481
- } finally {
9482
- await plp.close();
9483
- }
9484
- }
9485
- async function flowPdp(ctx) {
9486
- const pages = [];
9487
- const home = await ctx.ctx.newPage();
9488
- pages.push(await capturePage(home, {
9489
- url: ctx.baseUrl,
9490
- side: ctx.side,
9491
- viewport: ctx.viewport,
9492
- screenshotPath: screenshotPath(ctx, "home")
9493
- }));
9494
- const plpHit = ctx.rc.plpUrlHint ? { url: new URL(ctx.rc.plpUrlHint, ctx.baseUrl).toString(), selector: "__hint__" } : await findCategoryUrl(home, ctx);
9495
- await home.close();
9496
- if (!plpHit)
9497
- return pages;
9498
- const plp = await ctx.ctx.newPage();
9499
- pages.push(await capturePage(plp, {
9500
- url: plpHit.url,
9501
- side: ctx.side,
9502
- viewport: ctx.viewport,
9503
- screenshotPath: screenshotPath(ctx, "plp")
9504
- }));
9505
- const pdpHit = await findProductUrl(plp, ctx);
9506
- await plp.close();
9507
- if (!pdpHit)
9508
- return pages;
9509
- const pdp = await ctx.ctx.newPage();
9510
- try {
9511
- pages.push(await capturePage(pdp, {
9512
- url: pdpHit.url,
9513
- side: ctx.side,
9514
- viewport: ctx.viewport,
9515
- screenshotPath: screenshotPath(ctx, "pdp")
9516
- }));
9517
- return pages;
9518
- } finally {
9519
- await pdp.close();
10065
+ if (inp?.selector?.trim() && inp.action) {
10066
+ return {
10067
+ selector: inp.selector.trim(),
10068
+ action: inp.action,
10069
+ value: inp.value,
10070
+ reasoning: inp.reasoning
10071
+ };
9520
10072
  }
10073
+ return null;
9521
10074
  }
9522
- async function flowPurchaseJourney(ctx) {
9523
- const pages = [];
9524
- const steps = [];
9525
- const page = await ctx.ctx.newPage();
9526
- const budget = { remaining: ctx.recoveryBudget ?? 5 };
9527
- const total = PURCHASE_JOURNEY_TOTAL_STEPS;
9528
- const reportStart = (idx, name) => {
9529
- ctx.onStep?.({ phase: "start", name, index: idx, total });
9530
- };
9531
- const reportEnd = (idx, name, status, durationMs, note) => {
9532
- ctx.onStep?.({ phase: "end", name, index: idx, total, status, durationMs, note });
9533
- };
10075
+
10076
+ // src/learned/repo.ts
10077
+ import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync9, renameSync, writeFileSync as writeFileSync7 } from "node:fs";
10078
+ import { dirname as dirname2 } from "node:path";
10079
+ import { z as z2 } from "zod";
10080
+ var SelectorKey = z2.enum([
10081
+ "categoryLink",
10082
+ "productCard",
10083
+ "buyButton",
10084
+ "minicartTrigger",
10085
+ "cepInputPdp",
10086
+ "cepInputCart",
10087
+ "checkoutButton",
10088
+ "sizeSwatch",
10089
+ "colorSwatch",
10090
+ "variantRow",
10091
+ "quantityIncrement",
10092
+ "quantityInput",
10093
+ "minicartCount"
10094
+ ]);
10095
+ var SelectorEntry = z2.object({
10096
+ selector: z2.string(),
10097
+ confirmedHosts: z2.array(z2.string()),
10098
+ successRate: z2.number().min(0).max(1),
10099
+ totalAttempts: z2.number().int().nonnegative(),
10100
+ lastValidated: z2.string(),
10101
+ deprecated: z2.boolean().optional()
10102
+ });
10103
+ var LearnedSelectors = z2.object({
10104
+ schemaVersion: z2.literal("0.1"),
10105
+ platforms: z2.record(z2.string(), z2.record(SelectorKey, z2.array(SelectorEntry)))
10106
+ });
10107
+ var DEFAULT_PATH = "learned-selectors.json";
10108
+ function emptyLib() {
10109
+ return { schemaVersion: "0.1", platforms: {} };
10110
+ }
10111
+ function loadLearned(path = DEFAULT_PATH) {
10112
+ if (!existsSync6(path))
10113
+ return emptyLib();
9534
10114
  try {
9535
- reportStart(1, "visit-home");
9536
- const homeCap = await capturePage(page, {
9537
- url: ctx.baseUrl,
9538
- side: ctx.side,
9539
- viewport: ctx.viewport,
9540
- screenshotPath: screenshotPath(ctx, "pj-1-home")
9541
- });
9542
- pages.push(homeCap);
9543
- const step1Status = homeCap.status >= 200 && homeCap.status < 400 ? "ok" : "failed";
9544
- steps.push({
9545
- step: 1,
9546
- name: "visit-home",
9547
- side: ctx.side,
9548
- viewport: ctx.viewport,
9549
- status: step1Status,
9550
- durationMs: homeCap.durationMs,
9551
- url: homeCap.finalUrl,
9552
- screenshotPath: homeCap.screenshotPath
9553
- });
9554
- reportEnd(1, "visit-home", step1Status, homeCap.durationMs);
9555
- steps[steps.length - 1].actionDescription = `Navegou pra home \`${ctx.baseUrl}\` (HTTP ${homeCap.status})`;
9556
- if (homeCap.status >= 400 || homeCap.status === 0) {
9557
- return { pages, steps };
9558
- }
9559
- reportStart(2, "navigate-plp");
9560
- const plpHit = ctx.rc.plpUrlHint ? { url: new URL(ctx.rc.plpUrlHint, ctx.baseUrl).toString(), selector: "__hint__" } : await findCategoryUrl(page, ctx);
9561
- if (!plpHit) {
9562
- steps.push(makeSkipStep(2, "navigate-plp", ctx, "no category link found"));
9563
- reportEnd(2, "navigate-plp", "skipped", 0, "no category link found");
9564
- return { pages, steps };
9565
- }
9566
- const t2 = Date.now();
9567
- const plpCap = await capturePage(page, {
9568
- url: plpHit.url,
9569
- side: ctx.side,
9570
- viewport: ctx.viewport,
9571
- screenshotPath: screenshotPath(ctx, "pj-2-plp")
9572
- });
9573
- pages.push(plpCap);
9574
- const step2Status = plpCap.status >= 200 && plpCap.status < 400 ? "ok" : "failed";
9575
- steps.push({
9576
- step: 2,
9577
- name: "navigate-plp",
9578
- side: ctx.side,
9579
- viewport: ctx.viewport,
9580
- status: step2Status,
9581
- durationMs: Date.now() - t2,
9582
- url: plpCap.finalUrl,
9583
- screenshotPath: plpCap.screenshotPath,
9584
- selectorKey: "categoryLink",
9585
- usedSelector: plpHit.selector
9586
- });
9587
- reportEnd(2, "navigate-plp", step2Status, Date.now() - t2);
9588
- steps[steps.length - 1].actionDescription = `Navegou pra categoria \`${plpHit.url}\` (via \`${plpHit.selector}\`)`;
9589
- steps[steps.length - 1].beforeUrl = ctx.baseUrl;
9590
- reportStart(3, "enter-pdp");
9591
- let pdpHit = await findProductUrl(page, ctx);
9592
- let pdpRecoveredByLlm = false;
9593
- if (!pdpHit && budget.remaining > 0) {
9594
- const html = await page.content().catch(() => "");
9595
- if (html) {
9596
- const suggestion = await suggestRecovery({
9597
- stepName: "enter-pdp",
9598
- intendedAction: "Encontrar um link <a> que leve para a página de detalhes (PDP) de algum produto listado na PLP atual",
9599
- html,
9600
- alreadyTried: selFor(ctx, "productCard")
10115
+ const raw = JSON.parse(readFileSync9(path, "utf8"));
10116
+ return LearnedSelectors.parse(raw);
10117
+ } catch (err) {
10118
+ console.warn(`[learned] failed to parse ${path}: ${err.message}, starting fresh`);
10119
+ return emptyLib();
10120
+ }
10121
+ }
10122
+ function saveLearned(lib, path = DEFAULT_PATH) {
10123
+ const dir = dirname2(path);
10124
+ if (dir && dir !== "." && !existsSync6(dir))
10125
+ mkdirSync5(dir, { recursive: true });
10126
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
10127
+ writeFileSync7(tmp, `${JSON.stringify(lib, null, 2)}
10128
+ `, "utf8");
10129
+ renameSync(tmp, path);
10130
+ }
10131
+ function getLearnedSelectors(lib, platform, key) {
10132
+ const platformEntries = lib.platforms[platform];
10133
+ if (!platformEntries)
10134
+ return [];
10135
+ const entries = platformEntries[key] ?? [];
10136
+ return entries.filter((e) => !e.deprecated).sort((a, b) => b.successRate - a.successRate);
10137
+ }
10138
+ function ensureSlot(lib, platform, key) {
10139
+ if (!lib.platforms[platform])
10140
+ lib.platforms[platform] = {};
10141
+ const platformEntries = lib.platforms[platform];
10142
+ if (!platformEntries[key])
10143
+ platformEntries[key] = [];
10144
+ return platformEntries[key];
10145
+ }
10146
+ function recordSuccess(lib, platform, key, selector, host) {
10147
+ const list = ensureSlot(lib, platform, key);
10148
+ let entry = list.find((e) => e.selector === selector);
10149
+ if (!entry) {
10150
+ entry = {
10151
+ selector,
10152
+ confirmedHosts: [host],
10153
+ successRate: 1,
10154
+ totalAttempts: 1,
10155
+ lastValidated: new Date().toISOString()
10156
+ };
10157
+ list.push(entry);
10158
+ return entry;
10159
+ }
10160
+ const successes = entry.successRate * entry.totalAttempts + 1;
10161
+ entry.totalAttempts += 1;
10162
+ entry.successRate = successes / entry.totalAttempts;
10163
+ if (!entry.confirmedHosts.includes(host))
10164
+ entry.confirmedHosts.push(host);
10165
+ entry.lastValidated = new Date().toISOString();
10166
+ if (entry.deprecated && entry.successRate > 0.5)
10167
+ entry.deprecated = undefined;
10168
+ return entry;
10169
+ }
10170
+ function recordFailure(lib, platform, key, selector, host) {
10171
+ const list = ensureSlot(lib, platform, key);
10172
+ const entry = list.find((e) => e.selector === selector);
10173
+ if (!entry)
10174
+ return null;
10175
+ const successes = entry.successRate * entry.totalAttempts;
10176
+ entry.totalAttempts += 1;
10177
+ entry.successRate = successes / entry.totalAttempts;
10178
+ if (entry.successRate < 0.3 && entry.totalAttempts >= 3) {
10179
+ entry.deprecated = true;
10180
+ }
10181
+ entry.lastValidated = new Date().toISOString();
10182
+ return entry;
10183
+ }
10184
+ function promoteFromLlm(lib, platform, key, selector, host) {
10185
+ const list = ensureSlot(lib, platform, key);
10186
+ const existing = list.find((e) => e.selector === selector);
10187
+ if (existing)
10188
+ return recordSuccess(lib, platform, key, selector, host);
10189
+ const entry = {
10190
+ selector,
10191
+ confirmedHosts: [host],
10192
+ successRate: 0.5,
10193
+ totalAttempts: 1,
10194
+ lastValidated: new Date().toISOString()
10195
+ };
10196
+ list.push(entry);
10197
+ return entry;
10198
+ }
10199
+ function statsFromLib(lib) {
10200
+ const out = { platforms: [] };
10201
+ for (const [platform, byKey] of Object.entries(lib.platforms)) {
10202
+ let totalSelectors = 0;
10203
+ let deprecated = 0;
10204
+ const topByKey = [];
10205
+ for (const [key, entries] of Object.entries(byKey)) {
10206
+ for (const e of entries) {
10207
+ totalSelectors += 1;
10208
+ if (e.deprecated)
10209
+ deprecated += 1;
10210
+ }
10211
+ const top = entries.filter((e) => !e.deprecated).sort((a, b) => b.successRate - a.successRate)[0];
10212
+ if (top) {
10213
+ topByKey.push({
10214
+ key,
10215
+ selector: top.selector,
10216
+ successRate: top.successRate,
10217
+ hosts: top.confirmedHosts.length
9601
10218
  });
9602
- if (suggestion) {
9603
- budget.remaining--;
9604
- try {
9605
- const el = page.locator(suggestion.selector).first();
9606
- if (await el.isVisible({ timeout: 2000 }).catch(() => false)) {
9607
- const href = await el.getAttribute("href");
9608
- if (href) {
9609
- const abs = new URL(href, page.url()).toString();
9610
- pdpHit = { url: abs, selector: suggestion.selector };
9611
- pdpRecoveredByLlm = true;
9612
- }
9613
- }
9614
- } catch {}
9615
- }
9616
10219
  }
9617
10220
  }
9618
- if (!pdpHit) {
9619
- steps.push(makeSkipStep(3, "enter-pdp", ctx, "no product card found (recovery exhausted)"));
9620
- reportEnd(3, "enter-pdp", "skipped", 0, "no product card found");
9621
- return { pages, steps };
9622
- }
9623
- const t3 = Date.now();
9624
- const pdpCap = await capturePage(page, {
9625
- url: pdpHit.url,
9626
- side: ctx.side,
9627
- viewport: ctx.viewport,
9628
- screenshotPath: screenshotPath(ctx, "pj-3-pdp")
10221
+ out.platforms.push({
10222
+ platform,
10223
+ totalSelectors,
10224
+ activeSelectors: totalSelectors - deprecated,
10225
+ deprecatedSelectors: deprecated,
10226
+ topByKey
9629
10227
  });
9630
- pages.push(pdpCap);
9631
- const step3Status = pdpCap.status >= 200 && pdpCap.status < 400 ? "ok" : "failed";
9632
- steps.push({
9633
- step: 3,
9634
- name: "enter-pdp",
9635
- side: ctx.side,
9636
- viewport: ctx.viewport,
9637
- status: step3Status,
9638
- durationMs: Date.now() - t3,
9639
- url: pdpCap.finalUrl,
9640
- screenshotPath: pdpCap.screenshotPath,
9641
- selectorKey: "productCard",
10228
+ }
10229
+ return out;
10230
+ }
10231
+ var LEARNED_PATH = DEFAULT_PATH;
10232
+
10233
+ // src/engine/selectors.ts
10234
+ var DEFAULT_SELECTORS = {
10235
+ categoryLink: [
10236
+ "header nav a[href*='/c/']",
10237
+ "header nav a[href*='/category/']",
10238
+ "header nav a[href*='/collections/']",
10239
+ "[data-mega-menu] a[href*='/c/']",
10240
+ "header a[href]:not([href='/']):not([href='#']):not([href*='login']):not([href*='cart'])"
10241
+ ],
10242
+ productCard: [
10243
+ "[data-product-card] a",
10244
+ "[data-deco='view-product'] a",
10245
+ "[data-testid='product-card'] a",
10246
+ ".product-card a",
10247
+ "article a[href*='/p/']",
10248
+ "a[href*='/products/']"
10249
+ ],
10250
+ buyButton: [
10251
+ "button:has-text('Comprar')",
10252
+ "button:has-text('Adicionar')",
10253
+ "button:has-text('Add to cart')",
10254
+ "button:has-text('Add to bag')",
10255
+ "[data-buy-button]",
10256
+ "[data-testid='add-to-cart']",
10257
+ "button[type='submit']:has-text('Comprar')"
10258
+ ],
10259
+ minicartTrigger: [
10260
+ "[data-minicart-trigger]",
10261
+ "[data-testid='minicart-trigger']",
10262
+ "[aria-label*='carrinho' i]",
10263
+ "[aria-label*='cart' i]",
10264
+ "header button:has([data-cart-icon])",
10265
+ "header a[href='/checkout']"
10266
+ ],
10267
+ cepInputPdp: [
10268
+ "input[name='shipping-zipcode']",
10269
+ "input[name='zipcode']",
10270
+ "input[name='cep']",
10271
+ "input[placeholder*='CEP' i]",
10272
+ "input[placeholder*='Postal' i]",
10273
+ "[data-shipping-input] input"
10274
+ ],
10275
+ cepInputCart: [
10276
+ "input[name='cart-zipcode']",
10277
+ "[data-minicart] input[name*='zip' i]",
10278
+ "[data-cart] input[name*='zip' i]",
10279
+ "[role='dialog'] input[name*='cep' i]",
10280
+ "[role='dialog'] input[placeholder*='CEP' i]"
10281
+ ],
10282
+ checkoutButton: [
10283
+ "a:has-text('Finalizar compra')",
10284
+ "button:has-text('Finalizar compra')",
10285
+ "a:has-text('Ir para o checkout')",
10286
+ "button:has-text('Ir para o checkout')",
10287
+ "[role='dialog'] a:has-text('Finalizar')",
10288
+ "[role='dialog'] button:has-text('Finalizar')",
10289
+ "[data-minicart] a:has-text('Finalizar')",
10290
+ "[data-minicart] button:has-text('Finalizar')",
10291
+ "[data-cart-drawer] a:has-text('Finalizar')",
10292
+ "[data-cart-drawer] button:has-text('Finalizar')",
10293
+ "a:has-text('Checkout')",
10294
+ "[data-checkout-button]",
10295
+ "a:text-is('Finalizar')",
10296
+ "button:text-is('Finalizar')"
10297
+ ],
10298
+ sizeSwatch: [
10299
+ "[data-size]:not([disabled]):not([aria-disabled='true']):not(.unavailable):not(.sold-out)",
10300
+ "[data-variant-size]:not([disabled]):not([aria-disabled='true']):not(.unavailable)",
10301
+ "button[aria-label*='tamanho' i]:not([aria-disabled='true']):not([disabled])",
10302
+ "button[aria-label*='size' i]:not([aria-disabled='true']):not([disabled])",
10303
+ "[data-testid='size-selector'] button:not([disabled]):not(.unavailable)",
10304
+ ".size-selector button:not([disabled]):not(.unavailable):not(.sold-out)",
10305
+ "[class*='size'] button:not([disabled]):not(.unavailable)",
10306
+ "label:has(input[name*='size' i]:not([disabled])) "
10307
+ ],
10308
+ colorSwatch: [
10309
+ "[data-color]:not([disabled]):not([aria-disabled='true']):not(.unavailable):not(.sold-out)",
10310
+ "[data-variant-color]:not([disabled]):not([aria-disabled='true']):not(.unavailable)",
10311
+ "button[aria-label*='cor' i]:not([aria-disabled='true']):not([disabled])",
10312
+ "button[aria-label*='color' i]:not([aria-disabled='true']):not([disabled])",
10313
+ "[data-testid='color-selector'] button:not([disabled]):not(.unavailable)",
10314
+ ".color-swatch:not(.unavailable):not(.sold-out)",
10315
+ "[class*='color'] button:not([disabled]):not(.unavailable)"
10316
+ ],
10317
+ variantRow: [
10318
+ "[data-sku-row]",
10319
+ "[data-variant-row]",
10320
+ "tr[data-variant]",
10321
+ "tr:has([aria-label*='quantidade' i])",
10322
+ "tbody tr:has(input[type='number'])"
10323
+ ],
10324
+ quantityIncrement: [
10325
+ "[data-qty-plus]",
10326
+ "[data-quantity-plus]",
10327
+ "button[aria-label*='aumentar' i]",
10328
+ "button[aria-label*='increase' i]",
10329
+ "button[aria-label*='increment' i]",
10330
+ "button:has-text('+')",
10331
+ "[class*='qty'] button:has-text('+')"
10332
+ ],
10333
+ quantityInput: [
10334
+ "input[type='number'][min='0'][value='0']",
10335
+ "input[id*='quantity-input' i]",
10336
+ "input[name*='qty' i]",
10337
+ "input[name*='quantity' i]",
10338
+ "input[name*='quantidade' i]",
10339
+ "input[aria-label*='quantidade' i]",
10340
+ "[data-qty-input]",
10341
+ "input[type='number'][min='0']",
10342
+ "input[type='number'][min='1']"
10343
+ ],
10344
+ minicartCount: [
10345
+ "[data-minicart-count]",
10346
+ "[data-cart-count]",
10347
+ ".cart-count",
10348
+ ".minicart__count",
10349
+ "[class*='cart-count']",
10350
+ "[class*='CartCount']",
10351
+ "[aria-label*='itens' i][role='status']",
10352
+ "header [data-minicart-trigger] [class*='badge']",
10353
+ "header [aria-label*='cart' i] [class*='count']",
10354
+ "header [aria-label*='sacola' i] [class*='badge']",
10355
+ "header [aria-label*='sacola' i] [class*='count']",
10356
+ "header a[href*='/cart'] [class*='count']",
10357
+ "header a[href*='/checkout'] [class*='count']",
10358
+ "[data-fs-cart-icon] + [class*='count']",
10359
+ "[class*='Minicart'] [class*='count']",
10360
+ "[class*='minicart'] [class*='count']"
10361
+ ]
10362
+ };
10363
+ function selectorsFor(key, ctxOrRc = {}) {
10364
+ const ctx = isResolutionContext(ctxOrRc) ? ctxOrRc : { rc: ctxOrRc };
10365
+ const out = [];
10366
+ const override = ctx.rc?.selectors?.[key];
10367
+ if (override)
10368
+ out.push(override);
10369
+ if (ctx.learned && ctx.platform) {
10370
+ for (const entry of getLearnedSelectors(ctx.learned, ctx.platform, key)) {
10371
+ out.push(entry.selector);
10372
+ }
10373
+ }
10374
+ out.push(...DEFAULT_SELECTORS[key]);
10375
+ return [...new Set(out)];
10376
+ }
10377
+ function isResolutionContext(v) {
10378
+ if (!v || typeof v !== "object")
10379
+ return false;
10380
+ const k = Object.keys(v);
10381
+ return k.length === 0 || k.some((x) => x === "rc" || x === "learned" || x === "platform");
10382
+ }
10383
+
10384
+ // src/engine/flows.ts
10385
+ async function screenshotStable(page, opts) {
10386
+ await Promise.race([
10387
+ stabilizeCarousels(page).catch(() => {
10388
+ return;
10389
+ }),
10390
+ new Promise((resolve2) => setTimeout(resolve2, 3000))
10391
+ ]);
10392
+ await page.screenshot({ path: opts.path, fullPage: opts.fullPage ?? false }).catch(() => {
10393
+ return;
10394
+ });
10395
+ }
10396
+ var PURCHASE_JOURNEY_TOTAL_STEPS = 9;
10397
+ var DEBUG_PARITY2 = process.env.DEBUG_PARITY === "1" || process.env.DEBUG_PARITY === "true";
10398
+ var DEBUG_START2 = Date.now();
10399
+ function dlog2(ctx, msg) {
10400
+ if (!DEBUG_PARITY2)
10401
+ return;
10402
+ const elapsed = ((Date.now() - DEBUG_START2) / 1000).toFixed(1);
10403
+ process.stderr.write(`[+${elapsed}s ${ctx.viewport}/${ctx.side}] ${msg}
10404
+ `);
10405
+ }
10406
+ function withCap(p, capMs, fallback) {
10407
+ return Promise.race([
10408
+ p.catch(() => fallback),
10409
+ new Promise((resolve2) => setTimeout(() => resolve2(fallback), capMs))
10410
+ ]);
10411
+ }
10412
+ var VARIANT_REQUIRED_TEXT_PATTERNS = [
10413
+ /selecione um produto/i,
10414
+ /selecione um tamanho/i,
10415
+ /selecione uma cor/i,
10416
+ /selecione uma op[cç][aã]o/i,
10417
+ /select a size/i,
10418
+ /select a color/i,
10419
+ /select an option/i,
10420
+ /choose an option/i,
10421
+ /please select/i,
10422
+ /select size/i,
10423
+ /select color/i
10424
+ ];
10425
+ var ADD_TO_CART_ERROR_PATTERNS = [
10426
+ ...VARIANT_REQUIRED_TEXT_PATTERNS,
10427
+ /estoque esgotado/i,
10428
+ /out of stock/i,
10429
+ /indispon[ií]vel/i,
10430
+ /unavailable/i
10431
+ ];
10432
+ var ADD_TO_CART_SUCCESS_PATTERNS = [
10433
+ /produto adicionado/i,
10434
+ /adicionado ao carrinho/i,
10435
+ /adicionado [aà]\s+sacola/i,
10436
+ /added to cart/i,
10437
+ /added to bag/i,
10438
+ /item added/i,
10439
+ /successfully added/i
10440
+ ];
10441
+ function selFor(ctx, key) {
10442
+ return selectorsFor(key, { rc: ctx.rc, learned: ctx.learned, platform: ctx.platform });
10443
+ }
10444
+ var FLOW_DEADLINE_MS = {
10445
+ homepage: 90000,
10446
+ plp: 180000,
10447
+ pdp: 240000,
10448
+ "purchase-journey": 420000
10449
+ };
10450
+ async function runFlow(flow, ctx) {
10451
+ const start = Date.now();
10452
+ const deadlineMs = FLOW_DEADLINE_MS[flow];
10453
+ const inner = async () => {
10454
+ switch (flow) {
10455
+ case "homepage":
10456
+ return finalize(flow, ctx, await flowHomepage(ctx), [], start);
10457
+ case "plp":
10458
+ return finalize(flow, ctx, await flowPlp(ctx), [], start);
10459
+ case "pdp":
10460
+ return finalize(flow, ctx, await flowPdp(ctx), [], start);
10461
+ case "purchase-journey": {
10462
+ const { pages, steps } = await flowPurchaseJourney(ctx);
10463
+ return finalize(flow, ctx, pages, steps, start);
10464
+ }
10465
+ }
10466
+ };
10467
+ const innerPromise = inner();
10468
+ innerPromise.catch(() => {
10469
+ return;
10470
+ });
10471
+ let timer;
10472
+ let cleanup = Promise.resolve();
10473
+ const timeoutPromise = new Promise((resolve2) => {
10474
+ timer = setTimeout(() => {
10475
+ const pages = ctx.ctx.pages();
10476
+ resolve2(finalize(flow, ctx, [], [
10477
+ {
10478
+ step: 0,
10479
+ name: "flow-timeout",
10480
+ side: ctx.side,
10481
+ viewport: ctx.viewport,
10482
+ status: "failed",
10483
+ durationMs: deadlineMs,
10484
+ screenshotPath: "",
10485
+ actionDescription: `[flow-timeout] flow "${flow}" excedeu ${deadlineMs}ms — captura abortada pela safety net externa, ${pages.length} page(s) fechada(s) para liberar o contexto.`
10486
+ }
10487
+ ], start));
10488
+ const CLOSE_CAP_MS = 5000;
10489
+ const cappedClose = (p) => Promise.race([
10490
+ p.close().catch(() => {
10491
+ return;
10492
+ }),
10493
+ new Promise((resolveClose) => setTimeout(resolveClose, CLOSE_CAP_MS))
10494
+ ]);
10495
+ cleanup = Promise.allSettled(pages.map(cappedClose));
10496
+ }, deadlineMs);
10497
+ });
10498
+ try {
10499
+ const result = await Promise.race([innerPromise, timeoutPromise]);
10500
+ await cleanup;
10501
+ return result;
10502
+ } finally {
10503
+ if (timer !== undefined)
10504
+ clearTimeout(timer);
10505
+ }
10506
+ }
10507
+ function finalize(flow, ctx, pages, steps, start) {
10508
+ return {
10509
+ flow,
10510
+ side: ctx.side,
10511
+ viewport: ctx.viewport,
10512
+ pages,
10513
+ steps,
10514
+ totalDurationMs: Date.now() - start
10515
+ };
10516
+ }
10517
+ function screenshotPath(ctx, label) {
10518
+ const safe = label.replace(/[^a-z0-9_-]+/gi, "-").toLowerCase();
10519
+ return `${ctx.outDir}/${safe}-${ctx.viewport}-${ctx.side}.png`;
10520
+ }
10521
+ async function flowHomepage(ctx) {
10522
+ const page = await ctx.ctx.newPage();
10523
+ try {
10524
+ const cap = await capturePage(page, {
10525
+ url: ctx.baseUrl,
10526
+ side: ctx.side,
10527
+ viewport: ctx.viewport,
10528
+ screenshotPath: screenshotPath(ctx, "home")
10529
+ });
10530
+ return [cap];
10531
+ } finally {
10532
+ await page.close();
10533
+ }
10534
+ }
10535
+ async function flowPlp(ctx) {
10536
+ const home = await ctx.ctx.newPage();
10537
+ const homeCap = await capturePage(home, {
10538
+ url: ctx.baseUrl,
10539
+ side: ctx.side,
10540
+ viewport: ctx.viewport,
10541
+ screenshotPath: screenshotPath(ctx, "home")
10542
+ });
10543
+ const plpHit = ctx.rc.plpUrlHint ? { url: new URL(ctx.rc.plpUrlHint, ctx.baseUrl).toString(), selector: "__hint__" } : await findCategoryUrl(home, ctx);
10544
+ await home.close();
10545
+ if (!plpHit)
10546
+ return [homeCap];
10547
+ const plp = await ctx.ctx.newPage();
10548
+ try {
10549
+ const cap = await capturePage(plp, {
10550
+ url: plpHit.url,
10551
+ side: ctx.side,
10552
+ viewport: ctx.viewport,
10553
+ screenshotPath: screenshotPath(ctx, "plp")
10554
+ });
10555
+ return [homeCap, cap];
10556
+ } finally {
10557
+ await plp.close();
10558
+ }
10559
+ }
10560
+ async function flowPdp(ctx) {
10561
+ const pages = [];
10562
+ const home = await ctx.ctx.newPage();
10563
+ pages.push(await capturePage(home, {
10564
+ url: ctx.baseUrl,
10565
+ side: ctx.side,
10566
+ viewport: ctx.viewport,
10567
+ screenshotPath: screenshotPath(ctx, "home")
10568
+ }));
10569
+ const plpHit = ctx.rc.plpUrlHint ? { url: new URL(ctx.rc.plpUrlHint, ctx.baseUrl).toString(), selector: "__hint__" } : await findCategoryUrl(home, ctx);
10570
+ await home.close();
10571
+ if (!plpHit)
10572
+ return pages;
10573
+ const plp = await ctx.ctx.newPage();
10574
+ pages.push(await capturePage(plp, {
10575
+ url: plpHit.url,
10576
+ side: ctx.side,
10577
+ viewport: ctx.viewport,
10578
+ screenshotPath: screenshotPath(ctx, "plp")
10579
+ }));
10580
+ const pdpHit = await findProductUrl(plp, ctx);
10581
+ await plp.close();
10582
+ if (!pdpHit)
10583
+ return pages;
10584
+ const pdp = await ctx.ctx.newPage();
10585
+ try {
10586
+ pages.push(await capturePage(pdp, {
10587
+ url: pdpHit.url,
10588
+ side: ctx.side,
10589
+ viewport: ctx.viewport,
10590
+ screenshotPath: screenshotPath(ctx, "pdp")
10591
+ }));
10592
+ return pages;
10593
+ } finally {
10594
+ await pdp.close();
10595
+ }
10596
+ }
10597
+ async function flowPurchaseJourney(ctx) {
10598
+ const pages = [];
10599
+ const steps = [];
10600
+ const page = await ctx.ctx.newPage();
10601
+ const budget = { remaining: ctx.recoveryBudget ?? 5 };
10602
+ const total = PURCHASE_JOURNEY_TOTAL_STEPS;
10603
+ const reportStart = (idx, name) => {
10604
+ ctx.onStep?.({ phase: "start", name, index: idx, total });
10605
+ };
10606
+ const reportEnd = (idx, name, status, durationMs, note) => {
10607
+ ctx.onStep?.({ phase: "end", name, index: idx, total, status, durationMs, note });
10608
+ };
10609
+ try {
10610
+ reportStart(1, "visit-home");
10611
+ const homeCap = await capturePage(page, {
10612
+ url: ctx.baseUrl,
10613
+ side: ctx.side,
10614
+ viewport: ctx.viewport,
10615
+ screenshotPath: screenshotPath(ctx, "pj-1-home")
10616
+ });
10617
+ pages.push(homeCap);
10618
+ const step1Status = homeCap.status >= 200 && homeCap.status < 400 ? "ok" : "failed";
10619
+ steps.push({
10620
+ step: 1,
10621
+ name: "visit-home",
10622
+ side: ctx.side,
10623
+ viewport: ctx.viewport,
10624
+ status: step1Status,
10625
+ durationMs: homeCap.durationMs,
10626
+ url: homeCap.finalUrl,
10627
+ screenshotPath: homeCap.screenshotPath
10628
+ });
10629
+ reportEnd(1, "visit-home", step1Status, homeCap.durationMs);
10630
+ steps[steps.length - 1].actionDescription = `Navegou pra home \`${ctx.baseUrl}\` (HTTP ${homeCap.status})`;
10631
+ if (homeCap.status >= 400 || homeCap.status === 0) {
10632
+ return { pages, steps };
10633
+ }
10634
+ reportStart(2, "navigate-plp");
10635
+ const plpHit = ctx.rc.plpUrlHint ? { url: new URL(ctx.rc.plpUrlHint, ctx.baseUrl).toString(), selector: "__hint__" } : await findCategoryUrl(page, ctx);
10636
+ if (!plpHit) {
10637
+ steps.push(makeSkipStep(2, "navigate-plp", ctx, "no category link found"));
10638
+ reportEnd(2, "navigate-plp", "skipped", 0, "no category link found");
10639
+ return { pages, steps };
10640
+ }
10641
+ const t2 = Date.now();
10642
+ const plpCap = await capturePage(page, {
10643
+ url: plpHit.url,
10644
+ side: ctx.side,
10645
+ viewport: ctx.viewport,
10646
+ screenshotPath: screenshotPath(ctx, "pj-2-plp")
10647
+ });
10648
+ pages.push(plpCap);
10649
+ const step2Status = plpCap.status >= 200 && plpCap.status < 400 ? "ok" : "failed";
10650
+ steps.push({
10651
+ step: 2,
10652
+ name: "navigate-plp",
10653
+ side: ctx.side,
10654
+ viewport: ctx.viewport,
10655
+ status: step2Status,
10656
+ durationMs: Date.now() - t2,
10657
+ url: plpCap.finalUrl,
10658
+ screenshotPath: plpCap.screenshotPath,
10659
+ selectorKey: "categoryLink",
10660
+ usedSelector: plpHit.selector
10661
+ });
10662
+ reportEnd(2, "navigate-plp", step2Status, Date.now() - t2);
10663
+ steps[steps.length - 1].actionDescription = `Navegou pra categoria \`${plpHit.url}\` (via \`${plpHit.selector}\`)`;
10664
+ steps[steps.length - 1].beforeUrl = ctx.baseUrl;
10665
+ reportStart(3, "enter-pdp");
10666
+ let pdpHit = await findProductUrl(page, ctx);
10667
+ let pdpRecoveredByLlm = false;
10668
+ if (!pdpHit && budget.remaining > 0) {
10669
+ const html = await page.content().catch(() => "");
10670
+ if (html) {
10671
+ const suggestion = await suggestRecovery({
10672
+ stepName: "enter-pdp",
10673
+ intendedAction: "Encontrar um link <a> que leve para a página de detalhes (PDP) de algum produto listado na PLP atual",
10674
+ html,
10675
+ alreadyTried: selFor(ctx, "productCard")
10676
+ });
10677
+ if (suggestion) {
10678
+ budget.remaining--;
10679
+ try {
10680
+ const el = page.locator(suggestion.selector).first();
10681
+ if (await el.isVisible({ timeout: 2000 }).catch(() => false)) {
10682
+ const href = await el.getAttribute("href");
10683
+ if (href) {
10684
+ const abs = new URL(href, page.url()).toString();
10685
+ pdpHit = { url: abs, selector: suggestion.selector };
10686
+ pdpRecoveredByLlm = true;
10687
+ }
10688
+ }
10689
+ } catch {}
10690
+ }
10691
+ }
10692
+ }
10693
+ if (!pdpHit) {
10694
+ steps.push(makeSkipStep(3, "enter-pdp", ctx, "no product card found (recovery exhausted)"));
10695
+ reportEnd(3, "enter-pdp", "skipped", 0, "no product card found");
10696
+ return { pages, steps };
10697
+ }
10698
+ const t3 = Date.now();
10699
+ const pdpCap = await capturePage(page, {
10700
+ url: pdpHit.url,
10701
+ side: ctx.side,
10702
+ viewport: ctx.viewport,
10703
+ screenshotPath: screenshotPath(ctx, "pj-3-pdp")
10704
+ });
10705
+ pages.push(pdpCap);
10706
+ const step3Status = pdpCap.status >= 200 && pdpCap.status < 400 ? "ok" : "failed";
10707
+ steps.push({
10708
+ step: 3,
10709
+ name: "enter-pdp",
10710
+ side: ctx.side,
10711
+ viewport: ctx.viewport,
10712
+ status: step3Status,
10713
+ durationMs: Date.now() - t3,
10714
+ url: pdpCap.finalUrl,
10715
+ screenshotPath: pdpCap.screenshotPath,
10716
+ selectorKey: "productCard",
9642
10717
  usedSelector: pdpHit.selector,
9643
10718
  recoveredByLlm: pdpRecoveredByLlm || undefined
9644
10719
  });
@@ -10665,7 +11740,7 @@ async function detectCartRevealMode(page, trigger, drawerAlreadyOpen, viewport)
10665
11740
  if (viewport === "desktop") {
10666
11741
  try {
10667
11742
  const observedHoverDrawer = await page.evaluate(async () => {
10668
- return await new Promise((resolve) => {
11743
+ return await new Promise((resolve2) => {
10669
11744
  const selectorMatch = (el) => {
10670
11745
  if (!(el instanceof HTMLElement))
10671
11746
  return false;
@@ -10679,13 +11754,13 @@ async function detectCartRevealMode(page, trigger, drawerAlreadyOpen, viewport)
10679
11754
  for (const n of Array.from(m.addedNodes)) {
10680
11755
  if (n instanceof Element && selectorMatch(n)) {
10681
11756
  observer.disconnect();
10682
- resolve(true);
11757
+ resolve2(true);
10683
11758
  return;
10684
11759
  }
10685
11760
  }
10686
11761
  if (m.type === "attributes" && m.target instanceof Element && selectorMatch(m.target)) {
10687
11762
  observer.disconnect();
10688
- resolve(true);
11763
+ resolve2(true);
10689
11764
  return;
10690
11765
  }
10691
11766
  }
@@ -10698,7 +11773,7 @@ async function detectCartRevealMode(page, trigger, drawerAlreadyOpen, viewport)
10698
11773
  });
10699
11774
  setTimeout(() => {
10700
11775
  observer.disconnect();
10701
- resolve(false);
11776
+ resolve2(false);
10702
11777
  }, 600);
10703
11778
  });
10704
11779
  });
@@ -11017,7 +12092,7 @@ async function detectEmptyCartBanner(page) {
11017
12092
  }
11018
12093
 
11019
12094
  // src/learned/platform.ts
11020
- import * as cheerio6 from "cheerio";
12095
+ import * as cheerio7 from "cheerio";
11021
12096
  function detectPlatform(input) {
11022
12097
  const urlPlatform = detectFromUrl(input.url);
11023
12098
  if (urlPlatform !== "custom")
@@ -11066,7 +12141,7 @@ function detectFromHeaders(headers) {
11066
12141
  }
11067
12142
  function detectFromHtml(html) {
11068
12143
  try {
11069
- const $ = cheerio6.load(html);
12144
+ const $ = cheerio7.load(html);
11070
12145
  const fsClasses = $('[class*="fs-"], [data-fs-]').length;
11071
12146
  if (fsClasses > 5)
11072
12147
  return "vtex-fs";
@@ -11142,9 +12217,9 @@ function isAlreadyDeprecated(lib, platform, key, selector) {
11142
12217
  }
11143
12218
 
11144
12219
  // src/llm/discover-selectors.ts
11145
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync7 } from "node:fs";
11146
- import { join as join9 } from "node:path";
11147
- import * as cheerio7 from "cheerio";
12220
+ import { existsSync as existsSync7, mkdirSync as mkdirSync6, readFileSync as readFileSync10, writeFileSync as writeFileSync8 } from "node:fs";
12221
+ import { join as join10 } from "node:path";
12222
+ import * as cheerio8 from "cheerio";
11148
12223
  var CACHE_DIR = ".parity-cache";
11149
12224
  var DISCOVER_SELECTORS_TOOL = {
11150
12225
  name: "report_selectors",
@@ -11188,7 +12263,7 @@ var DISCOVER_SELECTORS_TOOL = {
11188
12263
  required: ["category_link", "product_card", "buy_button", "minicart_trigger"]
11189
12264
  }
11190
12265
  };
11191
- var SYSTEM_PROMPT2 = `
12266
+ var SYSTEM_PROMPT3 = `
11192
12267
  Você é um especialista em CSS selectors para crawlers/E2E de sites de e-commerce
11193
12268
  (VTEX, Shopify, Wake, Nuvemshop, custom).
11194
12269
 
@@ -11238,7 +12313,7 @@ Responda SEMPRE via tool_use report_selectors. Não escreva texto livre fora da
11238
12313
  `.trim();
11239
12314
  function compactHtmlForSelectors(html, maxChars = 30000) {
11240
12315
  try {
11241
- const $ = cheerio7.load(html);
12316
+ const $ = cheerio8.load(html);
11242
12317
  $("script, style, noscript, svg, picture source, link[rel='stylesheet']").remove();
11243
12318
  $("[type='application/ld+json']").remove();
11244
12319
  const sections = [];
@@ -11272,16 +12347,16 @@ function compactHtmlForSelectors(html, maxChars = 30000) {
11272
12347
  async function discoverSelectorsFromUrl(url, html, opts = {}) {
11273
12348
  const host = safeHost(url);
11274
12349
  const cacheDir = opts.cacheDir ?? CACHE_DIR;
11275
- const cachePath = join9(cacheDir, `selectors-${host}.json`);
12350
+ const cachePath = join10(cacheDir, `selectors-${host}.json`);
11276
12351
  if (!opts.noCache && existsSync7(cachePath)) {
11277
12352
  try {
11278
- const cached = JSON.parse(readFileSync8(cachePath, "utf8"));
12353
+ const cached = JSON.parse(readFileSync10(cachePath, "utf8"));
11279
12354
  return cached;
11280
12355
  } catch {}
11281
12356
  }
11282
12357
  const compacted = compactHtmlForSelectors(html);
11283
12358
  const input = await callTool({
11284
- systemPrompt: SYSTEM_PROMPT2,
12359
+ systemPrompt: SYSTEM_PROMPT3,
11285
12360
  userText: `URL: ${url}
11286
12361
 
11287
12362
  HTML compactado da home:
@@ -11311,8 +12386,8 @@ ${compacted}
11311
12386
  selectors.checkoutButton = undefined;
11312
12387
  }
11313
12388
  if (!existsSync7(cacheDir))
11314
- mkdirSync5(cacheDir, { recursive: true });
11315
- writeFileSync7(cachePath, `${JSON.stringify(selectors, null, 2)}
12389
+ mkdirSync6(cacheDir, { recursive: true });
12390
+ writeFileSync8(cachePath, `${JSON.stringify(selectors, null, 2)}
11316
12391
  `, "utf8");
11317
12392
  return selectors;
11318
12393
  }
@@ -11361,7 +12436,7 @@ var CRITICAL_STEPS2 = new Set([
11361
12436
  async function journeyCommand(opts) {
11362
12437
  const viewports = opts.viewports.split(",").map((s) => s.trim()).filter((s) => s === "mobile" || s === "desktop");
11363
12438
  if (viewports.length === 0) {
11364
- console.error(chalk10.red("Nenhum viewport válido (use mobile,desktop)"));
12439
+ console.error(chalk11.red("Nenhum viewport válido (use mobile,desktop)"));
11365
12440
  return 2;
11366
12441
  }
11367
12442
  const rc = loadParityRc();
@@ -11371,13 +12446,13 @@ async function journeyCommand(opts) {
11371
12446
  const runId = newRunId();
11372
12447
  const paths = createRunDir(opts.output, runId);
11373
12448
  if (!opts.json) {
11374
- console.log(chalk10.bold(`
12449
+ console.log(chalk11.bold(`
11375
12450
  parity journey ${runId}`));
11376
- console.log(chalk10.dim(` prod: ${opts.prod}`));
11377
- console.log(chalk10.dim(` cand: ${opts.cand}`));
11378
- console.log(chalk10.dim(` viewports: ${viewports.join(", ")} · CEP: ${rc.cep}`));
12451
+ console.log(chalk11.dim(` prod: ${opts.prod}`));
12452
+ console.log(chalk11.dim(` cand: ${opts.cand}`));
12453
+ console.log(chalk11.dim(` viewports: ${viewports.join(", ")} · CEP: ${rc.cep}`));
11379
12454
  if (isLlmAvailable())
11380
- console.log(chalk10.dim(` llm: ${providerLabel()}`));
12455
+ console.log(chalk11.dim(` llm: ${providerLabel()}`));
11381
12456
  console.log("");
11382
12457
  }
11383
12458
  let platform = "custom";
@@ -11421,20 +12496,20 @@ async function journeyCommand(opts) {
11421
12496
  const onStepFor = (viewport, side) => (event) => {
11422
12497
  if (opts.json)
11423
12498
  return;
11424
- const sideTag = side === "prod" ? chalk10.cyan("prod") : chalk10.magenta("cand");
11425
- const prefix = ` ${chalk10.dim(`[${viewport}/`)}${sideTag}${chalk10.dim("]")}`;
12499
+ const sideTag = side === "prod" ? chalk11.cyan("prod") : chalk11.magenta("cand");
12500
+ const prefix = ` ${chalk11.dim(`[${viewport}/`)}${sideTag}${chalk11.dim("]")}`;
11426
12501
  if (event.phase === "start") {
11427
- console.log(`${prefix} ${chalk10.dim(`${event.index}/${event.total}`)} ▶ ${chalk10.bold(stepLabel(event.name))}`);
12502
+ console.log(`${prefix} ${chalk11.dim(`${event.index}/${event.total}`)} ▶ ${chalk11.bold(stepLabel(event.name))}`);
11428
12503
  } else {
11429
- const glyph = event.status === "ok" ? chalk10.green("✓") : event.status === "skipped" ? chalk10.yellow("○") : chalk10.red("✗");
11430
- const noteText = event.note ? chalk10.dim(` (${event.note})`) : "";
11431
- const time = chalk10.dim(`${(event.durationMs / 1000).toFixed(1)}s`);
11432
- console.log(`${prefix} ${chalk10.dim(`${event.index}/${event.total}`)} ${glyph} ${stepLabel(event.name)} ${time}${noteText}`);
12504
+ const glyph = event.status === "ok" ? chalk11.green("✓") : event.status === "skipped" ? chalk11.yellow("○") : chalk11.red("✗");
12505
+ const noteText = event.note ? chalk11.dim(` (${event.note})`) : "";
12506
+ const time = chalk11.dim(`${(event.durationMs / 1000).toFixed(1)}s`);
12507
+ console.log(`${prefix} ${chalk11.dim(`${event.index}/${event.total}`)} ${glyph} ${stepLabel(event.name)} ${time}${noteText}`);
11433
12508
  }
11434
12509
  };
11435
12510
  async function runOneSide(browserInstance, viewport, side) {
11436
12511
  const baseUrl = side === "prod" ? opts.prod : opts.cand;
11437
- const tracePath = join10(paths.tracesDir, `${viewport}-${side}.zip`);
12512
+ const tracePath = join11(paths.tracesDir, `${viewport}-${side}.zip`);
11438
12513
  const ctx = await newContext(browserInstance, {
11439
12514
  viewport,
11440
12515
  tracesDir: paths.tracesDir,
@@ -11468,7 +12543,7 @@ async function journeyCommand(opts) {
11468
12543
  spinner?.succeed("Browser pronto");
11469
12544
  for (const viewport of viewports) {
11470
12545
  if (!opts.json)
11471
- console.log(chalk10.bold(`
12546
+ console.log(chalk11.bold(`
11472
12547
  ── ${viewport} ─────────────────────────────────────────────`));
11473
12548
  const [prodCap, candCap] = await Promise.all([
11474
12549
  runOneSide(browser, viewport, "prod"),
@@ -11503,11 +12578,11 @@ async function journeyCommand(opts) {
11503
12578
  if (totalDeprecated > 0)
11504
12579
  bits.push(`${totalDeprecated} depreciado(s)`);
11505
12580
  if (bits.length > 0)
11506
- console.log(chalk10.dim(` learned-selectors atualizado: ${bits.join(", ")}`));
12581
+ console.log(chalk11.dim(` learned-selectors atualizado: ${bits.join(", ")}`));
11507
12582
  }
11508
12583
  } catch (err) {
11509
12584
  if (!opts.json) {
11510
- console.warn(chalk10.yellow(` warn: failed to persist learned-selectors: ${err.message}`));
12585
+ console.warn(chalk11.yellow(` warn: failed to persist learned-selectors: ${err.message}`));
11511
12586
  }
11512
12587
  }
11513
12588
  }
@@ -11526,9 +12601,9 @@ async function journeyCommand(opts) {
11526
12601
  if (opts.github)
11527
12602
  emitGithubAnnotations(failures);
11528
12603
  if (opts.junit) {
11529
- writeFileSync8(opts.junit, buildJUnit(rows, failures), "utf8");
12604
+ writeFileSync9(opts.junit, buildJUnit(rows, failures), "utf8");
11530
12605
  if (!opts.json)
11531
- console.log(chalk10.dim(` → JUnit XML: ${opts.junit}`));
12606
+ console.log(chalk11.dim(` → JUnit XML: ${opts.junit}`));
11532
12607
  }
11533
12608
  return failures.some((f) => f.critical) ? 1 : 0;
11534
12609
  } catch (err) {
@@ -11593,14 +12668,14 @@ function collectFailures(rows) {
11593
12668
  function printTable(viewports, rows) {
11594
12669
  console.log("");
11595
12670
  for (const viewport of viewports) {
11596
- console.log(chalk10.bold(` [${viewport}]`));
12671
+ console.log(chalk11.bold(` [${viewport}]`));
11597
12672
  const vrows = rows.filter((r) => r.viewport === viewport);
11598
12673
  for (const r of vrows) {
11599
12674
  const label = STEP_LABELS2[r.name] ?? r.name;
11600
12675
  const p = statusGlyph(r.prod?.status);
11601
12676
  const c = statusGlyph(r.cand?.status);
11602
- const status = r.prod?.status === "ok" && r.cand?.status !== "ok" ? chalk10.red("FAILED") : "";
11603
- const note = r.cand?.status === "failed" || r.cand?.status === "skipped" ? chalk10.dim(`(${r.cand?.note ?? r.cand?.status})`) : "";
12677
+ const status = r.prod?.status === "ok" && r.cand?.status !== "ok" ? chalk11.red("FAILED") : "";
12678
+ const note = r.cand?.status === "failed" || r.cand?.status === "skipped" ? chalk11.dim(`(${r.cand?.note ?? r.cand?.status})`) : "";
11604
12679
  console.log(` ${label.padEnd(24)} prod ${p} cand ${c} ${status} ${note}`);
11605
12680
  }
11606
12681
  console.log("");
@@ -11608,12 +12683,12 @@ function printTable(viewports, rows) {
11608
12683
  }
11609
12684
  function statusGlyph(status) {
11610
12685
  if (!status)
11611
- return chalk10.dim("—");
12686
+ return chalk11.dim("—");
11612
12687
  if (status === "ok")
11613
- return chalk10.green("✓");
12688
+ return chalk11.green("✓");
11614
12689
  if (status === "skipped")
11615
- return chalk10.yellow("○");
11616
- return chalk10.red("✗");
12690
+ return chalk11.yellow("○");
12691
+ return chalk11.red("✗");
11617
12692
  }
11618
12693
  function printSummary3(rows, failures, htmlPath) {
11619
12694
  const byViewport = new Map;
@@ -11624,25 +12699,25 @@ function printSummary3(rows, failures, htmlPath) {
11624
12699
  v.passed++;
11625
12700
  byViewport.set(r.viewport, v);
11626
12701
  }
11627
- console.log(chalk10.bold(" Summary:"));
12702
+ console.log(chalk11.bold(" Summary:"));
11628
12703
  for (const [vp, { passed, total }] of byViewport) {
11629
- const stat = passed === total ? chalk10.green("✓") : chalk10.yellow(`${passed}/${total}`);
12704
+ const stat = passed === total ? chalk11.green("✓") : chalk11.yellow(`${passed}/${total}`);
11630
12705
  console.log(` ${vp.padEnd(8)} ${stat} steps em cand`);
11631
12706
  }
11632
12707
  if (failures.length > 0) {
11633
12708
  const crit = failures.filter((f) => f.critical).length;
11634
12709
  console.log("");
11635
- console.log(chalk10.red(` ✗ ${failures.length} step(s) divergent(es) (${crit} crítico)`));
12710
+ console.log(chalk11.red(` ✗ ${failures.length} step(s) divergent(es) (${crit} crítico)`));
11636
12711
  for (const f of failures) {
11637
- const tag = f.critical ? chalk10.red("[critical]") : chalk10.yellow("[warn]");
12712
+ const tag = f.critical ? chalk11.red("[critical]") : chalk11.yellow("[warn]");
11638
12713
  console.log(` ${tag} [${f.viewport}] ${f.step}: ${f.reason}`);
11639
12714
  }
11640
12715
  } else {
11641
12716
  console.log("");
11642
- console.log(chalk10.green(" ✓ jornada completa em cand"));
12717
+ console.log(chalk11.green(" ✓ jornada completa em cand"));
11643
12718
  }
11644
12719
  console.log("");
11645
- console.log(chalk10.dim(` → ${htmlPath}`));
12720
+ console.log(chalk11.dim(` → ${htmlPath}`));
11646
12721
  console.log("");
11647
12722
  }
11648
12723
  function emitGithubAnnotations(failures) {
@@ -11689,7 +12764,7 @@ function buildJourneyRun(opts, runId, flowCaptures, failures) {
11689
12764
  const totalDuration = flowCaptures.reduce((a, b) => a + b.totalDurationMs, 0);
11690
12765
  const critical = failures.filter((f) => f.critical).length;
11691
12766
  const others = failures.length - critical;
11692
- const verdict = {
12767
+ const verdict2 = {
11693
12768
  status: critical > 0 ? "fail" : failures.length > 0 ? "warn" : "pass",
11694
12769
  score: Math.max(0, 100 - critical * 20 - others * 8),
11695
12770
  critical,
@@ -11711,7 +12786,7 @@ function buildJourneyRun(opts, runId, flowCaptures, failures) {
11711
12786
  viewports: opts.viewports.split(","),
11712
12787
  cep: opts.cep,
11713
12788
  durationMs: totalDuration,
11714
- verdict,
12789
+ verdict: verdict2,
11715
12790
  topIssues: [],
11716
12791
  issues: [],
11717
12792
  checks: [],
@@ -11877,23 +12952,23 @@ function safeHost2(url) {
11877
12952
  }
11878
12953
 
11879
12954
  // src/commands/learned.ts
11880
- import chalk11 from "chalk";
12955
+ import chalk12 from "chalk";
11881
12956
  function learnedStats() {
11882
12957
  const lib = loadLearned();
11883
12958
  const stats = statsFromLib(lib);
11884
12959
  if (stats.platforms.length === 0) {
11885
- console.log(chalk11.dim(`Biblioteca vazia: ${LEARNED_PATH}`));
11886
- console.log(chalk11.dim("Rode `parity run ...` em algum site para começar a popular."));
12960
+ console.log(chalk12.dim(`Biblioteca vazia: ${LEARNED_PATH}`));
12961
+ console.log(chalk12.dim("Rode `parity run ...` em algum site para começar a popular."));
11887
12962
  return 0;
11888
12963
  }
11889
- console.log(chalk11.bold(`
12964
+ console.log(chalk12.bold(`
11890
12965
  learned-selectors stats (${LEARNED_PATH})
11891
12966
  `));
11892
12967
  for (const p of stats.platforms) {
11893
- console.log(`${chalk11.cyan(p.platform)}: ${p.activeSelectors} active · ${chalk11.dim(`${p.deprecatedSelectors} deprecated`)}`);
12968
+ console.log(`${chalk12.cyan(p.platform)}: ${p.activeSelectors} active · ${chalk12.dim(`${p.deprecatedSelectors} deprecated`)}`);
11894
12969
  for (const top of p.topByKey) {
11895
12970
  const sr = `${(top.successRate * 100).toFixed(0)}%`;
11896
- console.log(` ${chalk11.dim(top.key.padEnd(18))} ${chalk11.green(sr.padStart(4))} ${chalk11.dim(`(${top.hosts} hosts)`)} ${top.selector}`);
12971
+ console.log(` ${chalk12.dim(top.key.padEnd(18))} ${chalk12.green(sr.padStart(4))} ${chalk12.dim(`(${top.hosts} hosts)`)} ${top.selector}`);
11897
12972
  }
11898
12973
  console.log("");
11899
12974
  }
@@ -11901,9 +12976,9 @@ learned-selectors stats (${LEARNED_PATH})
11901
12976
  }
11902
12977
 
11903
12978
  // src/commands/vitals.ts
11904
- import { existsSync as existsSync8, readFileSync as readFileSync9, writeFileSync as writeFileSync9 } from "node:fs";
11905
- import { join as join11 } from "node:path";
11906
- import chalk12 from "chalk";
12979
+ import { existsSync as existsSync8, readFileSync as readFileSync11, writeFileSync as writeFileSync10 } from "node:fs";
12980
+ import { join as join12 } from "node:path";
12981
+ import chalk13 from "chalk";
11907
12982
  import ora4 from "ora";
11908
12983
  async function vitalsCommand(opts) {
11909
12984
  const viewports = opts.viewports.split(",").map((s) => s.trim()).filter((s) => s === "mobile" || s === "desktop");
@@ -11911,10 +12986,10 @@ async function vitalsCommand(opts) {
11911
12986
  const runId = newRunId();
11912
12987
  const paths = createRunDir(opts.output, runId);
11913
12988
  const t0 = Date.now();
11914
- console.log(chalk12.bold(`
12989
+ console.log(chalk13.bold(`
11915
12990
  parity vitals ${runId}`));
11916
- console.log(chalk12.dim(` prod: ${opts.prod}`));
11917
- console.log(chalk12.dim(` cand: ${opts.cand}`));
12991
+ console.log(chalk13.dim(` prod: ${opts.prod}`));
12992
+ console.log(chalk13.dim(` cand: ${opts.cand}`));
11918
12993
  const discoverSpinner = ora4("Descobrindo páginas…").start();
11919
12994
  const pagePaths = await discoverPagePaths2(opts.prod, opts);
11920
12995
  if (pagePaths.length === 0) {
@@ -11946,7 +13021,7 @@ async function vitalsCommand(opts) {
11946
13021
  completed++;
11947
13022
  const elapsed = ((Date.now() - t0) / 1000).toFixed(0);
11948
13023
  const etaSec = completed > 0 ? Math.round((Date.now() - t0) / completed * (total - completed) / 1000) : 0;
11949
- progress.text = `${completed}/${total} · ${elapsed}s · ETA ${etaSec}s · ${task.side === "prod" ? chalk12.cyan("prod") : chalk12.magenta("cand")} ${task.path} (${task.viewport})`;
13024
+ progress.text = `${completed}/${total} · ${elapsed}s · ETA ${etaSec}s · ${task.side === "prod" ? chalk13.cyan("prod") : chalk13.magenta("cand")} ${task.path} (${task.viewport})`;
11950
13025
  }
11951
13026
  });
11952
13027
  progress.succeed(`${total} capture(s) em ${((Date.now() - t0) / 1000).toFixed(1)}s`);
@@ -11988,7 +13063,7 @@ async function vitalsCommand(opts) {
11988
13063
  const cacheResult = cacheCoverage(checkCtx);
11989
13064
  const checks = [vitalsResult, cacheResult];
11990
13065
  const allIssues = checks.flatMap((c) => c.issues);
11991
- const verdict = computeVerdict2(checks, allIssues);
13066
+ const verdict2 = computeVerdict2(checks, allIssues);
11992
13067
  const run = {
11993
13068
  schemaVersion: "0.1",
11994
13069
  id: runId,
@@ -11999,7 +13074,7 @@ async function vitalsCommand(opts) {
11999
13074
  viewports,
12000
13075
  cep: rc.cep,
12001
13076
  durationMs: Date.now() - t0,
12002
- verdict,
13077
+ verdict: verdict2,
12003
13078
  topIssues: allIssues.slice(0, 10),
12004
13079
  issues: allIssues,
12005
13080
  checks,
@@ -12008,7 +13083,7 @@ async function vitalsCommand(opts) {
12008
13083
  writeRunReportJson(paths.runDir, run);
12009
13084
  const html = renderHtmlReport(run, paths.runDir);
12010
13085
  writeRunReportHtml(paths.runDir, html);
12011
- writeFileSync9(join11(paths.runDir, "vitals.json"), `${JSON.stringify({
13086
+ writeFileSync10(join12(paths.runDir, "vitals.json"), `${JSON.stringify({
12012
13087
  schemaVersion: "0.1",
12013
13088
  prodUrl: opts.prod,
12014
13089
  candUrl: opts.cand,
@@ -12019,8 +13094,8 @@ async function vitalsCommand(opts) {
12019
13094
  `, "utf8");
12020
13095
  printSummary4(vitalsResult, cacheResult);
12021
13096
  console.log("");
12022
- console.log(chalk12.dim(` → ${paths.reportHtml}`));
12023
- console.log(chalk12.dim(` \uD83D\uDCA1 use 'parity serve ${runId}' pra preview iframe`));
13097
+ console.log(chalk13.dim(` → ${paths.reportHtml}`));
13098
+ console.log(chalk13.dim(` \uD83D\uDCA1 use 'parity serve ${runId}' pra preview iframe`));
12024
13099
  console.log("");
12025
13100
  if (opts.open) {
12026
13101
  const { default: open } = await import("open");
@@ -12107,7 +13182,7 @@ function parseUrlList(input) {
12107
13182
  if (input.startsWith("file:") || input.endsWith(".txt") || input.endsWith(".list")) {
12108
13183
  const path = input.replace(/^file:\/\//, "");
12109
13184
  if (existsSync8(path)) {
12110
- return readFileSync9(path, "utf8").split(/\r?\n/).map((s) => s.trim()).filter((s) => s && !s.startsWith("#")).map((s) => s.startsWith("/") ? s : `/${s}`);
13185
+ return readFileSync11(path, "utf8").split(/\r?\n/).map((s) => s.trim()).filter((s) => s && !s.startsWith("#")).map((s) => s.startsWith("/") ? s : `/${s}`);
12111
13186
  }
12112
13187
  }
12113
13188
  return input.split(",").map((s) => s.trim()).filter(Boolean).map((s) => s.startsWith("/") || s.startsWith("http") ? s : `/${s}`);
@@ -12148,41 +13223,41 @@ function computeVerdict2(checks, issues) {
12148
13223
  }
12149
13224
  function printSummary4(vitals, cache) {
12150
13225
  console.log("");
12151
- console.log(chalk12.bold(" Summary:"));
13226
+ console.log(chalk13.bold(" Summary:"));
12152
13227
  console.log(` ${vitals.summary}`);
12153
13228
  console.log(` ${cache.summary}`);
12154
13229
  }
12155
13230
 
12156
13231
  // src/commands/list.ts
12157
- import chalk13 from "chalk";
13232
+ import chalk14 from "chalk";
12158
13233
  function listCommand(output) {
12159
13234
  const runs = listRuns(output);
12160
13235
  if (runs.length === 0) {
12161
- console.log(chalk13.dim(`Nenhum run salvo em ${output}`));
13236
+ console.log(chalk14.dim(`Nenhum run salvo em ${output}`));
12162
13237
  return 0;
12163
13238
  }
12164
13239
  for (const r of runs) {
12165
13240
  try {
12166
13241
  const run = loadRun(output, r.id);
12167
13242
  const v = run.verdict;
12168
- const status = v.status === "pass" ? chalk13.green(v.status) : v.status === "warn" ? chalk13.yellow(v.status) : chalk13.red(v.status);
12169
- console.log(` ${chalk13.bold(r.id)} ${status} score=${v.score} critical=${v.critical} high=${v.high} ${chalk13.dim(r.timestamp)}`);
13243
+ const status = v.status === "pass" ? chalk14.green(v.status) : v.status === "warn" ? chalk14.yellow(v.status) : chalk14.red(v.status);
13244
+ console.log(` ${chalk14.bold(r.id)} ${status} score=${v.score} critical=${v.critical} high=${v.high} ${chalk14.dim(r.timestamp)}`);
12170
13245
  } catch {
12171
- console.log(` ${chalk13.bold(r.id)} ${chalk13.dim("(report.json ausente)")}`);
13246
+ console.log(` ${chalk14.bold(r.id)} ${chalk14.dim("(report.json ausente)")}`);
12172
13247
  }
12173
13248
  }
12174
13249
  return 0;
12175
13250
  }
12176
13251
 
12177
13252
  // src/commands/prompt.ts
12178
- import { existsSync as existsSync9, writeFileSync as writeFileSync10 } from "node:fs";
12179
- import chalk14 from "chalk";
13253
+ import { existsSync as existsSync9, writeFileSync as writeFileSync11 } from "node:fs";
13254
+ import chalk15 from "chalk";
12180
13255
  function promptCommand(runId, opts) {
12181
13256
  let run;
12182
13257
  try {
12183
13258
  run = loadRun(opts.output, runId);
12184
13259
  } catch (err) {
12185
- console.error(chalk14.red(`✖ ${err.message}`));
13260
+ console.error(chalk15.red(`✖ ${err.message}`));
12186
13261
  return 1;
12187
13262
  }
12188
13263
  const md = buildLlmPrompt(run, {
@@ -12190,11 +13265,11 @@ function promptCommand(runId, opts) {
12190
13265
  limit: opts.limit
12191
13266
  });
12192
13267
  if (opts.out) {
12193
- writeFileSync10(opts.out, md, "utf8");
12194
- console.log(chalk14.green(`✔ prompt salvo em ${opts.out}`));
13268
+ writeFileSync11(opts.out, md, "utf8");
13269
+ console.log(chalk15.green(`✔ prompt salvo em ${opts.out}`));
12195
13270
  if (existsSync9(opts.out)) {
12196
13271
  const sizeKb = (md.length / 1024).toFixed(1);
12197
- console.log(chalk14.dim(` ${md.length} chars (${sizeKb} KB)`));
13272
+ console.log(chalk15.dim(` ${md.length} chars (${sizeKb} KB)`));
12198
13273
  }
12199
13274
  } else {
12200
13275
  process.stdout.write(md);
@@ -12203,20 +13278,20 @@ function promptCommand(runId, opts) {
12203
13278
  }
12204
13279
 
12205
13280
  // src/commands/report.ts
12206
- import chalk15 from "chalk";
13281
+ import chalk16 from "chalk";
12207
13282
  import open from "open";
12208
13283
  async function reportCommand(runId, output) {
12209
13284
  const paths = getRunPaths(output, runId);
12210
- console.log(chalk15.dim(`opening ${paths.reportHtml}`));
13285
+ console.log(chalk16.dim(`opening ${paths.reportHtml}`));
12211
13286
  await open(paths.reportHtml).catch((err) => {
12212
- console.error(chalk15.red(`failed to open: ${err.message}`));
13287
+ console.error(chalk16.red(`failed to open: ${err.message}`));
12213
13288
  });
12214
13289
  return 0;
12215
13290
  }
12216
13291
 
12217
13292
  // src/commands/run.ts
12218
- import { join as join13 } from "node:path";
12219
- import chalk17 from "chalk";
13293
+ import { join as join14 } from "node:path";
13294
+ import chalk18 from "chalk";
12220
13295
  import ora5 from "ora";
12221
13296
 
12222
13297
  // src/engine/sitemap-discover.ts
@@ -12435,12 +13510,12 @@ async function matchPdps(prod, cand) {
12435
13510
  if (sim >= 0.95)
12436
13511
  return "same";
12437
13512
  if (sim >= 0.8) {
12438
- const verdict = await llmMatch(prod, cand);
12439
- return verdict ?? "similar";
13513
+ const verdict2 = await llmMatch(prod, cand);
13514
+ return verdict2 ?? "similar";
12440
13515
  }
12441
13516
  if (sim >= 0.6) {
12442
- const verdict = await llmMatch(prod, cand);
12443
- return verdict ?? "different";
13517
+ const verdict2 = await llmMatch(prod, cand);
13518
+ return verdict2 ?? "different";
12444
13519
  }
12445
13520
  return "different";
12446
13521
  }
@@ -12501,21 +13576,21 @@ cand: ${JSON.stringify(cand)}`,
12501
13576
  }
12502
13577
  }
12503
13578
  });
12504
- const verdict = input?.verdict;
12505
- if (verdict === "same" || verdict === "similar" || verdict === "different") {
12506
- return verdict;
13579
+ const verdict2 = input?.verdict;
13580
+ if (verdict2 === "same" || verdict2 === "similar" || verdict2 === "different") {
13581
+ return verdict2;
12507
13582
  }
12508
13583
  return null;
12509
13584
  }
12510
13585
 
12511
13586
  // src/commands/serve.ts
12512
13587
  import { existsSync as existsSync11 } from "node:fs";
12513
- import chalk16 from "chalk";
13588
+ import chalk17 from "chalk";
12514
13589
 
12515
13590
  // src/server/proxy-server.ts
12516
- import { existsSync as existsSync10, readFileSync as readFileSync10, statSync } from "node:fs";
13591
+ import { existsSync as existsSync10, readFileSync as readFileSync12, statSync } from "node:fs";
12517
13592
  import { createServer } from "node:http";
12518
- import { extname, join as join12, normalize as normalize3, resolve, sep } from "node:path";
13593
+ import { extname, join as join13, normalize as normalize3, resolve as resolve2, sep } from "node:path";
12519
13594
  var MIME = {
12520
13595
  ".html": "text/html; charset=utf-8",
12521
13596
  ".css": "text/css; charset=utf-8",
@@ -12551,7 +13626,7 @@ var STRIPPED_REQUEST_HEADERS = new Set([
12551
13626
  "origin"
12552
13627
  ]);
12553
13628
  async function startProxyServer(runDir, opts = {}) {
12554
- const absRunDir = resolve(runDir);
13629
+ const absRunDir = resolve2(runDir);
12555
13630
  if (!existsSync10(absRunDir)) {
12556
13631
  throw new Error(`Run directory not found: ${runDir}`);
12557
13632
  }
@@ -12563,9 +13638,9 @@ async function startProxyServer(runDir, opts = {}) {
12563
13638
  } catch {}
12564
13639
  });
12565
13640
  });
12566
- await new Promise((resolve2, reject) => {
13641
+ await new Promise((resolve3, reject) => {
12567
13642
  server.once("error", reject);
12568
- server.listen(opts.port ?? 0, opts.host ?? "127.0.0.1", () => resolve2());
13643
+ server.listen(opts.port ?? 0, opts.host ?? "127.0.0.1", () => resolve3());
12569
13644
  });
12570
13645
  const addr = server.address();
12571
13646
  const port = typeof addr === "object" && addr ? addr.port : 0;
@@ -12573,13 +13648,13 @@ async function startProxyServer(runDir, opts = {}) {
12573
13648
  return {
12574
13649
  port,
12575
13650
  url: `http://${host}:${port}/`,
12576
- close: () => new Promise((resolve2) => {
13651
+ close: () => new Promise((resolve3) => {
12577
13652
  try {
12578
13653
  server.closeAllConnections?.();
12579
13654
  server.closeIdleConnections?.();
12580
13655
  } catch {}
12581
- server.close(() => resolve2());
12582
- setTimeout(() => resolve2(), 1000);
13656
+ server.close(() => resolve3());
13657
+ setTimeout(() => resolve3(), 1000);
12583
13658
  })
12584
13659
  };
12585
13660
  }
@@ -12607,13 +13682,13 @@ async function handleRequest(req, res, runDir) {
12607
13682
  return serveStatic(res, runDir, path);
12608
13683
  }
12609
13684
  function serveReportHtml(res, runDir) {
12610
- const filePath = join12(runDir, "report.html");
13685
+ const filePath = join13(runDir, "report.html");
12611
13686
  if (!existsSync10(filePath)) {
12612
13687
  res.statusCode = 404;
12613
13688
  res.end("report.html not found");
12614
13689
  return;
12615
13690
  }
12616
- const html = readFileSync10(filePath, "utf8");
13691
+ const html = readFileSync12(filePath, "utf8");
12617
13692
  const inject = `<script>window.__parity_proxy = "/proxy?url=";</script>`;
12618
13693
  const out = html.includes("</head>") ? html.replace("</head>", `${inject}</head>`) : `${inject}${html}`;
12619
13694
  res.statusCode = 200;
@@ -12623,7 +13698,7 @@ function serveReportHtml(res, runDir) {
12623
13698
  }
12624
13699
  function serveStatic(res, runDir, path) {
12625
13700
  const safePath2 = normalize3(path).replace(/^[/\\]+/, "");
12626
- const filePath = resolve(runDir, safePath2);
13701
+ const filePath = resolve2(runDir, safePath2);
12627
13702
  if (!filePath.startsWith(runDir + sep) && filePath !== runDir) {
12628
13703
  res.statusCode = 403;
12629
13704
  res.end("forbidden");
@@ -12644,7 +13719,7 @@ function serveStatic(res, runDir, path) {
12644
13719
  res.statusCode = 200;
12645
13720
  res.setHeader("content-type", MIME[ext] ?? "application/octet-stream");
12646
13721
  res.setHeader("cache-control", "no-store");
12647
- res.end(readFileSync10(filePath));
13722
+ res.end(readFileSync12(filePath));
12648
13723
  }
12649
13724
  async function proxyRequest(req, res, targetUrl) {
12650
13725
  let target;
@@ -12730,16 +13805,16 @@ async function serveRunAndBlock(runDir, opts = {}) {
12730
13805
  try {
12731
13806
  handle = await startProxyServer(runDir, { port: opts.port });
12732
13807
  } catch (err) {
12733
- console.error(chalk16.red(` ✖ Falha ao iniciar servidor: ${err.message}`));
13808
+ console.error(chalk17.red(` ✖ Falha ao iniciar servidor: ${err.message}`));
12734
13809
  return 2;
12735
13810
  }
12736
13811
  const label = opts.label ?? "parity serve";
12737
13812
  console.log("");
12738
- console.log(chalk16.bold(` ${label}`));
12739
- console.log(` ${chalk16.green("●")} ${handle.url}`);
12740
- console.log(chalk16.dim(` Servindo: ${runDir}`));
12741
- console.log(chalk16.dim(` Proxy de iframes: ${handle.url}proxy?url=<encoded>`));
12742
- console.log(chalk16.dim(` Ctrl+C pra parar (2x pra forçar saída).
13813
+ console.log(chalk17.bold(` ${label}`));
13814
+ console.log(` ${chalk17.green("●")} ${handle.url}`);
13815
+ console.log(chalk17.dim(` Servindo: ${runDir}`));
13816
+ console.log(chalk17.dim(` Proxy de iframes: ${handle.url}proxy?url=<encoded>`));
13817
+ console.log(chalk17.dim(` Ctrl+C pra parar (2x pra forçar saída).
12743
13818
  `));
12744
13819
  if (opts.open !== false) {
12745
13820
  const { default: open2 } = await import("open");
@@ -12772,11 +13847,11 @@ async function serveRunAndBlock(runDir, opts = {}) {
12772
13847
  async function serveCommand(runId, opts) {
12773
13848
  const paths = getRunPaths(opts.output, runId);
12774
13849
  if (!existsSync11(paths.runDir)) {
12775
- console.error(chalk16.red(`✖ Run não encontrado: ${runId} (em ${opts.output})`));
13850
+ console.error(chalk17.red(`✖ Run não encontrado: ${runId} (em ${opts.output})`));
12776
13851
  return 1;
12777
13852
  }
12778
13853
  if (!existsSync11(paths.reportHtml)) {
12779
- console.error(chalk16.red(`✖ report.html não existe em ${paths.runDir}`));
13854
+ console.error(chalk17.red(`✖ report.html não existe em ${paths.runDir}`));
12780
13855
  return 1;
12781
13856
  }
12782
13857
  return serveRunAndBlock(paths.runDir, {
@@ -12836,7 +13911,7 @@ function applyPreset(opts) {
12836
13911
  async function runCommand(rawOpts) {
12837
13912
  const opts = applyPreset(rawOpts);
12838
13913
  if (rawOpts.preset) {
12839
- console.log(chalk17.dim(` preset: ${rawOpts.preset}`));
13914
+ console.log(chalk18.dim(` preset: ${rawOpts.preset}`));
12840
13915
  }
12841
13916
  const flows = opts.flows.split(",").map((s) => s.trim());
12842
13917
  const viewports = opts.viewports.split(",").map((s) => s.trim());
@@ -12847,11 +13922,11 @@ async function runCommand(rawOpts) {
12847
13922
  const primaryViewport = viewports[0] ?? "mobile";
12848
13923
  const preflight = await preflightCheck(opts.prod, opts.cand, primaryViewport);
12849
13924
  if (!preflight.ok) {
12850
- console.error(chalk17.red(`
13925
+ console.error(chalk18.red(`
12851
13926
  ✖ pre-flight falhou:`));
12852
13927
  for (const err of preflight.errors)
12853
- console.error(chalk17.red(` - ${err}`));
12854
- console.error(chalk17.dim(`
13928
+ console.error(chalk18.red(` - ${err}`));
13929
+ console.error(chalk18.dim(`
12855
13930
  dica: verifique se as URLs estão corretas e acessíveis`));
12856
13931
  return 2;
12857
13932
  }
@@ -12862,7 +13937,7 @@ async function runCommand(rawOpts) {
12862
13937
  if (prodHomeHtml) {
12863
13938
  platform = detectPlatform({ url: opts.prod, html: prodHomeHtml });
12864
13939
  if (platform !== "custom") {
12865
- console.log(chalk17.dim(` Detected platform: ${platform}`));
13940
+ console.log(chalk18.dim(` Detected platform: ${platform}`));
12866
13941
  }
12867
13942
  }
12868
13943
  const prodHost = hostOf2(opts.prod);
@@ -12904,11 +13979,11 @@ async function runCommand(rawOpts) {
12904
13979
  const paths = createRunDir(opts.output, runId);
12905
13980
  const startedAt = Date.now();
12906
13981
  const timestamp = new Date().toISOString();
12907
- console.log(chalk17.bold(`
13982
+ console.log(chalk18.bold(`
12908
13983
  parity run ${runId}`));
12909
- console.log(chalk17.dim(` prod: ${opts.prod}`));
12910
- console.log(chalk17.dim(` cand: ${opts.cand}`));
12911
- console.log(chalk17.dim(` flows: ${flows.join(", ")} · viewports: ${viewports.join(", ")} · CEP: ${rc.cep}
13984
+ console.log(chalk18.dim(` prod: ${opts.prod}`));
13985
+ console.log(chalk18.dim(` cand: ${opts.cand}`));
13986
+ console.log(chalk18.dim(` flows: ${flows.join(", ")} · viewports: ${viewports.join(", ")} · CEP: ${rc.cep}
12912
13987
  `));
12913
13988
  const spinner = ora5("Launching browser…").start();
12914
13989
  let browser = null;
@@ -12937,8 +14012,8 @@ async function runCommand(rawOpts) {
12937
14012
  for (const side of ["prod", "cand"]) {
12938
14013
  const baseUrl = side === "prod" ? opts.prod : opts.cand;
12939
14014
  spinner.text = `[${viewport}/${side}] preparing context…`;
12940
- const harPath = join13(paths.harDir, `${viewport}-${side}.har`);
12941
- const tracePath = join13(paths.tracesDir, `${viewport}-${side}.zip`);
14015
+ const harPath = join14(paths.harDir, `${viewport}-${side}.har`);
14016
+ const tracePath = join14(paths.tracesDir, `${viewport}-${side}.zip`);
12942
14017
  const ctx = await newContext(browser, {
12943
14018
  viewport,
12944
14019
  harPath,
@@ -12983,9 +14058,9 @@ async function runCommand(rawOpts) {
12983
14058
  if (prodPdp && candPdp) {
12984
14059
  const fpProd = fingerprintPdp(prodPdp.html);
12985
14060
  const fpCand = fingerprintPdp(candPdp.html);
12986
- const verdict2 = await matchPdps(fpProd, fpCand);
12987
- if (verdict2 !== "same") {
12988
- spinner.warn(`PDP cross-site matcher: '${verdict2}' (prod="${fpProd.name ?? "?"}" cand="${fpCand.name ?? "?"}")`);
14061
+ const verdict3 = await matchPdps(fpProd, fpCand);
14062
+ if (verdict3 !== "same") {
14063
+ spinner.warn(`PDP cross-site matcher: '${verdict3}' (prod="${fpProd.name ?? "?"}" cand="${fpCand.name ?? "?"}")`);
12989
14064
  }
12990
14065
  }
12991
14066
  }
@@ -13063,789 +14138,367 @@ async function runCommand(rawOpts) {
13063
14138
  allFlowCaptures.push({
13064
14139
  flow: "homepage",
13065
14140
  side: task.side,
13066
- viewport: task.viewport,
13067
- pages: [cap],
13068
- totalDurationMs: cap.durationMs
13069
- });
13070
- } finally {
13071
- await page.close().catch(() => {
13072
- return;
13073
- });
13074
- await ctx.close().catch(() => {
13075
- return;
13076
- });
13077
- }
13078
- } catch {} finally {
13079
- done++;
13080
- extraSpinner.text = `[vitals extras] ${done}/${tasks.length} (último: ${task.path})`;
13081
- }
13082
- });
13083
- extraSpinner.succeed(`+${extraPaths.length} página(s) com vitals`);
13084
- } else {
13085
- extraSpinner.warn("Nenhuma página extra encontrada no sitemap");
13086
- }
13087
- } catch (err) {
13088
- extraSpinner.warn(`vitals extras pulado: ${err.message}`);
13089
- }
13090
- }
13091
- const visualPagesLimit = opts.noVisualDiff ? 0 : opts.visualPages ?? 5;
13092
- if (browser && visualPagesLimit > 0) {
13093
- const visualSpinner = ora5("Descobrindo páginas pra visual diff…").start();
13094
- try {
13095
- const sample = await discoverPagesFromSitemap(opts.prod, { sampleSize: visualPagesLimit });
13096
- const visualPaths = sample.all.map((p) => p.path);
13097
- const alreadyCapturedKeys = new Set;
13098
- for (const p of allPageCaptures) {
13099
- try {
13100
- alreadyCapturedKeys.add(`${new URL(p.url).pathname}::${p.viewport}::${p.side}`);
13101
- } catch {}
13102
- }
13103
- const tasks = [];
13104
- for (const viewport of viewports) {
13105
- for (const path of visualPaths) {
13106
- for (const side of ["prod", "cand"]) {
13107
- if (alreadyCapturedKeys.has(`${path}::${viewport}::${side}`))
13108
- continue;
13109
- tasks.push({ path, viewport, side });
13110
- }
13111
- }
13112
- }
13113
- if (tasks.length === 0) {
13114
- visualSpinner.succeed(`Visual diff: páginas já capturadas em flows (${visualPaths.length} alvos)`);
13115
- } else {
13116
- visualSpinner.text = `Visual diff: capturando ${tasks.length} screenshot(s) (${visualPaths.length} páginas × ${viewports.length} viewport(s) × 2 sides)…`;
13117
- let done = 0;
13118
- await runWithConcurrency3(tasks, 4, async (task) => {
13119
- const baseUrl = task.side === "prod" ? opts.prod : opts.cand;
13120
- const fullUrl = new URL(task.path, baseUrl).toString();
13121
- const safePath2 = task.path.replace(/[/?&=]+/g, "_") || "_root";
13122
- const screenshotPath2 = `${paths.screenshotsDir}/visual-${safePath2}-${task.viewport}-${task.side}.png`;
13123
- try {
13124
- const ctx = await newContext(browser, { viewport: task.viewport, cohortCookieValue: "control", noCache: opts.bypassCache });
13125
- await installVitalsCollector(ctx);
13126
- const page = await ctx.newPage();
13127
- try {
13128
- const cap = await capturePage(page, {
13129
- url: fullUrl,
13130
- side: task.side,
13131
- viewport: task.viewport,
13132
- screenshotPath: screenshotPath2,
13133
- settleMs: 1800,
13134
- timeoutMs: 30000,
13135
- scrollToLoad: true
13136
- });
13137
- allPageCaptures.push(cap);
13138
- } finally {
13139
- await page.close().catch(() => {
13140
- return;
13141
- });
13142
- await ctx.close().catch(() => {
13143
- return;
13144
- });
13145
- }
13146
- } catch {} finally {
13147
- done++;
13148
- visualSpinner.text = `[visual] ${done}/${tasks.length} (último: ${task.path})`;
13149
- }
13150
- });
13151
- visualSpinner.succeed(`Visual diff: ${tasks.length} screenshot(s) capturado(s)`);
13152
- }
13153
- } catch (err) {
13154
- visualSpinner.warn(`Visual diff descoberta pulada: ${err.message}`);
13155
- }
13156
- }
13157
- spinner.start("Rodando checks…");
13158
- const checkCtx = {
13159
- prodPages: allPageCaptures.filter((p) => p.side === "prod"),
13160
- candPages: allPageCaptures.filter((p) => p.side === "cand"),
13161
- prodFlows: allFlowCaptures.filter((f) => f.side === "prod"),
13162
- candFlows: allFlowCaptures.filter((f) => f.side === "cand"),
13163
- rc,
13164
- ignore,
13165
- outDir: paths.runDir,
13166
- viewports
13167
- };
13168
- const checks = await runAllChecks(checkCtx);
13169
- spinner.succeed(`Checks concluídos (${checks.length})`);
13170
- const allIssues = checks.flatMap((c) => c.issues);
13171
- spinner.start(isLlmAvailable() ? "Agregando issues via LLM (Sonnet 4.6)…" : "Agregando issues (modo offline)…");
13172
- const topIssues = await aggregateIssues({
13173
- runId,
13174
- prodUrl: opts.prod,
13175
- candUrl: opts.cand,
13176
- viewports,
13177
- flows,
13178
- checks
13179
- });
13180
- spinner.succeed(`${topIssues.length} issue(s) priorizada(s)`);
13181
- const verdict = computeVerdict3(checks, allIssues);
13182
- let baselineSection;
13183
- if (opts.baseline) {
13184
- try {
13185
- const bl = loadBaseline(opts.baseline);
13186
- const delta = compareToBaseline({
13187
- id: runId,
13188
- issues: allIssues
13189
- }, bl);
13190
- baselineSection = {
13191
- name: opts.baseline,
13192
- delta: {
13193
- resolved: bl.issues.filter((i) => delta.resolved.includes(i.id)),
13194
- new: allIssues.filter((i) => delta.new.includes(i.id)),
13195
- regressions: allIssues.filter((i) => delta.regressions.includes(i.id))
13196
- }
13197
- };
13198
- } catch (err) {
13199
- spinner.warn(`Baseline "${opts.baseline}" não carregou: ${err.message}`);
13200
- }
13201
- }
13202
- const visualCheck = checks.find((c) => c.name === "visual-regression-keyframes");
13203
- const visualDiff = visualCheck?.data?.visualDiff;
13204
- const seoCheck = checks.find((c) => c.name === "seo-deep-audit");
13205
- const seo = seoCheck?.data?.seo;
13206
- const run = {
13207
- schemaVersion: "0.1",
13208
- id: runId,
13209
- timestamp,
13210
- prodUrl: opts.prod,
13211
- candUrl: opts.cand,
13212
- flows,
13213
- viewports,
13214
- cep: rc.cep,
13215
- durationMs: Date.now() - startedAt,
13216
- verdict,
13217
- topIssues,
13218
- issues: allIssues,
13219
- checks,
13220
- flowCaptures: allFlowCaptures,
13221
- visualDiff,
13222
- seo,
13223
- baseline: baselineSection
13224
- };
13225
- writeRunReportJson(paths.runDir, run);
13226
- const html = renderHtmlReport(run, paths.runDir);
13227
- writeRunReportHtml(paths.runDir, html);
13228
- printSummary5(run, paths.reportHtml, { promotedCount, deprecatedCount, platform });
13229
- if (opts.ci) {
13230
- const blocking = allIssues.filter((i) => failOn.includes(i.severity));
13231
- if (blocking.length > 0) {
13232
- console.log(chalk17.red(`
13233
- ✖ ${blocking.length} issue(s) bloqueante(s) [${failOn.join(", ")}] — exit 1`));
13234
- return 1;
13235
- }
13236
- }
13237
- if (opts.open) {
13238
- if (browser) {
13239
- await browser.close().catch(() => {
13240
- return;
13241
- });
13242
- browser = null;
13243
- }
13244
- return await serveRunAndBlock(paths.runDir, { label: `parity run · ${runId}` });
13245
- }
13246
- return 0;
13247
- } catch (err) {
13248
- spinner.fail(`Erro: ${err.message}`);
13249
- console.error(err);
13250
- return 2;
13251
- } finally {
13252
- if (browser)
13253
- await browser.close().catch(() => {
13254
- return;
13255
- });
13256
- }
13257
- }
13258
- function computeVerdict3(checks, issues) {
13259
- const counts = { critical: 0, high: 0, medium: 0, low: 0 };
13260
- for (const i of issues)
13261
- counts[i.severity]++;
13262
- const checksPassed = checks.filter((c) => c.status === "pass").length;
13263
- const checksFailed = checks.filter((c) => c.status === "fail").length;
13264
- const checksSkipped = checks.filter((c) => c.status === "skipped").length;
13265
- const checksWarn = checks.filter((c) => c.status === "warn").length;
13266
- const score = Math.max(0, 100 - counts.critical * 20 - counts.high * 8 - counts.medium * 3 - counts.low * 1);
13267
- const status = counts.critical > 0 || checksFailed > 0 ? "fail" : counts.high > 0 || checksWarn > 0 ? "warn" : "pass";
13268
- return {
13269
- status,
13270
- score,
13271
- critical: counts.critical,
13272
- high: counts.high,
13273
- medium: counts.medium,
13274
- low: counts.low,
13275
- checksRun: checks.length,
13276
- checksPassed,
13277
- checksFailed,
13278
- checksSkipped
13279
- };
13280
- }
13281
- async function runWithConcurrency3(items, workers, fn) {
13282
- let next = 0;
13283
- await Promise.all(Array.from({ length: workers }, async () => {
13284
- while (true) {
13285
- const idx = next++;
13286
- if (idx >= items.length)
13287
- break;
13288
- await fn(items[idx]);
13289
- }
13290
- }));
13291
- }
13292
- async function preflightCheck(prodUrl, candUrl, viewport) {
13293
- const spinner = ora5("Pre-flight: verificando URLs…").start();
13294
- const errors = [];
13295
- const ua = userAgentFor(viewport);
13296
- async function probe(label, url) {
13297
- try {
13298
- new URL(url);
13299
- } catch {
13300
- errors.push(`${label}: URL inválida (${url})`);
13301
- return;
13302
- }
13303
- const controller = new AbortController;
13304
- const t = setTimeout(() => controller.abort(), 1e4);
13305
- try {
13306
- const res = await fetch(url, {
13307
- method: "GET",
13308
- redirect: "follow",
13309
- signal: controller.signal,
13310
- headers: {
13311
- "User-Agent": ua
14141
+ viewport: task.viewport,
14142
+ pages: [cap],
14143
+ totalDurationMs: cap.durationMs
14144
+ });
14145
+ } finally {
14146
+ await page.close().catch(() => {
14147
+ return;
14148
+ });
14149
+ await ctx.close().catch(() => {
14150
+ return;
14151
+ });
14152
+ }
14153
+ } catch {} finally {
14154
+ done++;
14155
+ extraSpinner.text = `[vitals extras] ${done}/${tasks.length} (último: ${task.path})`;
14156
+ }
14157
+ });
14158
+ extraSpinner.succeed(`+${extraPaths.length} página(s) com vitals`);
14159
+ } else {
14160
+ extraSpinner.warn("Nenhuma página extra encontrada no sitemap");
13312
14161
  }
13313
- });
13314
- if (res.status >= 500)
13315
- errors.push(`${label}: HTTP ${res.status} ${res.statusText} (${url})`);
13316
- else if (res.status >= 400)
13317
- errors.push(`${label}: HTTP ${res.status} ${res.statusText} (${url})`);
13318
- } catch (err) {
13319
- const e = err;
13320
- const msg = e.name === "AbortError" ? "timeout (10s)" : e.message;
13321
- errors.push(`${label}: ${msg} (${url})`);
13322
- } finally {
13323
- clearTimeout(t);
13324
- }
13325
- }
13326
- await Promise.all([probe("prod", prodUrl), probe("cand", candUrl)]);
13327
- if (errors.length > 0) {
13328
- spinner.fail("Pre-flight falhou");
13329
- return { ok: false, errors };
13330
- }
13331
- spinner.succeed("Pre-flight OK");
13332
- return { ok: true, errors: [] };
13333
- }
13334
- function addCacheBuster(url) {
13335
- try {
13336
- const u = new URL(url);
13337
- u.searchParams.set("_pcb", String(Date.now()));
13338
- return u.toString();
13339
- } catch {
13340
- return url;
13341
- }
13342
- }
13343
- async function warmupTargets(opts) {
13344
- const headers = opts.bypassCache ? { "Cache-Control": "no-cache", Pragma: "no-cache" } : {};
13345
- const jobs = [];
13346
- for (const url of opts.urls) {
13347
- for (const viewport of opts.viewports) {
13348
- const ua = userAgentFor(viewport);
13349
- const target = addCacheBuster(url);
13350
- jobs.push(fetch(target, {
13351
- method: "GET",
13352
- redirect: "follow",
13353
- headers: { ...headers, "User-Agent": ua }
13354
- }).then((res) => res.ok || res.status >= 200 && res.status < 400 ? { ok: true } : { ok: false, url, viewport, reason: `HTTP ${res.status} ${res.statusText}` }).catch((err) => ({
13355
- ok: false,
13356
- url,
13357
- viewport,
13358
- reason: err.message ?? "fetch failed"
13359
- })));
14162
+ } catch (err) {
14163
+ extraSpinner.warn(`vitals extras pulado: ${err.message}`);
14164
+ }
13360
14165
  }
13361
- }
13362
- const results = await Promise.all(jobs);
13363
- const succeeded = results.filter((r) => r.ok).length;
13364
- const failed = results.filter((r) => !r.ok).map(({ url, viewport, reason }) => ({ url, viewport, reason }));
13365
- return { attempted: results.length, succeeded, failed };
13366
- }
13367
- async function fetchHomeHtml(url, viewport = "desktop") {
13368
- try {
13369
- const res = await fetch(url, {
13370
- headers: {
13371
- "User-Agent": userAgentFor(viewport),
13372
- Accept: "text/html,application/xhtml+xml",
13373
- "Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8"
13374
- },
13375
- redirect: "follow"
13376
- });
13377
- if (!res.ok)
13378
- return null;
13379
- return await res.text();
13380
- } catch {
13381
- return null;
13382
- }
13383
- }
13384
- function findPdpCapture(flows, side) {
13385
- for (const fc of flows) {
13386
- if (fc.side !== side)
13387
- continue;
13388
- if (fc.flow !== "purchase-journey" && fc.flow !== "pdp")
13389
- continue;
13390
- const last = fc.pages[fc.pages.length - 1];
13391
- if (last)
13392
- return last;
13393
- }
13394
- return;
13395
- }
13396
- function hostOf2(url) {
13397
- try {
13398
- return new URL(url).hostname;
13399
- } catch {
13400
- return url;
13401
- }
13402
- }
13403
- function printSummary5(run, htmlPath, meta) {
13404
- const { verdict } = run;
13405
- const emoji = verdict.status === "pass" ? chalk17.green("✔") : verdict.status === "warn" ? chalk17.yellow("⚠") : chalk17.red("✖");
13406
- const score = verdict.status === "pass" ? chalk17.green(verdict.score) : verdict.status === "warn" ? chalk17.yellow(verdict.score) : chalk17.red(verdict.score);
13407
- console.log("");
13408
- console.log(` ${emoji} parity ${verdict.status.toUpperCase()} · score ${score}/100`);
13409
- console.log(` checks: ${chalk17.green(verdict.checksPassed)} pass · ${chalk17.red(verdict.checksFailed)} fail · ${chalk17.dim(`${verdict.checksSkipped} skipped`)}`);
13410
- console.log(` issues: ${chalk17.red(verdict.critical)} critical · ${chalk17.yellow(verdict.high)} high · ${verdict.medium} medium · ${chalk17.dim(`${verdict.low} low`)}`);
13411
- if (run.topIssues.length > 0) {
13412
- console.log("");
13413
- console.log(chalk17.bold(" Top issues:"));
13414
- for (const i of run.topIssues.slice(0, 5)) {
13415
- const sev = i.severity === "critical" ? chalk17.red(`[${i.severity}]`) : i.severity === "high" ? chalk17.yellow(`[${i.severity}]`) : chalk17.dim(`[${i.severity}]`);
13416
- console.log(` ${sev} ${i.summary}`);
14166
+ const visualPagesLimit = opts.noVisualDiff ? 0 : opts.visualPages ?? 5;
14167
+ if (browser && visualPagesLimit > 0) {
14168
+ const visualSpinner = ora5("Descobrindo páginas pra visual diff…").start();
14169
+ try {
14170
+ const sample = await discoverPagesFromSitemap(opts.prod, { sampleSize: visualPagesLimit });
14171
+ const visualPaths = sample.all.map((p) => p.path);
14172
+ const alreadyCapturedKeys = new Set;
14173
+ for (const p of allPageCaptures) {
14174
+ try {
14175
+ alreadyCapturedKeys.add(`${new URL(p.url).pathname}::${p.viewport}::${p.side}`);
14176
+ } catch {}
14177
+ }
14178
+ const tasks = [];
14179
+ for (const viewport of viewports) {
14180
+ for (const path of visualPaths) {
14181
+ for (const side of ["prod", "cand"]) {
14182
+ if (alreadyCapturedKeys.has(`${path}::${viewport}::${side}`))
14183
+ continue;
14184
+ tasks.push({ path, viewport, side });
14185
+ }
14186
+ }
14187
+ }
14188
+ if (tasks.length === 0) {
14189
+ visualSpinner.succeed(`Visual diff: páginas já capturadas em flows (${visualPaths.length} alvos)`);
14190
+ } else {
14191
+ visualSpinner.text = `Visual diff: capturando ${tasks.length} screenshot(s) (${visualPaths.length} páginas × ${viewports.length} viewport(s) × 2 sides)…`;
14192
+ let done = 0;
14193
+ await runWithConcurrency3(tasks, 4, async (task) => {
14194
+ const baseUrl = task.side === "prod" ? opts.prod : opts.cand;
14195
+ const fullUrl = new URL(task.path, baseUrl).toString();
14196
+ const safePath2 = task.path.replace(/[/?&=]+/g, "_") || "_root";
14197
+ const screenshotPath2 = `${paths.screenshotsDir}/visual-${safePath2}-${task.viewport}-${task.side}.png`;
14198
+ try {
14199
+ const ctx = await newContext(browser, { viewport: task.viewport, cohortCookieValue: "control", noCache: opts.bypassCache });
14200
+ await installVitalsCollector(ctx);
14201
+ const page = await ctx.newPage();
14202
+ try {
14203
+ const cap = await capturePage(page, {
14204
+ url: fullUrl,
14205
+ side: task.side,
14206
+ viewport: task.viewport,
14207
+ screenshotPath: screenshotPath2,
14208
+ settleMs: 1800,
14209
+ timeoutMs: 30000,
14210
+ scrollToLoad: true
14211
+ });
14212
+ allPageCaptures.push(cap);
14213
+ } finally {
14214
+ await page.close().catch(() => {
14215
+ return;
14216
+ });
14217
+ await ctx.close().catch(() => {
14218
+ return;
14219
+ });
14220
+ }
14221
+ } catch {} finally {
14222
+ done++;
14223
+ visualSpinner.text = `[visual] ${done}/${tasks.length} (último: ${task.path})`;
14224
+ }
14225
+ });
14226
+ visualSpinner.succeed(`Visual diff: ${tasks.length} screenshot(s) capturado(s)`);
14227
+ }
14228
+ } catch (err) {
14229
+ visualSpinner.warn(`Visual diff descoberta pulada: ${err.message}`);
14230
+ }
13417
14231
  }
13418
- }
13419
- if (meta.promotedCount > 0 || meta.deprecatedCount > 0) {
13420
- console.log("");
13421
- console.log(chalk17.dim(` \uD83D\uDCDA learned-selectors [${meta.platform}]: ${chalk17.green(`+${meta.promotedCount}`)} promoted · ${chalk17.yellow(meta.deprecatedCount)} deprecated`));
13422
- }
13423
- console.log("");
13424
- console.log(chalk17.dim(` → ${htmlPath}`));
13425
- console.log("");
13426
- }
13427
-
13428
- // src/commands/section.ts
13429
- import { mkdirSync as mkdirSync6 } from "node:fs";
13430
- import { resolve as resolve2 } from "node:path";
13431
- import chalk18 from "chalk";
13432
- import * as cheerio8 from "cheerio";
13433
- import * as diff2 from "diff";
13434
- import prettier2 from "prettier";
13435
-
13436
- // src/engine/computed-styles.ts
13437
- var SECTION_STYLE_KEYS = [
13438
- "display",
13439
- "visibility",
13440
- "opacity",
13441
- "position",
13442
- "z-index",
13443
- "top",
13444
- "right",
13445
- "bottom",
13446
- "left",
13447
- "width",
13448
- "height",
13449
- "max-width",
13450
- "max-height",
13451
- "min-width",
13452
- "min-height",
13453
- "transform",
13454
- "clip-path",
13455
- "overflow",
13456
- "overflow-x",
13457
- "overflow-y",
13458
- "margin",
13459
- "padding",
13460
- "border",
13461
- "font-size",
13462
- "font-weight",
13463
- "color",
13464
- "background-color",
13465
- "background-image",
13466
- "flex-direction",
13467
- "justify-content",
13468
- "align-items",
13469
- "gap",
13470
- "grid-template-columns",
13471
- "grid-template-rows"
13472
- ];
13473
- async function readComputedStyles(page, selector) {
13474
- const keys = SECTION_STYLE_KEYS;
13475
- let exists = false;
13476
- try {
13477
- exists = await page.locator(selector).count() > 0;
13478
- } catch (err) {
13479
- return { found: false, error: `seletor inválido: ${err.message}` };
13480
- }
13481
- if (!exists) {
13482
- return { found: false, error: `seletor '${selector}' não casou nenhum elemento` };
13483
- }
13484
- const hiddenByPlaywright = !await page.locator(selector).first().isVisible({ timeout: 1000 }).catch(() => false);
13485
- let got;
13486
- try {
13487
- got = await page.evaluate(({ sel, keys: keys2 }) => {
14232
+ spinner.start("Rodando checks…");
14233
+ const checkCtx = {
14234
+ prodPages: allPageCaptures.filter((p) => p.side === "prod"),
14235
+ candPages: allPageCaptures.filter((p) => p.side === "cand"),
14236
+ prodFlows: allFlowCaptures.filter((f) => f.side === "prod"),
14237
+ candFlows: allFlowCaptures.filter((f) => f.side === "cand"),
14238
+ rc,
14239
+ ignore,
14240
+ outDir: paths.runDir,
14241
+ viewports
14242
+ };
14243
+ const checks = await runAllChecks(checkCtx);
14244
+ spinner.succeed(`Checks concluídos (${checks.length})`);
14245
+ const allIssues = checks.flatMap((c) => c.issues);
14246
+ spinner.start(isLlmAvailable() ? "Agregando issues via LLM (Sonnet 4.6)…" : "Agregando issues (modo offline)…");
14247
+ const topIssues = await aggregateIssues({
14248
+ runId,
14249
+ prodUrl: opts.prod,
14250
+ candUrl: opts.cand,
14251
+ viewports,
14252
+ flows,
14253
+ checks
14254
+ });
14255
+ spinner.succeed(`${topIssues.length} issue(s) priorizada(s)`);
14256
+ const verdict2 = computeVerdict3(checks, allIssues);
14257
+ let baselineSection;
14258
+ if (opts.baseline) {
13488
14259
  try {
13489
- const el = document.querySelector(sel);
13490
- if (!el || !(el instanceof HTMLElement)) {
13491
- return { ok: false, reason: "querySelector retornou null ou non-HTMLElement" };
13492
- }
13493
- const cs = window.getComputedStyle(el);
13494
- const out = {};
13495
- for (const k of keys2)
13496
- out[k] = cs.getPropertyValue(k) || "";
13497
- const r = el.getBoundingClientRect();
13498
- return {
13499
- ok: true,
13500
- styles: out,
13501
- rect: { x: r.x, y: r.y, width: r.width, height: r.height }
14260
+ const bl = loadBaseline(opts.baseline);
14261
+ const delta = compareToBaseline({
14262
+ id: runId,
14263
+ issues: allIssues
14264
+ }, bl);
14265
+ baselineSection = {
14266
+ name: opts.baseline,
14267
+ delta: {
14268
+ resolved: bl.issues.filter((i) => delta.resolved.includes(i.id)),
14269
+ new: allIssues.filter((i) => delta.new.includes(i.id)),
14270
+ regressions: allIssues.filter((i) => delta.regressions.includes(i.id))
14271
+ }
13502
14272
  };
13503
14273
  } catch (err) {
13504
- return {
13505
- ok: false,
13506
- reason: `document.querySelector inválido em '${sel}': ${err.message ?? "syntax error"}`
13507
- };
14274
+ spinner.warn(`Baseline "${opts.baseline}" não carregou: ${err.message}`);
13508
14275
  }
13509
- }, { sel: selector, keys });
13510
- } catch (err) {
13511
- return {
13512
- found: false,
13513
- error: `falha no page.evaluate de '${selector}': ${err.message ?? "unknown"}. Se o seletor usa sintaxe Playwright-only (text=..., :visible, xpath=...), use um seletor CSS puro.`
14276
+ }
14277
+ const visualCheck = checks.find((c) => c.name === "visual-regression-keyframes");
14278
+ const visualDiff = visualCheck?.data?.visualDiff;
14279
+ const seoCheck = checks.find((c) => c.name === "seo-deep-audit");
14280
+ const seo = seoCheck?.data?.seo;
14281
+ const run = {
14282
+ schemaVersion: "0.1",
14283
+ id: runId,
14284
+ timestamp,
14285
+ prodUrl: opts.prod,
14286
+ candUrl: opts.cand,
14287
+ flows,
14288
+ viewports,
14289
+ cep: rc.cep,
14290
+ durationMs: Date.now() - startedAt,
14291
+ verdict: verdict2,
14292
+ topIssues,
14293
+ issues: allIssues,
14294
+ checks,
14295
+ flowCaptures: allFlowCaptures,
14296
+ visualDiff,
14297
+ seo,
14298
+ baseline: baselineSection
13514
14299
  };
13515
- }
13516
- if (!got.ok) {
13517
- return { found: false, error: got.reason };
13518
- }
13519
- return { found: true, styles: got.styles, rect: got.rect, hiddenByPlaywright };
13520
- }
13521
-
13522
- // src/commands/section.ts
13523
- async function sectionCommand(opts) {
13524
- const viewport = parseViewport4(opts.viewport);
13525
- if (!viewport) {
13526
- console.error(chalk18.red(`viewport inválido: ${opts.viewport} (use mobile|desktop|tablet)`));
13527
- return 2;
13528
- }
13529
- const waitMs = Number.parseInt(opts.wait, 10);
13530
- if (!Number.isFinite(waitMs) || waitMs < 0) {
13531
- console.error(chalk18.red(`--wait inválido: ${opts.wait}`));
13532
- return 2;
13533
- }
13534
- if (!opts.selector || opts.selector.trim().length === 0) {
13535
- console.error(chalk18.red("--selector é obrigatório"));
13536
- return 2;
13537
- }
13538
- if (!isValidUrl2(opts.prod) || !isValidUrl2(opts.cand)) {
13539
- console.error(chalk18.red("--prod ou --cand inválido"));
13540
- return 2;
13541
- }
13542
- const facetsAsked = Boolean(opts.outputHtml) || Boolean(opts.screenshot) || Boolean(opts.computedStyles);
13543
- const want = {
13544
- html: facetsAsked ? Boolean(opts.outputHtml) : true,
13545
- screenshot: facetsAsked ? Boolean(opts.screenshot) : true,
13546
- styles: facetsAsked ? Boolean(opts.computedStyles) : true
13547
- };
13548
- mkdirSync6(opts.outDir, { recursive: true });
13549
- const hash2 = hashSelector(opts.selector);
13550
- const screenshotPaths = {
13551
- prod: resolve2(opts.outDir, `section-${hash2}-${viewport}-prod.png`),
13552
- cand: resolve2(opts.outDir, `section-${hash2}-${viewport}-cand.png`)
13553
- };
13554
- const browser = await launchBrowser({ headless: true });
13555
- try {
13556
- const [prodSide, candSide] = await Promise.all([
13557
- gatherSide(browser, {
13558
- url: opts.prod,
13559
- viewport,
13560
- waitMs,
13561
- selector: opts.selector,
13562
- wantScreenshot: want.screenshot,
13563
- wantStyles: want.styles,
13564
- wantHtml: want.html,
13565
- screenshotPath: screenshotPaths.prod
13566
- }),
13567
- gatherSide(browser, {
13568
- url: opts.cand,
13569
- viewport,
13570
- waitMs,
13571
- selector: opts.selector,
13572
- wantScreenshot: want.screenshot,
13573
- wantStyles: want.styles,
13574
- wantHtml: want.html,
13575
- screenshotPath: screenshotPaths.cand
13576
- })
13577
- ]);
13578
- if (opts.json) {
13579
- console.log(JSON.stringify({
13580
- prod: opts.prod,
13581
- cand: opts.cand,
13582
- selector: opts.selector,
13583
- viewport,
13584
- prodSide,
13585
- candSide,
13586
- screenshotPaths: want.screenshot ? screenshotPaths : null
13587
- }));
13588
- return verdict(prodSide, candSide);
14300
+ writeRunReportJson(paths.runDir, run);
14301
+ const html = renderHtmlReport(run, paths.runDir);
14302
+ writeRunReportHtml(paths.runDir, html);
14303
+ printSummary5(run, paths.reportHtml, { promotedCount, deprecatedCount, platform });
14304
+ if (opts.ci) {
14305
+ const blocking = allIssues.filter((i) => failOn.includes(i.severity));
14306
+ if (blocking.length > 0) {
14307
+ console.log(chalk18.red(`
14308
+ ${blocking.length} issue(s) bloqueante(s) [${failOn.join(", ")}] — exit 1`));
14309
+ return 1;
14310
+ }
13589
14311
  }
13590
- await printResults2({ opts, viewport, prodSide, candSide, screenshotPaths, want });
13591
- return verdict(prodSide, candSide);
14312
+ if (opts.open) {
14313
+ if (browser) {
14314
+ await browser.close().catch(() => {
14315
+ return;
14316
+ });
14317
+ browser = null;
14318
+ }
14319
+ return await serveRunAndBlock(paths.runDir, { label: `parity run · ${runId}` });
14320
+ }
14321
+ return 0;
14322
+ } catch (err) {
14323
+ spinner.fail(`Erro: ${err.message}`);
14324
+ console.error(err);
14325
+ return 2;
13592
14326
  } finally {
13593
- await browser.close().catch(() => {
13594
- return;
13595
- });
14327
+ if (browser)
14328
+ await browser.close().catch(() => {
14329
+ return;
14330
+ });
13596
14331
  }
13597
14332
  }
13598
- async function gatherSide(browser, opts) {
13599
- const ctx = await newContext(browser, { viewport: opts.viewport });
13600
- const page = await ctx.newPage();
13601
- const result = {
13602
- html: null,
13603
- styles: null,
13604
- screenshotTaken: false
14333
+ function computeVerdict3(checks, issues) {
14334
+ const counts = { critical: 0, high: 0, medium: 0, low: 0 };
14335
+ for (const i of issues)
14336
+ counts[i.severity]++;
14337
+ const checksPassed = checks.filter((c) => c.status === "pass").length;
14338
+ const checksFailed = checks.filter((c) => c.status === "fail").length;
14339
+ const checksSkipped = checks.filter((c) => c.status === "skipped").length;
14340
+ const checksWarn = checks.filter((c) => c.status === "warn").length;
14341
+ const score = Math.max(0, 100 - counts.critical * 20 - counts.high * 8 - counts.medium * 3 - counts.low * 1);
14342
+ const status = counts.critical > 0 || checksFailed > 0 ? "fail" : counts.high > 0 || checksWarn > 0 ? "warn" : "pass";
14343
+ return {
14344
+ status,
14345
+ score,
14346
+ critical: counts.critical,
14347
+ high: counts.high,
14348
+ medium: counts.medium,
14349
+ low: counts.low,
14350
+ checksRun: checks.length,
14351
+ checksPassed,
14352
+ checksFailed,
14353
+ checksSkipped
13605
14354
  };
13606
- try {
13607
- await page.goto(opts.url, { waitUntil: "domcontentloaded", timeout: 30000 });
13608
- await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => {
13609
- return;
13610
- });
13611
- if (opts.waitMs > 0)
13612
- await page.waitForTimeout(opts.waitMs);
13613
- await Promise.race([
13614
- stabilizeCarousels(page).catch(() => {
13615
- return;
13616
- }),
13617
- new Promise((resolve3) => setTimeout(resolve3, 3000))
13618
- ]);
13619
- if (opts.wantHtml) {
13620
- try {
13621
- const fullHtml = await page.content();
13622
- const $ = cheerio8.load(fullHtml);
13623
- const matches = $(opts.selector);
13624
- if (matches.length === 0) {
13625
- result.htmlError = `seletor '${opts.selector}' não casou nenhum elemento`;
13626
- } else {
13627
- result.html = $.html(matches.first());
13628
- }
13629
- } catch (err) {
13630
- result.htmlError = `falha lendo HTML: ${err.message}`;
13631
- }
14355
+ }
14356
+ async function runWithConcurrency3(items, workers, fn) {
14357
+ let next = 0;
14358
+ await Promise.all(Array.from({ length: workers }, async () => {
14359
+ while (true) {
14360
+ const idx = next++;
14361
+ if (idx >= items.length)
14362
+ break;
14363
+ await fn(items[idx]);
13632
14364
  }
13633
- if (opts.wantStyles) {
13634
- result.styles = await readComputedStyles(page, opts.selector);
14365
+ }));
14366
+ }
14367
+ async function preflightCheck(prodUrl, candUrl, viewport) {
14368
+ const spinner = ora5("Pre-flight: verificando URLs…").start();
14369
+ const errors = [];
14370
+ const ua = userAgentFor(viewport);
14371
+ async function probe(label, url) {
14372
+ try {
14373
+ new URL(url);
14374
+ } catch {
14375
+ errors.push(`${label}: URL inválida (${url})`);
14376
+ return;
13635
14377
  }
13636
- if (opts.wantScreenshot) {
13637
- await captureSectionScreenshot(page, opts.selector, opts.screenshotPath).then((err) => {
13638
- if (err)
13639
- result.screenshotError = err;
13640
- else
13641
- result.screenshotTaken = true;
14378
+ const controller = new AbortController;
14379
+ const t = setTimeout(() => controller.abort(), 1e4);
14380
+ try {
14381
+ const res = await fetch(url, {
14382
+ method: "GET",
14383
+ redirect: "follow",
14384
+ signal: controller.signal,
14385
+ headers: {
14386
+ "User-Agent": ua
14387
+ }
13642
14388
  });
14389
+ if (res.status >= 500)
14390
+ errors.push(`${label}: HTTP ${res.status} ${res.statusText} (${url})`);
14391
+ else if (res.status >= 400)
14392
+ errors.push(`${label}: HTTP ${res.status} ${res.statusText} (${url})`);
14393
+ } catch (err) {
14394
+ const e = err;
14395
+ const msg = e.name === "AbortError" ? "timeout (10s)" : e.message;
14396
+ errors.push(`${label}: ${msg} (${url})`);
14397
+ } finally {
14398
+ clearTimeout(t);
13643
14399
  }
13644
- } finally {
13645
- await page.close().catch(() => {
13646
- return;
13647
- });
13648
- await ctx.close().catch(() => {
13649
- return;
13650
- });
13651
14400
  }
13652
- return result;
14401
+ await Promise.all([probe("prod", prodUrl), probe("cand", candUrl)]);
14402
+ if (errors.length > 0) {
14403
+ spinner.fail("Pre-flight falhou");
14404
+ return { ok: false, errors };
14405
+ }
14406
+ spinner.succeed("Pre-flight OK");
14407
+ return { ok: true, errors: [] };
13653
14408
  }
13654
- async function captureSectionScreenshot(page, selector, outPath) {
14409
+ function addCacheBuster(url) {
13655
14410
  try {
13656
- const loc = page.locator(selector).first();
13657
- if (await loc.count() === 0) {
13658
- return `seletor '${selector}' não casou nenhum elemento`;
14411
+ const u = new URL(url);
14412
+ u.searchParams.set("_pcb", String(Date.now()));
14413
+ return u.toString();
14414
+ } catch {
14415
+ return url;
14416
+ }
14417
+ }
14418
+ async function warmupTargets(opts) {
14419
+ const headers = opts.bypassCache ? { "Cache-Control": "no-cache", Pragma: "no-cache" } : {};
14420
+ const jobs = [];
14421
+ for (const url of opts.urls) {
14422
+ for (const viewport of opts.viewports) {
14423
+ const ua = userAgentFor(viewport);
14424
+ const target = addCacheBuster(url);
14425
+ jobs.push(fetch(target, {
14426
+ method: "GET",
14427
+ redirect: "follow",
14428
+ headers: { ...headers, "User-Agent": ua }
14429
+ }).then((res) => res.ok || res.status >= 200 && res.status < 400 ? { ok: true } : { ok: false, url, viewport, reason: `HTTP ${res.status} ${res.statusText}` }).catch((err) => ({
14430
+ ok: false,
14431
+ url,
14432
+ viewport,
14433
+ reason: err.message ?? "fetch failed"
14434
+ })));
13659
14435
  }
13660
- await loc.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {
13661
- return;
13662
- });
13663
- await loc.screenshot({ path: outPath, timeout: 8000 });
13664
- return null;
13665
- } catch (err) {
13666
- return `falha no screenshot: ${err.message}`;
13667
14436
  }
14437
+ const results = await Promise.all(jobs);
14438
+ const succeeded = results.filter((r) => r.ok).length;
14439
+ const failed = results.filter((r) => !r.ok).map(({ url, viewport, reason }) => ({ url, viewport, reason }));
14440
+ return { attempted: results.length, succeeded, failed };
13668
14441
  }
13669
- function isValidUrl2(s) {
14442
+ async function fetchHomeHtml(url, viewport = "desktop") {
13670
14443
  try {
13671
- new URL(s);
13672
- return true;
14444
+ const res = await fetch(url, {
14445
+ headers: {
14446
+ "User-Agent": userAgentFor(viewport),
14447
+ Accept: "text/html,application/xhtml+xml",
14448
+ "Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8"
14449
+ },
14450
+ redirect: "follow"
14451
+ });
14452
+ if (!res.ok)
14453
+ return null;
14454
+ return await res.text();
13673
14455
  } catch {
13674
- return false;
14456
+ return null;
13675
14457
  }
13676
14458
  }
13677
- function parseViewport4(raw) {
13678
- if (raw === "mobile" || raw === "desktop" || raw === "tablet")
13679
- return raw;
13680
- return null;
13681
- }
13682
- function hashSelector(selector) {
13683
- let h = 0;
13684
- for (let i = 0;i < selector.length; i++) {
13685
- h = (h << 5) - h + selector.charCodeAt(i);
13686
- h |= 0;
14459
+ function findPdpCapture(flows, side) {
14460
+ for (const fc of flows) {
14461
+ if (fc.side !== side)
14462
+ continue;
14463
+ if (fc.flow !== "purchase-journey" && fc.flow !== "pdp")
14464
+ continue;
14465
+ const last = fc.pages[fc.pages.length - 1];
14466
+ if (last)
14467
+ return last;
13687
14468
  }
13688
- return (h >>> 0).toString(16).padStart(8, "0").slice(0, 8);
14469
+ return;
13689
14470
  }
13690
- function verdict(prod, cand) {
13691
- if (prod.htmlError || cand.htmlError)
13692
- return 1;
13693
- if (prod.styles && "found" in prod.styles && cand.styles && "found" in cand.styles) {
13694
- if (prod.styles.found && cand.styles.found) {
13695
- for (const k of SECTION_STYLE_KEYS) {
13696
- if (prod.styles.styles[k] !== cand.styles.styles[k])
13697
- return 1;
13698
- }
13699
- if (prod.styles.hiddenByPlaywright !== cand.styles.hiddenByPlaywright)
13700
- return 1;
13701
- const pr = prod.styles.rect;
13702
- const cr = cand.styles.rect;
13703
- if (pr && cr && (pr.width !== cr.width || pr.height !== cr.height))
13704
- return 1;
13705
- } else if (prod.styles.found !== cand.styles.found) {
13706
- return 1;
13707
- }
13708
- }
13709
- if (prod.html && cand.html) {
13710
- if (prod.html !== cand.html)
13711
- return 1;
14471
+ function hostOf2(url) {
14472
+ try {
14473
+ return new URL(url).hostname;
14474
+ } catch {
14475
+ return url;
13712
14476
  }
13713
- return 0;
13714
14477
  }
13715
- async function printResults2(args) {
13716
- const { opts, viewport, prodSide, candSide, screenshotPaths, want } = args;
13717
- console.log(chalk18.bold(`
13718
- parity section`));
13719
- console.log(chalk18.dim(` selector: ${opts.selector}`));
13720
- console.log(chalk18.dim(` viewport: ${viewport}`));
13721
- console.log(chalk18.dim(` prod: ${opts.prod}`));
13722
- console.log(chalk18.dim(` cand: ${opts.cand}`));
14478
+ function printSummary5(run, htmlPath, meta) {
14479
+ const { verdict: verdict2 } = run;
14480
+ const emoji = verdict2.status === "pass" ? chalk18.green("✔") : verdict2.status === "warn" ? chalk18.yellow("⚠") : chalk18.red("✖");
14481
+ const score = verdict2.status === "pass" ? chalk18.green(verdict2.score) : verdict2.status === "warn" ? chalk18.yellow(verdict2.score) : chalk18.red(verdict2.score);
13723
14482
  console.log("");
13724
- if (want.html)
13725
- await printHtmlDiff(prodSide, candSide, opts.selector);
13726
- if (want.styles)
13727
- printStylesDiff(prodSide, candSide);
13728
- if (want.screenshot)
13729
- printScreenshotPaths(prodSide, candSide, screenshotPaths);
13730
- }
13731
- async function printHtmlDiff(prod, cand, selector) {
13732
- console.log(chalk18.bold(" HTML diff"));
13733
- if (prod.htmlError) {
13734
- console.log(chalk18.red(` prod: ${prod.htmlError}`));
13735
- return;
13736
- }
13737
- if (cand.htmlError) {
13738
- console.log(chalk18.red(` cand: ${cand.htmlError}`));
13739
- return;
13740
- }
13741
- if (!prod.html || !cand.html) {
13742
- console.log(chalk18.dim(" (nenhum lado tem HTML disponível)"));
13743
- return;
13744
- }
13745
- const [prodFmt, candFmt] = await Promise.all([formatHtml(prod.html), formatHtml(cand.html)]);
13746
- if (prodFmt === candFmt) {
13747
- console.log(chalk18.green(" ✓ idêntico após pretty-print"));
14483
+ console.log(` ${emoji} parity ${verdict2.status.toUpperCase()} · score ${score}/100`);
14484
+ console.log(` checks: ${chalk18.green(verdict2.checksPassed)} pass · ${chalk18.red(verdict2.checksFailed)} fail · ${chalk18.dim(`${verdict2.checksSkipped} skipped`)}`);
14485
+ console.log(` issues: ${chalk18.red(verdict2.critical)} critical · ${chalk18.yellow(verdict2.high)} high · ${verdict2.medium} medium · ${chalk18.dim(`${verdict2.low} low`)}`);
14486
+ if (run.topIssues.length > 0) {
13748
14487
  console.log("");
13749
- return;
13750
- }
13751
- const patch = diff2.createPatch(selector, prodFmt, candFmt, "prod", "cand");
13752
- console.log(colorPatch(patch));
13753
- console.log("");
13754
- }
13755
- function printStylesDiff(prod, cand) {
13756
- console.log(chalk18.bold(" Computed styles diff"));
13757
- if (!prod.styles || !cand.styles) {
13758
- console.log(chalk18.dim(" (styles não foram coletados)"));
13759
- return;
13760
- }
13761
- if (!prod.styles.found) {
13762
- console.log(chalk18.red(` prod: ${prod.styles.error}`));
13763
- return;
13764
- }
13765
- if (!cand.styles.found) {
13766
- console.log(chalk18.red(` cand: ${cand.styles.error}`));
13767
- return;
13768
- }
13769
- const rows = [];
13770
- for (const k of SECTION_STYLE_KEYS) {
13771
- const p = prod.styles.styles[k] ?? "";
13772
- const c = cand.styles.styles[k] ?? "";
13773
- if (p !== c)
13774
- rows.push({ key: k, prod: p, cand: c });
13775
- }
13776
- if (prod.styles.hiddenByPlaywright !== cand.styles.hiddenByPlaywright) {
13777
- rows.unshift({
13778
- key: "(playwright isVisible)",
13779
- prod: prod.styles.hiddenByPlaywright ? "hidden" : "visible",
13780
- cand: cand.styles.hiddenByPlaywright ? "hidden" : "visible"
13781
- });
13782
- }
13783
- const prodRect = prod.styles.rect;
13784
- const candRect = cand.styles.rect;
13785
- if (prodRect && candRect && (prodRect.width !== candRect.width || prodRect.height !== candRect.height)) {
13786
- rows.unshift({
13787
- key: "(boundingRect)",
13788
- prod: `${prodRect.width}×${prodRect.height} @ ${prodRect.x},${prodRect.y}`,
13789
- cand: `${candRect.width}×${candRect.height} @ ${candRect.x},${candRect.y}`
13790
- });
14488
+ console.log(chalk18.bold(" Top issues:"));
14489
+ for (const i of run.topIssues.slice(0, 5)) {
14490
+ const sev = i.severity === "critical" ? chalk18.red(`[${i.severity}]`) : i.severity === "high" ? chalk18.yellow(`[${i.severity}]`) : chalk18.dim(`[${i.severity}]`);
14491
+ console.log(` ${sev} ${i.summary}`);
14492
+ }
13791
14493
  }
13792
- if (rows.length === 0) {
13793
- console.log(chalk18.green(" ✓ todos os SECTION_STYLE_KEYS são iguais"));
14494
+ if (meta.promotedCount > 0 || meta.deprecatedCount > 0) {
13794
14495
  console.log("");
13795
- return;
13796
- }
13797
- const keyWidth = Math.max(...rows.map((r) => r.key.length));
13798
- for (const r of rows) {
13799
- console.log(` ${chalk18.cyan(r.key.padEnd(keyWidth))} ${chalk18.red(`prod=${r.prod || "(vazio)"}`)} ${chalk18.green(`cand=${r.cand || "(vazio)"}`)}`);
14496
+ console.log(chalk18.dim(` \uD83D\uDCDA learned-selectors [${meta.platform}]: ${chalk18.green(`+${meta.promotedCount}`)} promoted · ${chalk18.yellow(meta.deprecatedCount)} deprecated`));
13800
14497
  }
13801
14498
  console.log("");
13802
- }
13803
- function printScreenshotPaths(prod, cand, paths) {
13804
- console.log(chalk18.bold(" Screenshots"));
13805
- if (prod.screenshotError) {
13806
- console.log(chalk18.red(` prod: ${prod.screenshotError}`));
13807
- } else if (prod.screenshotTaken) {
13808
- console.log(` prod: ${chalk18.dim(paths.prod)}`);
13809
- }
13810
- if (cand.screenshotError) {
13811
- console.log(chalk18.red(` cand: ${cand.screenshotError}`));
13812
- } else if (cand.screenshotTaken) {
13813
- console.log(` cand: ${chalk18.dim(paths.cand)}`);
13814
- }
14499
+ console.log(chalk18.dim(` → ${htmlPath}`));
13815
14500
  console.log("");
13816
14501
  }
13817
- async function formatHtml(raw) {
13818
- try {
13819
- return await prettier2.format(raw, {
13820
- parser: "html",
13821
- printWidth: 100,
13822
- htmlWhitespaceSensitivity: "ignore"
13823
- });
13824
- } catch {
13825
- return raw;
13826
- }
13827
- }
13828
- function colorPatch(patch) {
13829
- const lines = patch.split(`
13830
- `);
13831
- const out = [];
13832
- for (const line of lines) {
13833
- if (line.startsWith("Index:") || line.startsWith("==="))
13834
- continue;
13835
- if (line.startsWith("---") || line.startsWith("+++"))
13836
- out.push(chalk18.dim(line));
13837
- else if (line.startsWith("@@"))
13838
- out.push(chalk18.cyan(line));
13839
- else if (line.startsWith("+"))
13840
- out.push(chalk18.green(line));
13841
- else if (line.startsWith("-"))
13842
- out.push(chalk18.red(line));
13843
- else
13844
- out.push(line);
13845
- }
13846
- return out.join(`
13847
- `);
13848
- }
13849
14502
 
13850
14503
  // src/cli.ts
13851
14504
  var program = new Command;
@@ -13924,7 +14577,7 @@ program.command("check").argument("<name>", "check name (kebab-case, e.g. consol
13924
14577
  json: opts.json
13925
14578
  }));
13926
14579
  });
13927
- program.command("section").description("Focused prod×cand comparison of one section: HTML diff + screenshot + computed-styles diff. No flags = all 3 facets. Designed for the 'in DOM but invisible' debug loop (issue #31).").requiredOption("--prod <url>", "Production URL (base, e.g. https://www.example.com)").requiredOption("--cand <url>", "Candidate URL (base, e.g. https://example.deco-cx.workers.dev)").requiredOption("--selector <sel>", "CSS selector for the section to compare").option("--output-html", "Include the HTML diff facet (default: on if no facet flag passed)", false).option("--screenshot", "Include the screenshot facet (locator screenshot per side)", false).option("--computed-styles", "Include the computed-styles diff facet", false).option("--viewport <viewport>", "mobile | desktop | tablet", "mobile").option("--wait <ms>", "Extra ms after networkidle so hydration settles", "2000").option("--out-dir <dir>", "Where to write the screenshots", "./parity-output/sections").option("--json", "Emit one-line JSON instead of pretty text", false).action(async (opts) => {
14580
+ program.command("section").description("Focused prod×cand comparison of one section: HTML diff + screenshot + computed-styles diff. No flags = all 3 facets. Designed for the 'in DOM but invisible' debug loop (issue #31).").requiredOption("--prod <url>", "Production URL (base, e.g. https://www.example.com)").requiredOption("--cand <url>", "Candidate URL (base, e.g. https://example.deco-cx.workers.dev)").requiredOption("--selector <sel>", "CSS selector for the section to compare").option("--output-html", "Include the HTML diff facet (default: on if no facet flag passed)", false).option("--screenshot", "Include the screenshot facet (locator screenshot per side)", false).option("--computed-styles", "Include the computed-styles diff facet", false).option("--heatmap", "Run pixelmatch on the two screenshots and analyze diff regions (bounding box + hotspots). Implies --screenshot.", false).option("--css-source", "For each divergent computed-style property, resolve which CSS rule (stylesheet + selector) produced it via CDP. Useful when the LLM needs to know which file to edit.", false).option("--prompt", "Emit an LLM-ready bundle: <prefix>-bundle.json (machine-readable) + <prefix>-prompt.md (opinionated Markdown with embedded images). Implies all signals.", false).option("--llm-summary", "After building the bundle, invoke Claude (needs ANTHROPIC_API_KEY) and print a 1-paragraph 'what I understood' summary. Does NOT generate a patch. Implies --prompt.", false).option("--viewport <viewport>", "mobile | desktop | tablet", "mobile").option("--wait <ms>", "Extra ms after networkidle so hydration settles", "2000").option("--out-dir <dir>", "Where to write the screenshots", "./parity-output/sections").option("--json", "Emit one-line JSON instead of pretty text", false).action(async (opts) => {
13928
14581
  process.exit(await sectionCommand({
13929
14582
  prod: opts.prod,
13930
14583
  cand: opts.cand,
@@ -13932,12 +14585,28 @@ program.command("section").description("Focused prod×cand comparison of one sec
13932
14585
  outputHtml: opts.outputHtml,
13933
14586
  screenshot: opts.screenshot,
13934
14587
  computedStyles: opts.computedStyles,
14588
+ heatmap: opts.heatmap,
14589
+ cssSource: opts.cssSource,
14590
+ prompt: opts.prompt,
14591
+ llmSummary: opts.llmSummary,
13935
14592
  viewport: opts.viewport,
13936
14593
  wait: opts.wait,
13937
14594
  outDir: opts.outDir,
13938
14595
  json: opts.json
13939
14596
  }));
13940
14597
  });
14598
+ program.command("fix").description("Pixel-perfect debug shortcut: run `parity section` with ALL signals on (HTML + screenshot + computed-styles + heatmap + CSS source) and emit an LLM-ready Markdown bundle. When ANTHROPIC_API_KEY is set, also prints a 1-paragraph summary of what Claude understood from the signals — does NOT generate a patch (you ask for that in a follow-up turn).").requiredOption("--prod <url>", "Production URL (source of truth)").requiredOption("--cand <url>", "Candidate URL (migrated)").requiredOption("--selector <sel>", "CSS selector for the section to fix").option("--viewport <viewport>", "mobile | desktop | tablet", "mobile").option("--wait <ms>", "Extra ms after networkidle so hydration settles", "2000").option("--out-dir <dir>", "Where to write the bundle + screenshots", "./parity-output/sections").option("--json", "Emit one-line JSON instead of pretty text", false).option("--no-llm", "Skip the LLM call (still writes the markdown bundle). Use when offline or to avoid API costs.").action(async (opts) => {
14599
+ process.exit(await fixCommand({
14600
+ prod: opts.prod,
14601
+ cand: opts.cand,
14602
+ selector: opts.selector,
14603
+ viewport: opts.viewport,
14604
+ wait: opts.wait,
14605
+ outDir: opts.outDir,
14606
+ json: opts.json,
14607
+ noLlm: !opts.llm
14608
+ }));
14609
+ });
13941
14610
  program.command("learned").description("Inspect the learned-selectors library").addCommand(new Command("stats").description("Print learned-selectors stats per platform").action(() => {
13942
14611
  process.exit(learnedStats());
13943
14612
  }));