@decocms/parity 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/dist/cli.js +2294 -1625
- package/package.json +2 -2
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/
|
|
8829
|
-
import {
|
|
8830
|
-
import {
|
|
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
|
|
8835
|
+
import * as cheerio5 from "cheerio";
|
|
8836
|
+
import * as diff2 from "diff";
|
|
8837
|
+
import prettier2 from "prettier";
|
|
8833
8838
|
|
|
8834
|
-
// src/
|
|
8835
|
-
var
|
|
8836
|
-
"
|
|
8837
|
-
"
|
|
8838
|
-
"
|
|
8839
|
-
"
|
|
8840
|
-
"
|
|
8841
|
-
"
|
|
8842
|
-
"
|
|
8843
|
-
"
|
|
8844
|
-
"
|
|
8845
|
-
"
|
|
8846
|
-
"
|
|
8847
|
-
"
|
|
8848
|
-
"
|
|
8849
|
-
"
|
|
8850
|
-
"
|
|
8851
|
-
"
|
|
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
|
|
8854
|
-
|
|
8855
|
-
|
|
8856
|
-
|
|
8857
|
-
|
|
8858
|
-
|
|
8859
|
-
|
|
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
|
-
|
|
8870
|
-
}
|
|
8871
|
-
|
|
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
|
-
|
|
8874
|
-
|
|
8875
|
-
|
|
8876
|
-
|
|
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
|
-
|
|
8880
|
-
|
|
8881
|
-
|
|
8882
|
-
|
|
8883
|
-
|
|
8884
|
-
|
|
8885
|
-
|
|
8886
|
-
|
|
8887
|
-
|
|
8888
|
-
|
|
8889
|
-
|
|
8890
|
-
|
|
8891
|
-
|
|
8892
|
-
|
|
8893
|
-
|
|
8894
|
-
|
|
8895
|
-
|
|
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
|
-
|
|
8900
|
-
|
|
8901
|
-
|
|
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
|
|
8984
|
+
return result;
|
|
8904
8985
|
}
|
|
8905
8986
|
|
|
8906
|
-
// src/
|
|
8907
|
-
import
|
|
8908
|
-
|
|
8909
|
-
|
|
8910
|
-
|
|
8911
|
-
|
|
8912
|
-
|
|
8913
|
-
|
|
8914
|
-
|
|
8915
|
-
|
|
8916
|
-
|
|
8917
|
-
|
|
8918
|
-
|
|
8919
|
-
|
|
8920
|
-
|
|
8921
|
-
|
|
8922
|
-
const
|
|
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(`})`);
|
|
9229
|
+
md("");
|
|
9230
|
+
md("**cand** (needs to match prod):");
|
|
9231
|
+
md(`})`);
|
|
9232
|
+
if (input.screenshots.heatmapPath) {
|
|
9233
|
+
md("");
|
|
9234
|
+
md("**Heatmap** (red = differs):");
|
|
9235
|
+
md(`})`);
|
|
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
|
-
|
|
8925
|
-
|
|
8926
|
-
|
|
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
|
|
9301
|
+
return absPath;
|
|
8929
9302
|
}
|
|
8930
9303
|
}
|
|
8931
|
-
|
|
8932
|
-
|
|
8933
|
-
|
|
8934
|
-
|
|
8935
|
-
|
|
8936
|
-
|
|
8937
|
-
|
|
8938
|
-
|
|
8939
|
-
|
|
8940
|
-
|
|
8941
|
-
|
|
8942
|
-
|
|
8943
|
-
|
|
8944
|
-
|
|
8945
|
-
|
|
8946
|
-
|
|
8947
|
-
|
|
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
|
-
|
|
8950
|
-
|
|
8951
|
-
|
|
8952
|
-
|
|
8953
|
-
|
|
8954
|
-
|
|
8955
|
-
|
|
8956
|
-
|
|
8957
|
-
|
|
8958
|
-
|
|
8959
|
-
|
|
8960
|
-
|
|
8961
|
-
|
|
8962
|
-
|
|
8963
|
-
|
|
8964
|
-
|
|
8965
|
-
|
|
8966
|
-
|
|
8967
|
-
|
|
8968
|
-
|
|
8969
|
-
|
|
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
|
-
|
|
8972
|
-
|
|
8973
|
-
|
|
8974
|
-
|
|
8975
|
-
|
|
8976
|
-
|
|
8977
|
-
|
|
8978
|
-
|
|
8979
|
-
|
|
8980
|
-
|
|
8981
|
-
${
|
|
8982
|
-
|
|
8983
|
-
|
|
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:
|
|
8986
|
-
description:
|
|
8987
|
-
inputSchema:
|
|
9389
|
+
name: SUMMARIZE_TOOL.name,
|
|
9390
|
+
description: SUMMARIZE_TOOL.description,
|
|
9391
|
+
inputSchema: SUMMARIZE_TOOL.inputSchema
|
|
8988
9392
|
}
|
|
8989
9393
|
});
|
|
8990
|
-
if (
|
|
8991
|
-
return
|
|
8992
|
-
|
|
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/
|
|
9002
|
-
|
|
9003
|
-
|
|
9004
|
-
|
|
9005
|
-
|
|
9006
|
-
|
|
9007
|
-
|
|
9008
|
-
|
|
9009
|
-
|
|
9010
|
-
|
|
9011
|
-
|
|
9012
|
-
|
|
9013
|
-
|
|
9014
|
-
|
|
9015
|
-
|
|
9016
|
-
|
|
9017
|
-
|
|
9018
|
-
|
|
9019
|
-
|
|
9020
|
-
|
|
9021
|
-
|
|
9022
|
-
|
|
9023
|
-
|
|
9024
|
-
|
|
9025
|
-
|
|
9026
|
-
|
|
9027
|
-
|
|
9028
|
-
|
|
9029
|
-
|
|
9030
|
-
|
|
9031
|
-
|
|
9032
|
-
|
|
9033
|
-
|
|
9034
|
-
|
|
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
|
|
9037
|
-
|
|
9038
|
-
|
|
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
|
-
|
|
9041
|
-
|
|
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
|
-
|
|
9044
|
-
return emptyLib();
|
|
9594
|
+
return `falha no screenshot: ${err.message}`;
|
|
9045
9595
|
}
|
|
9046
9596
|
}
|
|
9047
|
-
function
|
|
9048
|
-
|
|
9049
|
-
|
|
9050
|
-
|
|
9051
|
-
|
|
9052
|
-
|
|
9053
|
-
|
|
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
|
|
9057
|
-
|
|
9058
|
-
|
|
9059
|
-
|
|
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
|
|
9064
|
-
|
|
9065
|
-
|
|
9066
|
-
|
|
9067
|
-
|
|
9068
|
-
|
|
9069
|
-
return
|
|
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
|
|
9072
|
-
|
|
9073
|
-
|
|
9074
|
-
if (
|
|
9075
|
-
|
|
9076
|
-
|
|
9077
|
-
|
|
9078
|
-
|
|
9079
|
-
|
|
9080
|
-
|
|
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
|
-
|
|
9086
|
-
|
|
9087
|
-
|
|
9088
|
-
|
|
9089
|
-
|
|
9090
|
-
|
|
9091
|
-
|
|
9092
|
-
|
|
9093
|
-
|
|
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
|
|
9096
|
-
|
|
9097
|
-
|
|
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
|
-
|
|
9107
|
-
|
|
9108
|
-
|
|
9109
|
-
|
|
9110
|
-
|
|
9111
|
-
|
|
9112
|
-
|
|
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
|
|
9125
|
-
|
|
9126
|
-
|
|
9127
|
-
|
|
9128
|
-
|
|
9129
|
-
|
|
9130
|
-
|
|
9131
|
-
|
|
9132
|
-
|
|
9133
|
-
|
|
9134
|
-
|
|
9135
|
-
|
|
9136
|
-
|
|
9137
|
-
|
|
9138
|
-
|
|
9139
|
-
|
|
9140
|
-
|
|
9141
|
-
|
|
9142
|
-
|
|
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
|
-
|
|
9864
|
+
console.log(` ${chalk10.dim(`heatmap: ${heatmapPath}`)}`);
|
|
9865
|
+
console.log("");
|
|
9155
9866
|
}
|
|
9156
|
-
|
|
9157
|
-
|
|
9158
|
-
|
|
9159
|
-
|
|
9160
|
-
|
|
9161
|
-
|
|
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
|
|
9303
|
-
|
|
9304
|
-
|
|
9305
|
-
|
|
9306
|
-
|
|
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/
|
|
9310
|
-
async function
|
|
9311
|
-
|
|
9312
|
-
|
|
9313
|
-
|
|
9314
|
-
|
|
9315
|
-
|
|
9316
|
-
|
|
9317
|
-
|
|
9318
|
-
|
|
9319
|
-
|
|
9320
|
-
|
|
9321
|
-
|
|
9322
|
-
|
|
9323
|
-
|
|
9324
|
-
|
|
9325
|
-
|
|
9326
|
-
|
|
9327
|
-
|
|
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
|
-
|
|
9338
|
-
|
|
9339
|
-
|
|
9340
|
-
|
|
9341
|
-
|
|
9342
|
-
|
|
9343
|
-
|
|
9344
|
-
|
|
9345
|
-
|
|
9346
|
-
/
|
|
9347
|
-
/
|
|
9348
|
-
/
|
|
9349
|
-
|
|
9350
|
-
|
|
9351
|
-
|
|
9352
|
-
/
|
|
9353
|
-
/
|
|
9354
|
-
/
|
|
9355
|
-
/
|
|
9356
|
-
|
|
9357
|
-
|
|
9358
|
-
/
|
|
9359
|
-
/
|
|
9360
|
-
/
|
|
9361
|
-
/
|
|
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
|
|
9367
|
-
|
|
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
|
-
|
|
9370
|
-
|
|
9371
|
-
|
|
9372
|
-
|
|
9373
|
-
|
|
9374
|
-
|
|
9375
|
-
|
|
9376
|
-
|
|
9377
|
-
|
|
9378
|
-
const
|
|
9379
|
-
|
|
9380
|
-
|
|
9381
|
-
|
|
9382
|
-
|
|
9383
|
-
|
|
9384
|
-
|
|
9385
|
-
|
|
9386
|
-
|
|
9387
|
-
|
|
9388
|
-
|
|
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
|
-
|
|
9424
|
-
|
|
9425
|
-
|
|
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
|
-
|
|
9433
|
-
|
|
9434
|
-
|
|
9435
|
-
|
|
9436
|
-
|
|
9437
|
-
|
|
9438
|
-
|
|
9439
|
-
|
|
9440
|
-
|
|
9441
|
-
}
|
|
9442
|
-
|
|
9443
|
-
|
|
9444
|
-
|
|
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
|
|
9450
|
-
|
|
9451
|
-
|
|
9452
|
-
|
|
9453
|
-
|
|
9454
|
-
|
|
9455
|
-
return
|
|
9456
|
-
}
|
|
9457
|
-
|
|
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
|
-
|
|
9461
|
-
|
|
9462
|
-
|
|
9463
|
-
|
|
9464
|
-
|
|
9465
|
-
|
|
9466
|
-
|
|
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
|
-
|
|
9469
|
-
|
|
9470
|
-
|
|
9471
|
-
|
|
9472
|
-
|
|
9473
|
-
|
|
9474
|
-
|
|
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
|
-
|
|
9523
|
-
|
|
9524
|
-
|
|
9525
|
-
|
|
9526
|
-
|
|
9527
|
-
|
|
9528
|
-
|
|
9529
|
-
|
|
9530
|
-
|
|
9531
|
-
|
|
9532
|
-
|
|
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
|
-
|
|
9536
|
-
|
|
9537
|
-
|
|
9538
|
-
|
|
9539
|
-
|
|
9540
|
-
|
|
9541
|
-
|
|
9542
|
-
|
|
9543
|
-
|
|
9544
|
-
|
|
9545
|
-
|
|
9546
|
-
|
|
9547
|
-
|
|
9548
|
-
|
|
9549
|
-
|
|
9550
|
-
|
|
9551
|
-
|
|
9552
|
-
|
|
9553
|
-
|
|
9554
|
-
|
|
9555
|
-
|
|
9556
|
-
|
|
9557
|
-
|
|
9558
|
-
|
|
9559
|
-
|
|
9560
|
-
|
|
9561
|
-
|
|
9562
|
-
|
|
9563
|
-
|
|
9564
|
-
|
|
9565
|
-
|
|
9566
|
-
|
|
9567
|
-
|
|
9568
|
-
|
|
9569
|
-
|
|
9570
|
-
|
|
9571
|
-
|
|
9572
|
-
|
|
9573
|
-
|
|
9574
|
-
|
|
9575
|
-
|
|
9576
|
-
|
|
9577
|
-
|
|
9578
|
-
|
|
9579
|
-
|
|
9580
|
-
|
|
9581
|
-
|
|
9582
|
-
|
|
9583
|
-
|
|
9584
|
-
|
|
9585
|
-
|
|
9586
|
-
|
|
9587
|
-
|
|
9588
|
-
|
|
9589
|
-
|
|
9590
|
-
|
|
9591
|
-
|
|
9592
|
-
|
|
9593
|
-
|
|
9594
|
-
|
|
9595
|
-
|
|
9596
|
-
|
|
9597
|
-
|
|
9598
|
-
|
|
9599
|
-
|
|
9600
|
-
|
|
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
|
-
|
|
9619
|
-
|
|
9620
|
-
|
|
9621
|
-
|
|
9622
|
-
|
|
9623
|
-
|
|
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
|
-
|
|
9631
|
-
|
|
9632
|
-
|
|
9633
|
-
|
|
9634
|
-
|
|
9635
|
-
|
|
9636
|
-
|
|
9637
|
-
|
|
9638
|
-
|
|
9639
|
-
|
|
9640
|
-
|
|
9641
|
-
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 $ =
|
|
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
|
|
11146
|
-
import { join as
|
|
11147
|
-
import * as
|
|
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
|
|
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 $ =
|
|
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 =
|
|
12350
|
+
const cachePath = join10(cacheDir, `selectors-${host}.json`);
|
|
11276
12351
|
if (!opts.noCache && existsSync7(cachePath)) {
|
|
11277
12352
|
try {
|
|
11278
|
-
const cached = JSON.parse(
|
|
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:
|
|
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
|
-
|
|
11315
|
-
|
|
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(
|
|
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(
|
|
12449
|
+
console.log(chalk11.bold(`
|
|
11375
12450
|
parity journey ${runId}`));
|
|
11376
|
-
console.log(
|
|
11377
|
-
console.log(
|
|
11378
|
-
console.log(
|
|
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(
|
|
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" ?
|
|
11425
|
-
const prefix = ` ${
|
|
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} ${
|
|
12502
|
+
console.log(`${prefix} ${chalk11.dim(`${event.index}/${event.total}`)} ▶ ${chalk11.bold(stepLabel(event.name))}`);
|
|
11428
12503
|
} else {
|
|
11429
|
-
const glyph = event.status === "ok" ?
|
|
11430
|
-
const noteText = event.note ?
|
|
11431
|
-
const time =
|
|
11432
|
-
console.log(`${prefix} ${
|
|
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 =
|
|
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(
|
|
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(
|
|
12581
|
+
console.log(chalk11.dim(` learned-selectors atualizado: ${bits.join(", ")}`));
|
|
11507
12582
|
}
|
|
11508
12583
|
} catch (err) {
|
|
11509
12584
|
if (!opts.json) {
|
|
11510
|
-
console.warn(
|
|
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
|
-
|
|
12604
|
+
writeFileSync9(opts.junit, buildJUnit(rows, failures), "utf8");
|
|
11530
12605
|
if (!opts.json)
|
|
11531
|
-
console.log(
|
|
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(
|
|
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" ?
|
|
11603
|
-
const note = r.cand?.status === "failed" || r.cand?.status === "skipped" ?
|
|
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
|
|
12686
|
+
return chalk11.dim("—");
|
|
11612
12687
|
if (status === "ok")
|
|
11613
|
-
return
|
|
12688
|
+
return chalk11.green("✓");
|
|
11614
12689
|
if (status === "skipped")
|
|
11615
|
-
return
|
|
11616
|
-
return
|
|
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(
|
|
12702
|
+
console.log(chalk11.bold(" Summary:"));
|
|
11628
12703
|
for (const [vp, { passed, total }] of byViewport) {
|
|
11629
|
-
const stat = 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(
|
|
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 ?
|
|
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(
|
|
12717
|
+
console.log(chalk11.green(" ✓ jornada completa em cand"));
|
|
11643
12718
|
}
|
|
11644
12719
|
console.log("");
|
|
11645
|
-
console.log(
|
|
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
|
|
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
|
|
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(
|
|
11886
|
-
console.log(
|
|
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(
|
|
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(`${
|
|
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(` ${
|
|
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
|
|
11905
|
-
import { join as
|
|
11906
|
-
import
|
|
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(
|
|
12989
|
+
console.log(chalk13.bold(`
|
|
11915
12990
|
parity vitals ${runId}`));
|
|
11916
|
-
console.log(
|
|
11917
|
-
console.log(
|
|
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" ?
|
|
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
|
|
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
|
-
|
|
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(
|
|
12023
|
-
console.log(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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" ?
|
|
12169
|
-
console.log(` ${
|
|
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(` ${
|
|
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
|
|
12179
|
-
import
|
|
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(
|
|
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
|
-
|
|
12194
|
-
console.log(
|
|
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(
|
|
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
|
|
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(
|
|
13285
|
+
console.log(chalk16.dim(`opening ${paths.reportHtml}`));
|
|
12211
13286
|
await open(paths.reportHtml).catch((err) => {
|
|
12212
|
-
console.error(
|
|
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
|
|
12219
|
-
import
|
|
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
|
|
12439
|
-
return
|
|
13513
|
+
const verdict2 = await llmMatch(prod, cand);
|
|
13514
|
+
return verdict2 ?? "similar";
|
|
12440
13515
|
}
|
|
12441
13516
|
if (sim >= 0.6) {
|
|
12442
|
-
const
|
|
12443
|
-
return
|
|
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
|
|
12505
|
-
if (
|
|
12506
|
-
return
|
|
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
|
|
13588
|
+
import chalk17 from "chalk";
|
|
12514
13589
|
|
|
12515
13590
|
// src/server/proxy-server.ts
|
|
12516
|
-
import { existsSync as existsSync10, readFileSync as
|
|
13591
|
+
import { existsSync as existsSync10, readFileSync as readFileSync12, statSync } from "node:fs";
|
|
12517
13592
|
import { createServer } from "node:http";
|
|
12518
|
-
import { extname, join as
|
|
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 =
|
|
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((
|
|
13641
|
+
await new Promise((resolve3, reject) => {
|
|
12567
13642
|
server.once("error", reject);
|
|
12568
|
-
server.listen(opts.port ?? 0, opts.host ?? "127.0.0.1", () =>
|
|
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((
|
|
13651
|
+
close: () => new Promise((resolve3) => {
|
|
12577
13652
|
try {
|
|
12578
13653
|
server.closeAllConnections?.();
|
|
12579
13654
|
server.closeIdleConnections?.();
|
|
12580
13655
|
} catch {}
|
|
12581
|
-
server.close(() =>
|
|
12582
|
-
setTimeout(() =>
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
12739
|
-
console.log(` ${
|
|
12740
|
-
console.log(
|
|
12741
|
-
console.log(
|
|
12742
|
-
console.log(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
13925
|
+
console.error(chalk18.red(`
|
|
12851
13926
|
✖ pre-flight falhou:`));
|
|
12852
13927
|
for (const err of preflight.errors)
|
|
12853
|
-
console.error(
|
|
12854
|
-
console.error(
|
|
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(
|
|
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(
|
|
13982
|
+
console.log(chalk18.bold(`
|
|
12908
13983
|
parity run ${runId}`));
|
|
12909
|
-
console.log(
|
|
12910
|
-
console.log(
|
|
12911
|
-
console.log(
|
|
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 =
|
|
12941
|
-
const tracePath =
|
|
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
|
|
12987
|
-
if (
|
|
12988
|
-
spinner.warn(`PDP cross-site matcher: '${
|
|
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
|
-
|
|
13315
|
-
|
|
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
|
-
|
|
13363
|
-
|
|
13364
|
-
|
|
13365
|
-
|
|
13366
|
-
|
|
13367
|
-
|
|
13368
|
-
|
|
13369
|
-
|
|
13370
|
-
|
|
13371
|
-
|
|
13372
|
-
|
|
13373
|
-
|
|
13374
|
-
|
|
13375
|
-
|
|
13376
|
-
|
|
13377
|
-
|
|
13378
|
-
|
|
13379
|
-
|
|
13380
|
-
|
|
13381
|
-
|
|
13382
|
-
|
|
13383
|
-
|
|
13384
|
-
|
|
13385
|
-
|
|
13386
|
-
|
|
13387
|
-
|
|
13388
|
-
|
|
13389
|
-
|
|
13390
|
-
|
|
13391
|
-
|
|
13392
|
-
|
|
13393
|
-
|
|
13394
|
-
|
|
13395
|
-
|
|
13396
|
-
|
|
13397
|
-
|
|
13398
|
-
|
|
13399
|
-
|
|
13400
|
-
|
|
13401
|
-
|
|
13402
|
-
|
|
13403
|
-
|
|
13404
|
-
|
|
13405
|
-
|
|
13406
|
-
|
|
13407
|
-
|
|
13408
|
-
|
|
13409
|
-
|
|
13410
|
-
|
|
13411
|
-
|
|
13412
|
-
|
|
13413
|
-
|
|
13414
|
-
|
|
13415
|
-
|
|
13416
|
-
|
|
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
|
-
|
|
13420
|
-
|
|
13421
|
-
|
|
13422
|
-
|
|
13423
|
-
|
|
13424
|
-
|
|
13425
|
-
|
|
13426
|
-
|
|
13427
|
-
|
|
13428
|
-
|
|
13429
|
-
|
|
13430
|
-
|
|
13431
|
-
|
|
13432
|
-
|
|
13433
|
-
|
|
13434
|
-
|
|
13435
|
-
|
|
13436
|
-
|
|
13437
|
-
|
|
13438
|
-
|
|
13439
|
-
|
|
13440
|
-
|
|
13441
|
-
|
|
13442
|
-
|
|
13443
|
-
|
|
13444
|
-
|
|
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
|
|
13490
|
-
|
|
13491
|
-
|
|
13492
|
-
|
|
13493
|
-
|
|
13494
|
-
|
|
13495
|
-
|
|
13496
|
-
|
|
13497
|
-
|
|
13498
|
-
|
|
13499
|
-
|
|
13500
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
13510
|
-
|
|
13511
|
-
|
|
13512
|
-
|
|
13513
|
-
|
|
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
|
-
|
|
13517
|
-
|
|
13518
|
-
|
|
13519
|
-
|
|
13520
|
-
|
|
13521
|
-
|
|
13522
|
-
|
|
13523
|
-
|
|
13524
|
-
|
|
13525
|
-
|
|
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
|
-
|
|
13591
|
-
|
|
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
|
-
|
|
13594
|
-
|
|
13595
|
-
|
|
14327
|
+
if (browser)
|
|
14328
|
+
await browser.close().catch(() => {
|
|
14329
|
+
return;
|
|
14330
|
+
});
|
|
13596
14331
|
}
|
|
13597
14332
|
}
|
|
13598
|
-
|
|
13599
|
-
const
|
|
13600
|
-
const
|
|
13601
|
-
|
|
13602
|
-
|
|
13603
|
-
|
|
13604
|
-
|
|
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
|
-
|
|
13607
|
-
|
|
13608
|
-
|
|
13609
|
-
|
|
13610
|
-
|
|
13611
|
-
|
|
13612
|
-
|
|
13613
|
-
|
|
13614
|
-
|
|
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
|
-
|
|
13634
|
-
|
|
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
|
-
|
|
13637
|
-
|
|
13638
|
-
|
|
13639
|
-
|
|
13640
|
-
|
|
13641
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14409
|
+
function addCacheBuster(url) {
|
|
13655
14410
|
try {
|
|
13656
|
-
const
|
|
13657
|
-
|
|
13658
|
-
|
|
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
|
|
14442
|
+
async function fetchHomeHtml(url, viewport = "desktop") {
|
|
13670
14443
|
try {
|
|
13671
|
-
|
|
13672
|
-
|
|
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
|
|
14456
|
+
return null;
|
|
13675
14457
|
}
|
|
13676
14458
|
}
|
|
13677
|
-
function
|
|
13678
|
-
|
|
13679
|
-
|
|
13680
|
-
|
|
13681
|
-
|
|
13682
|
-
|
|
13683
|
-
|
|
13684
|
-
|
|
13685
|
-
|
|
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
|
|
14469
|
+
return;
|
|
13689
14470
|
}
|
|
13690
|
-
function
|
|
13691
|
-
|
|
13692
|
-
return
|
|
13693
|
-
|
|
13694
|
-
|
|
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
|
-
|
|
13716
|
-
const {
|
|
13717
|
-
|
|
13718
|
-
|
|
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
|
-
|
|
13725
|
-
|
|
13726
|
-
|
|
13727
|
-
|
|
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
|
-
|
|
13750
|
-
|
|
13751
|
-
|
|
13752
|
-
|
|
13753
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
}));
|