@mmerterden/dev-toolkit-mcp 2.24.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/CHANGELOG.md +708 -0
- package/LICENSE +21 -0
- package/README.md +387 -0
- package/index.js +1277 -0
- package/package.json +80 -0
- package/tools/design-check/content-cardinality.js +204 -0
- package/tools/design-check/geometry.js +140 -0
- package/tools/design-check/index.js +213 -0
- package/tools/design-check/mock-detect.js +213 -0
- package/tools/design-check/report.js +590 -0
- package/tools/design-check/scan.js +91 -0
- package/tools/design-check/scenario-inventory.js +598 -0
- package/tools/design-check/visual-compare.js +961 -0
- package/tools/ios-app-store-audit/context.js +180 -0
- package/tools/ios-app-store-audit/data/apple-required-sdks.json +32 -0
- package/tools/ios-app-store-audit/data/debug-tools-blocklist.json +133 -0
- package/tools/ios-app-store-audit/index.js +164 -0
- package/tools/ios-app-store-audit/models.js +47 -0
- package/tools/ios-app-store-audit/rules/asset-validation.js +72 -0
- package/tools/ios-app-store-audit/rules/binary-size.js +70 -0
- package/tools/ios-app-store-audit/rules/code-signing.js +95 -0
- package/tools/ios-app-store-audit/rules/dead-reference.js +131 -0
- package/tools/ios-app-store-audit/rules/debug-tool-leak.js +185 -0
- package/tools/ios-app-store-audit/rules/duplicate-resource.js +130 -0
- package/tools/ios-app-store-audit/rules/embedded-sdk.js +126 -0
- package/tools/ios-app-store-audit/rules/entitlement.js +105 -0
- package/tools/ios-app-store-audit/rules/extension-signing.js +105 -0
- package/tools/ios-app-store-audit/rules/info-plist.js +158 -0
- package/tools/ios-app-store-audit/rules/ipv6-compliance.js +101 -0
- package/tools/ios-app-store-audit/rules/privacy-manifest.js +121 -0
- package/tools/ios-app-store-audit/rules/production-hygiene.js +237 -0
- package/tools/ios-app-store-audit/rules/provisioning-profile.js +127 -0
- package/tools/ios-app-store-audit/rules/required-reason-api.js +123 -0
- package/tools/ios-app-store-audit/rules/sdk-floor.js +104 -0
- package/tools/ios-app-store-audit/rules/swift-abi.js +64 -0
- package/tools/ios-app-store-audit/rules/team-id.js +62 -0
- package/tools/ios-testflight/index.js +479 -0
- package/ui-tree-dumper.swift +122 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design_report - render a design-audit report into a self-contained HTML file
|
|
3
|
+
* (base64-embedded images) and optionally a PDF (via Playwright if present).
|
|
4
|
+
*
|
|
5
|
+
* Presentation:
|
|
6
|
+
* - Per screen: Figma (expected) on the left, Live (actual) on the right with
|
|
7
|
+
* SVG arrow callouts pointing at each deviation and its "expected → actual".
|
|
8
|
+
* - Findings grouped by category (Localization/Copy, Component, Padding,
|
|
9
|
+
* Font Size, Font Color, Spacing, Size). Verified (on-spec) checks are listed
|
|
10
|
+
* as passing so the report shows what was measured, not only what failed.
|
|
11
|
+
* - Localization/copy deltas are compared against the design's source-of-truth
|
|
12
|
+
* UX-writing, never against a stale frame render.
|
|
13
|
+
* - A coverage gate: audited screens are weighed against the run's declared
|
|
14
|
+
* target set, and every target that was neither audited nor skipped WITH A
|
|
15
|
+
* REASON fails the gate. An audit that quietly visits a third of the screens
|
|
16
|
+
* must not read as a clean report.
|
|
17
|
+
*
|
|
18
|
+
* Generic: the report object is platform/project-agnostic, and all human-facing
|
|
19
|
+
* wording comes from a label pack (English by default) so the engine carries no
|
|
20
|
+
* single natural language.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
24
|
+
import { join } from "path";
|
|
25
|
+
|
|
26
|
+
const LABEL_PACKS = {
|
|
27
|
+
en: {
|
|
28
|
+
htmlLang: "en",
|
|
29
|
+
reportTitle: "Design Check Report",
|
|
30
|
+
moduleWord: "module",
|
|
31
|
+
thScreen: "Screen", thPerceptual: "Perceptual", thDeviations: "Deviations", thStatus: "Status",
|
|
32
|
+
statusCaptured: "CAPTURED", statusDeviation: "DEVIATION", statusPass: "PASS", statusReview: "REVIEW",
|
|
33
|
+
screensWord: "screens", deviationsWord: "deviations", advisoryWord: "ADVISORY (not counted)",
|
|
34
|
+
awaitingFigma: "captured · awaiting Figma mapping",
|
|
35
|
+
noAutoFindings: "visual comparison · no automatic element findings",
|
|
36
|
+
noDeviations: "No deviations.",
|
|
37
|
+
verifiedOnSpec: "VERIFIED · on-spec",
|
|
38
|
+
expectedWord: "expected", actualWord: "actual",
|
|
39
|
+
affectedComponents: "Affected components",
|
|
40
|
+
mainComponent: "Main component",
|
|
41
|
+
variantsWord: "variants",
|
|
42
|
+
toggleHint: "(show/hide · click → Figma)",
|
|
43
|
+
noPreview: "No preview captured.",
|
|
44
|
+
fixPromptTitle: "Developer prompt (paste into an agent)",
|
|
45
|
+
figmaCol: "FIGMA · expected", liveCol: "LIVE · actual",
|
|
46
|
+
coverageTitle: "Coverage",
|
|
47
|
+
coverageAudited: "audited", coverageTargets: "targets",
|
|
48
|
+
coverageSkipped: "Skipped with a reason", coverageUnaccounted: "Not audited, no reason given",
|
|
49
|
+
coverageGatePass: "COVERAGE GATE PASSED",
|
|
50
|
+
coverageGateFail: "COVERAGE GATE FAILED",
|
|
51
|
+
coverageGateHint: "every target must be audited or skipped with an explicit reason",
|
|
52
|
+
coverageNoReason: "no reason given",
|
|
53
|
+
coverageUndeclared: "target undeclared by the run",
|
|
54
|
+
coverageTruncated: "The scenario inventory hit its cap, so the target set is incomplete and this percentage is measured against a partial denominator.",
|
|
55
|
+
coverageBelowFloor: "below the required coverage floor",
|
|
56
|
+
footerNote: "dev-toolkit-mcp design-check · copy is compared against the design's source-of-truth UX writing, not a stale frame render; localization differences are not counted as defects.",
|
|
57
|
+
},
|
|
58
|
+
tr: {
|
|
59
|
+
htmlLang: "tr",
|
|
60
|
+
reportTitle: "Design Check Raporu",
|
|
61
|
+
moduleWord: "modül",
|
|
62
|
+
thScreen: "Ekran", thPerceptual: "Perceptual", thDeviations: "Sapma", thStatus: "Durum",
|
|
63
|
+
statusCaptured: "YAKALANDI", statusDeviation: "SAPMA", statusPass: "PASS", statusReview: "İNCELE",
|
|
64
|
+
screensWord: "ekran", deviationsWord: "sapma", advisoryWord: "BİLGİ AMAÇLI (sayılmıyor)",
|
|
65
|
+
awaitingFigma: "yakalandı · Figma eşlemesi bekliyor",
|
|
66
|
+
noAutoFindings: "görsel karşılaştırma · otomatik element bulgusu yok",
|
|
67
|
+
noDeviations: "Sapma yok.",
|
|
68
|
+
verifiedOnSpec: "DOĞRULANDI · on-spec",
|
|
69
|
+
expectedWord: "olması gereken", actualWord: "mevcut",
|
|
70
|
+
affectedComponents: "Etkilenen componentler",
|
|
71
|
+
mainComponent: "Main component",
|
|
72
|
+
variantsWord: "varyant",
|
|
73
|
+
toggleHint: "(göster/gizle · tıkla → Figma)",
|
|
74
|
+
noPreview: "Önizleme çekilmedi.",
|
|
75
|
+
fixPromptTitle: "Geliştirme promptu (ajana yapıştır)",
|
|
76
|
+
figmaCol: "FIGMA · olması gereken", liveCol: "CANLI · mevcut",
|
|
77
|
+
coverageTitle: "Kapsam",
|
|
78
|
+
coverageAudited: "denetlendi", coverageTargets: "hedef",
|
|
79
|
+
coverageSkipped: "Gerekçeli atlanan", coverageUnaccounted: "Denetlenmedi, gerekçe yok",
|
|
80
|
+
coverageGatePass: "KAPSAM GEÇİDİ GEÇTİ",
|
|
81
|
+
coverageGateFail: "KAPSAM GEÇİDİ BAŞARISIZ",
|
|
82
|
+
coverageGateHint: "her hedef ya denetlenmeli ya da açık gerekçeyle atlanmalı",
|
|
83
|
+
coverageNoReason: "gerekçe verilmedi",
|
|
84
|
+
coverageUndeclared: "hedef koşuda hiç bildirilmedi",
|
|
85
|
+
coverageTruncated: "Senaryo envanteri üst sınıra takıldı; hedef kümesi eksik ve bu yüzde kısmi bir payda üzerinden hesaplandı.",
|
|
86
|
+
coverageBelowFloor: "istenen kapsam tabanının altında",
|
|
87
|
+
footerNote: "dev-toolkit-mcp design-check · metin karşılaştırması tasarımın kaynak-doğru UX-writing'ine göre yapılır (bayat frame render'ına değil); localization farkları hata sayılmaz.",
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
function labels(report) {
|
|
92
|
+
const pack = LABEL_PACKS[report.lang] || LABEL_PACKS.en;
|
|
93
|
+
return { ...LABEL_PACKS.en, ...pack, ...(report.labels || {}) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function dataUri(path) {
|
|
97
|
+
if (!path || !existsSync(path)) return "";
|
|
98
|
+
return `data:image/png;base64,${readFileSync(path).toString("base64")}`;
|
|
99
|
+
}
|
|
100
|
+
function esc(s) {
|
|
101
|
+
return String(s == null ? "" : s).replace(/[&<>"']/g, (c) => (
|
|
102
|
+
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]
|
|
103
|
+
));
|
|
104
|
+
}
|
|
105
|
+
function parseSize(s) { const m = /^(\d+)x(\d+)$/.exec(s || ""); return m ? { w: +m[1], h: +m[2] } : { w: 375, h: 812 }; }
|
|
106
|
+
function figmaNodeUrl(fileKey, node) {
|
|
107
|
+
if (!fileKey || !node) return null;
|
|
108
|
+
return `https://www.figma.com/design/${fileKey}/?node-id=${String(node).replace(":", "-")}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The file a finding's component actually lives in.
|
|
113
|
+
*
|
|
114
|
+
* Components are NOT all in the audited screen's file. Observed on a real module:
|
|
115
|
+
*
|
|
116
|
+
* CardsFlightCardsCheckinCheckinStart -> 9ipBvrtsq9HlQ175B9k0wx
|
|
117
|
+
* CheckinBoardingPassLayout -> ps4xrJzhn7WNbfkZs895Bi
|
|
118
|
+
*
|
|
119
|
+
* Every component link was built from the report-level `figmaFileKey`, so a
|
|
120
|
+
* component from a second library file got a URL into the wrong file. That link
|
|
121
|
+
* still opens - it just resolves to nothing - which is worse than no link,
|
|
122
|
+
* because the reader concludes the node was deleted. Prefer the per-finding key;
|
|
123
|
+
* fall back to the report-level one, which is correct for the same-file case.
|
|
124
|
+
*/
|
|
125
|
+
function componentFileKey(finding, reportFileKey) {
|
|
126
|
+
return (finding && finding.componentFileKey) || reportFileKey;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Normalize whatever the caller passed as `coverage` into a verdict.
|
|
131
|
+
*
|
|
132
|
+
* Accepted shapes:
|
|
133
|
+
* - { targets: <n>|[ids], covered: <n>|[ids], skipped: [{id,group?,reason}], floor?: 0..1 }
|
|
134
|
+
* - legacy: [ { group, items:[...] } ] → every item counts as an unaccounted
|
|
135
|
+
* target, because the legacy shape carries no per-item reason.
|
|
136
|
+
*
|
|
137
|
+
* Gate: nothing may vanish. Every target is audited, or skipped with a reason.
|
|
138
|
+
*/
|
|
139
|
+
const idOf = (x) => (typeof x === "string" ? x : (x && x.id ? String(x.id) : null));
|
|
140
|
+
const tally = (list) => list.reduce((n, e) => n + (e.count || 1), 0);
|
|
141
|
+
|
|
142
|
+
export function coverageVerdict(coverage, auditedCount) {
|
|
143
|
+
if (!coverage) return null;
|
|
144
|
+
const count = (x) => (Array.isArray(x) ? x.length : (Number.isFinite(x) ? x : 0));
|
|
145
|
+
|
|
146
|
+
if (Array.isArray(coverage)) {
|
|
147
|
+
const unaccounted = coverage.flatMap((g) => (g.items || []).map((it) => ({
|
|
148
|
+
id: String(it), group: g.group, reason: "",
|
|
149
|
+
})));
|
|
150
|
+
const target = auditedCount + unaccounted.length;
|
|
151
|
+
return {
|
|
152
|
+
target, audited: auditedCount, skipped: [], unaccounted,
|
|
153
|
+
unaccountedCount: unaccounted.length,
|
|
154
|
+
pct: target ? auditedCount / target : 1,
|
|
155
|
+
gate: unaccounted.length ? "fail" : "pass",
|
|
156
|
+
floor: null, belowFloor: false, legacyShape: true,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const target = count(coverage.targets) || (auditedCount + count(coverage.skipped));
|
|
161
|
+
// An explicitly empty `covered` means nothing was audited - honour it instead of
|
|
162
|
+
// falling back to the variant count, which would credit a run for screens it
|
|
163
|
+
// never claimed to have covered.
|
|
164
|
+
const audited = "covered" in coverage ? count(coverage.covered) : auditedCount;
|
|
165
|
+
const skippedRaw = coverage.skipped || [];
|
|
166
|
+
const skipped = [], unaccounted = [];
|
|
167
|
+
for (const s of skippedRaw) {
|
|
168
|
+
const entry = typeof s === "string" ? { id: s, reason: "" } : { id: s.id, group: s.group, reason: s.reason || "" };
|
|
169
|
+
(entry.reason ? skipped : unaccounted).push(entry);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Targets that appear in neither list are the silent drops this gate exists for.
|
|
173
|
+
// When the caller passes id arrays they get named; with bare counts the best the
|
|
174
|
+
// report can say is how many went undeclared.
|
|
175
|
+
const accountedIds = new Set([
|
|
176
|
+
...(Array.isArray(coverage.covered) ? coverage.covered.map(idOf) : []),
|
|
177
|
+
...skipped.map((e) => e.id), ...unaccounted.map((e) => e.id),
|
|
178
|
+
].filter(Boolean));
|
|
179
|
+
if (Array.isArray(coverage.targets)) {
|
|
180
|
+
for (const t of coverage.targets) {
|
|
181
|
+
const id = idOf(t);
|
|
182
|
+
if (id && !accountedIds.has(id)) {
|
|
183
|
+
unaccounted.push({ id, group: (t && t.screen) || (t && t.group) || null, reason: "" });
|
|
184
|
+
accountedIds.add(id);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
const missing = Math.max(0, target - (audited + skipped.length + unaccounted.length));
|
|
189
|
+
if (missing) unaccounted.push({ id: null, group: null, reason: "", count: missing });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Clamped: a caller that reports covered ids outside the target set must not
|
|
193
|
+
// render a >100% bar.
|
|
194
|
+
const pct = target ? Math.min(1, Math.max(0, audited / target)) : 1;
|
|
195
|
+
const floor = Number.isFinite(coverage.floor) ? coverage.floor : null;
|
|
196
|
+
const belowFloor = floor != null && pct < floor;
|
|
197
|
+
const unaccountedCount = tally(unaccounted);
|
|
198
|
+
// A truncated inventory means the denominator itself is incomplete, so no
|
|
199
|
+
// percentage over it can be trusted - that is a gate failure, not a footnote.
|
|
200
|
+
const truncatedInventory = !!coverage.truncatedInventory;
|
|
201
|
+
return {
|
|
202
|
+
target, audited, skipped, unaccounted, unaccountedCount, pct, floor, belowFloor,
|
|
203
|
+
truncatedInventory,
|
|
204
|
+
gate: unaccountedCount || belowFloor || truncatedInventory ? "fail" : "pass",
|
|
205
|
+
legacyShape: false,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const CAT_COLOR = {
|
|
210
|
+
"localization": "#E31837", "copy": "#E31837",
|
|
211
|
+
"component": "#B5179E", "icon": "#B5179E",
|
|
212
|
+
"padding": "#3A0CA3", "spacing": "#3A0CA3", "size": "#3A0CA3", "position": "#3A0CA3",
|
|
213
|
+
"font size": "#F77F00", "typography": "#F77F00",
|
|
214
|
+
"font color": "#0A7D33", "color": "#0A7D33",
|
|
215
|
+
};
|
|
216
|
+
const catColor = (c) => CAT_COLOR[(c || "").toLowerCase()] || "#555";
|
|
217
|
+
|
|
218
|
+
// Polished annotation (Zeplin/Figma-inspect style): Figma + Live screenshots with
|
|
219
|
+
// numbered pins, dashed leader lines, and dark callout cards stacked on the right
|
|
220
|
+
// carrying the category chip, element, and expected → actual. Pure static SVG so it
|
|
221
|
+
// renders identically in HTML, PDF, and Confluence.
|
|
222
|
+
function twoUp(v, fileKey, L) {
|
|
223
|
+
const size = parseSize(v.compareSize); // figma/compare space (e.g. 375x812)
|
|
224
|
+
const lsz = parseSize(v.liveSize || v.compareSize); // live NATIVE aspect (undistorted)
|
|
225
|
+
const LW = 250;
|
|
226
|
+
const LHf = Math.round(LW * size.h / size.w); // figma column height
|
|
227
|
+
const LHl = Math.round(LW * lsz.h / lsz.w); // live column height (own aspect)
|
|
228
|
+
const LH = Math.max(LHf, LHl);
|
|
229
|
+
const imgY = 22, gap = 34;
|
|
230
|
+
const hasFigma = !!(v.images && v.images.figma);
|
|
231
|
+
const liveX = hasFigma ? LW + gap : 0, liveRight = liveX + LW;
|
|
232
|
+
const cardX = liveRight + 78, CW = 440, CH = 120;
|
|
233
|
+
const devs = (v.findings || []).filter((f) => (f.status || "deviation") !== "verified" && !f.advisory && f.region);
|
|
234
|
+
const rowH = CH + 16, top = 24;
|
|
235
|
+
const svgH = Math.max(LH + imgY + 20, top + devs.length * rowH + 20);
|
|
236
|
+
const totalW = cardX + CW + 20;
|
|
237
|
+
|
|
238
|
+
let pins = "", leaders = "", cards = "";
|
|
239
|
+
devs.forEach((f, i) => {
|
|
240
|
+
const c = catColor(f.category || f.type);
|
|
241
|
+
const rx = (f.region.x + f.region.w / 2) / size.w * LW;
|
|
242
|
+
const ry = (f.region.y + f.region.h / 2) / size.h * LHl; // pins on the live column (own height)
|
|
243
|
+
const pinX = liveX + rx, pinY = imgY + ry;
|
|
244
|
+
const cy = top + i * rowH + CH / 2;
|
|
245
|
+
const cardTop = cy - CH / 2;
|
|
246
|
+
const cat = (f.category || f.type || "").toUpperCase();
|
|
247
|
+
// dashed leader: pin -> elbow -> card left edge
|
|
248
|
+
const midX = (pinX + cardX) / 2;
|
|
249
|
+
leaders += `<path d="M ${pinX.toFixed(0)} ${pinY.toFixed(0)} L ${midX.toFixed(0)} ${pinY.toFixed(0)} L ${midX.toFixed(0)} ${cy.toFixed(0)} L ${cardX.toFixed(0)} ${cy.toFixed(0)}" fill="none" stroke="${c}" stroke-width="1" stroke-dasharray="4 3" opacity="0.75"/>`;
|
|
250
|
+
pins += `<circle cx="${pinX.toFixed(0)}" cy="${pinY.toFixed(0)}" r="8" fill="${c}" stroke="#fff" stroke-width="1.5"/><text x="${pinX.toFixed(0)}" y="${(pinY + 3.5).toFixed(0)}" text-anchor="middle" font-size="10" fill="#fff" font-weight="700">${i + 1}</text>`;
|
|
251
|
+
const u = f.component ? figmaNodeUrl(componentFileKey(f, fileKey), f.componentNode) : null;
|
|
252
|
+
const compHtml = f.component
|
|
253
|
+
? `<div style="font-size:11px;color:#7A5AF8;font-weight:600;margin-bottom:3px">◈ ${u ? `<a href="${esc(u)}" target="_blank" style="color:#7A5AF8;text-decoration:none">${esc(f.component)} ↗</a>` : esc(f.component)}</div>`
|
|
254
|
+
: "";
|
|
255
|
+
// HTML card inside a foreignObject → text wraps naturally (no truncation)
|
|
256
|
+
cards += `<foreignObject x="${cardX}" y="${cardTop}" width="${CW}" height="${CH}">
|
|
257
|
+
<div xmlns="http://www.w3.org/1999/xhtml" style="box-sizing:border-box;width:100%;font-family:-apple-system,Segoe UI,Roboto,sans-serif;background:#fff;border:1.5px solid #1a1a1a;border-radius:12px;padding:9px 12px;overflow:hidden">
|
|
258
|
+
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:5px">
|
|
259
|
+
<span style="background:${c};color:#fff;font-size:10px;font-weight:700;min-width:18px;height:18px;border-radius:9px;display:inline-flex;align-items:center;justify-content:center">${i + 1}</span>
|
|
260
|
+
<span style="background:${c};color:#fff;font-size:10px;font-weight:700;padding:2px 7px;border-radius:9px">${esc(cat)}</span>
|
|
261
|
+
<span style="font-size:12.5px;font-weight:600;color:#111">${esc(f.element || "")}</span>
|
|
262
|
+
</div>
|
|
263
|
+
${compHtml}
|
|
264
|
+
<div style="font-size:12px;line-height:1.35"><span style="color:#8a8f98">${esc(L.expectedWord)} </span><span style="color:#0A7D33;font-weight:700">${esc(f.expected ?? "-")}</span></div>
|
|
265
|
+
<div style="font-size:12px;line-height:1.35"><span style="color:#8a8f98">${esc(L.actualWord)} </span><span style="color:#cf222e;font-weight:700">${esc(f.actual ?? "-")}</span></div>
|
|
266
|
+
</div>
|
|
267
|
+
</foreignObject>`;
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
return `<div class="twoup"><svg viewBox="0 0 ${totalW} ${svgH}" width="100%" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,Segoe UI,Roboto,sans-serif">
|
|
271
|
+
${hasFigma ? `<text x="0" y="15" font-size="11" font-weight="700" fill="#666">${esc(L.figmaCol)}</text>
|
|
272
|
+
<image href="${dataUri(v.images.figma)}" x="0" y="${imgY}" width="${LW}" height="${LHf}" preserveAspectRatio="xMidYMin meet"/>
|
|
273
|
+
<rect x="0" y="${imgY}" width="${LW}" height="${LHf}" fill="none" stroke="#e5e5e5"/>` : ""}
|
|
274
|
+
<text x="${liveX}" y="15" font-size="11" font-weight="700" fill="#666">${esc(L.liveCol)}</text>
|
|
275
|
+
<image href="${dataUri(v.images?.live)}" x="${liveX}" y="${imgY}" width="${LW}" height="${LHl}" preserveAspectRatio="xMidYMin meet"/>
|
|
276
|
+
<rect x="${liveX}" y="${imgY}" width="${LW}" height="${LHl}" fill="none" stroke="#e5e5e5"/>
|
|
277
|
+
${leaders}${pins}${cards}
|
|
278
|
+
</svg></div>`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function groupFindings(findings) {
|
|
282
|
+
const groups = {};
|
|
283
|
+
for (const f of findings || []) {
|
|
284
|
+
const cat = (f.category || f.type || "other");
|
|
285
|
+
(groups[cat] = groups[cat] || []).push(f);
|
|
286
|
+
}
|
|
287
|
+
return groups;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function findingLine(f, i, L) {
|
|
291
|
+
const c = catColor(f.category || f.type);
|
|
292
|
+
const verified = (f.status === "verified");
|
|
293
|
+
return `<li class="finding ${verified ? "ok" : "bad"}">
|
|
294
|
+
<span class="mark" style="color:${verified ? "#0A7D33" : c}">${verified ? "✓" : (i != null ? i + 1 : "•")}</span>
|
|
295
|
+
<span class="fel">${esc(f.element || "")}</span>
|
|
296
|
+
${!verified && (f.expected != null || f.actual != null)
|
|
297
|
+
? `<span class="exp">${esc(L.expectedWord)} <code>${esc(f.expected)}</code> <b>→</b> ${esc(L.actualWord)} <code>${esc(f.actual)}</code></span>`
|
|
298
|
+
: ""}
|
|
299
|
+
${/* the detail carries the measured delta and its unit - the actionable part,
|
|
300
|
+
so keep it even when expected → actual is shown */ ""}
|
|
301
|
+
${f.detail ? `<span class="detail">${esc(f.detail)}</span>` : (!verified && f.expected == null ? `<span class="detail">on-spec</span>` : "")}
|
|
302
|
+
</li>`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Expandable "main component + all its variants" blocks (click to reveal).
|
|
306
|
+
function componentsBlock(v, fileKey, L) {
|
|
307
|
+
const seen = new Map();
|
|
308
|
+
for (const f of v.findings || []) {
|
|
309
|
+
if (f.component && !seen.has(f.component)) seen.set(f.component, f);
|
|
310
|
+
}
|
|
311
|
+
if (!seen.size) return "";
|
|
312
|
+
const blocks = [...seen.values()].map((f) => {
|
|
313
|
+
const variants = f.componentVariants || [];
|
|
314
|
+
// A variant may name its own file too (a variant pulled from a different
|
|
315
|
+
// library than its parent set), so resolve per-variant and fall back to the
|
|
316
|
+
// component's file, then the report's.
|
|
317
|
+
const compKey = componentFileKey(f, fileKey);
|
|
318
|
+
const url = figmaNodeUrl(compKey, f.componentNode);
|
|
319
|
+
const thumbs = variants.length
|
|
320
|
+
? `<div class="cvgrid">${variants.map((cv) => `<figure>${cv.node && url ? `<a href="${esc(figmaNodeUrl(cv.fileKey || compKey, cv.node))}" target="_blank">` : ""}<img src="${dataUri(cv.image)}" alt="">${cv.node && url ? "</a>" : ""}<figcaption>${esc(cv.name)}</figcaption></figure>`).join("")}</div>`
|
|
321
|
+
: `<p class="cvnote">${esc(L.noPreview)}</p>`;
|
|
322
|
+
const name = url ? `<a href="${esc(url)}" target="_blank">${esc(f.component)} ↗</a>` : `<b>${esc(f.component)}</b>`;
|
|
323
|
+
return `<details class="cvcomp"><summary>◈ ${esc(L.mainComponent)}: ${name}${variants.length ? ` · ${variants.length} ${esc(L.variantsWord)}` : ""} <span class="cvhint">${esc(L.toggleHint)}</span></summary>${thumbs}</details>`;
|
|
324
|
+
}).join("");
|
|
325
|
+
return `<div class="components"><h4>${esc(L.affectedComponents)}</h4>${blocks}</div>`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* A fix prompt an agent can act on without re-deriving anything.
|
|
330
|
+
*
|
|
331
|
+
* Prose loses the two things that make a deviation fixable: WHICH property moved
|
|
332
|
+
* and BY HOW MUCH. So emit a fixed-field table instead, keep it in English
|
|
333
|
+
* (it is a payload for a coding agent, next to code identifiers, not report
|
|
334
|
+
* copy), and state explicitly what must NOT be touched - advisories and
|
|
335
|
+
* fixture differences otherwise get "fixed" into new bugs.
|
|
336
|
+
*/
|
|
337
|
+
function buildFixPrompt(v) {
|
|
338
|
+
const all = v.findings || [];
|
|
339
|
+
const devs = all.filter((f) => !f.advisory && f.status !== "verified");
|
|
340
|
+
const advs = all.filter((f) => f.advisory);
|
|
341
|
+
if (!devs.length && !advs.length) return v.fixPrompt || "";
|
|
342
|
+
|
|
343
|
+
const rows = devs.map((f, i) => {
|
|
344
|
+
const prop = f.type === "inset" ? `${Object.keys(f.deltaPt || f.deltaPx || { edge: 1 })[0]} inset`
|
|
345
|
+
: f.type === "gap" ? "vertical gap"
|
|
346
|
+
: f.type === "spacing" ? "top offset"
|
|
347
|
+
: f.type === "height" ? "height"
|
|
348
|
+
: f.type === "font-size" ? "font size"
|
|
349
|
+
: f.type === "color" ? "colour"
|
|
350
|
+
: f.type;
|
|
351
|
+
const d = f.deltaPt || f.deltaPx || {};
|
|
352
|
+
const delta = Object.entries(d).map(([k, n]) => `${n >= 0 ? "+" : ""}${n}pt`).join(" ") || "-";
|
|
353
|
+
return `${i + 1}. ${f.element} | ${prop} | ${f.expected} -> ${f.actual} | ${delta}`;
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Deltas repeated verbatim across elements point at one shared container or
|
|
357
|
+
// token rather than N independent bugs - say so instead of making the agent
|
|
358
|
+
// spot it.
|
|
359
|
+
const byDelta = {};
|
|
360
|
+
for (const f of devs) {
|
|
361
|
+
const d = f.deltaPt || f.deltaPx || {};
|
|
362
|
+
for (const [k, n] of Object.entries(d)) {
|
|
363
|
+
const key = `${k}:${n}`;
|
|
364
|
+
(byDelta[key] = byDelta[key] || []).push(f.element);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const shared = Object.entries(byDelta)
|
|
368
|
+
.filter(([, els]) => new Set(els).size > 1)
|
|
369
|
+
.map(([key, els]) => {
|
|
370
|
+
const [k, n] = key.split(":");
|
|
371
|
+
return ` - ${k} off by ${n >= 0 ? "+" : ""}${n}pt on ${[...new Set(els)].join(", ")} -> likely ONE shared container padding / spacing token, not ${new Set(els).size} separate fixes`;
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const out = [
|
|
375
|
+
`SCREEN: ${v.name}`,
|
|
376
|
+
`FIGMA NODE: ${v.figmaNodeId || "-"} COMPARE FRAME: ${v.compareSize || "-"}`,
|
|
377
|
+
v.measurement ? `MEASUREMENT: ${v.measurement.mode} (tolerance ${v.measurement.tolerancePt}pt)` : "",
|
|
378
|
+
];
|
|
379
|
+
// No deviations means nothing to fix - an empty "DEVIATIONS" heading would read
|
|
380
|
+
// as one, and on an advisory-only screen it is actively wrong.
|
|
381
|
+
if (rows.length) {
|
|
382
|
+
out.push("", "DEVIATIONS (element | property | expected -> actual | delta)", ...rows);
|
|
383
|
+
} else {
|
|
384
|
+
out.push("", "NO GEOMETRY ISSUES - nothing to change on this screen.");
|
|
385
|
+
}
|
|
386
|
+
if (shared.length) out.push("", "SHARED ROOT CAUSE", ...shared);
|
|
387
|
+
if (advs.length) {
|
|
388
|
+
out.push("", "DO NOT CHANGE (advisory / not a defect)");
|
|
389
|
+
for (const f of advs) out.push(` - ${f.element}: ${f.detail}`);
|
|
390
|
+
}
|
|
391
|
+
out.push("", "RULES",
|
|
392
|
+
" - Fix only the properties listed above.",
|
|
393
|
+
" - Values are POINTS, already normalised for the device/design width difference.",
|
|
394
|
+
" - Prefer changing a spacing/padding token over hardcoding a number.",
|
|
395
|
+
" - Content/copy and row counts come from mock fixtures; do not 'fix' those.");
|
|
396
|
+
return out.filter((l) => l !== "").join("\n");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function variantSection(v, idx, fileKey, L) {
|
|
400
|
+
const findings = v.findings || [];
|
|
401
|
+
// An advisory (content-driven height, unmeasurable font family) is context, not
|
|
402
|
+
// a defect. Counting it as a deviation buries the real insets and gaps under
|
|
403
|
+
// noise, so it gets its own uncounted group.
|
|
404
|
+
const devs = findings.filter((f) => (f.status || "deviation") !== "verified" && !f.advisory);
|
|
405
|
+
const advisories = findings.filter((f) => f.advisory && f.status !== "verified");
|
|
406
|
+
const verified = findings.filter((f) => f.status === "verified");
|
|
407
|
+
const grouped = groupFindings(devs);
|
|
408
|
+
let devPin = 0;
|
|
409
|
+
const devGroups = Object.entries(grouped).map(([cat, list]) => `
|
|
410
|
+
<div class="grp"><h4 style="color:${catColor(cat)}">${esc(cat.toUpperCase())} <span>(${list.length})</span></h4>
|
|
411
|
+
<ul class="findings">${list.map((f) => findingLine(f, devPin++, L)).join("")}</ul></div>`).join("");
|
|
412
|
+
// An advisory exists to say "do not act on this". Printing each one as a full
|
|
413
|
+
// card beside the real findings doubles the page for the same elements and
|
|
414
|
+
// gives the reader nothing to do, so it collapses to one line they can open.
|
|
415
|
+
const advGroups = advisories.length ? `
|
|
416
|
+
<details class="grp adv"><summary style="color:#8a6d00;font-weight:700;font-size:12px;cursor:pointer">${esc(L.advisoryWord)} (${advisories.length})</summary>
|
|
417
|
+
<ul class="findings">${advisories.map((f) => findingLine(f, null, L)).join("")}</ul></details>` : "";
|
|
418
|
+
const okGroups = (verified.length ? `
|
|
419
|
+
<div class="grp verified"><h4 style="color:#0A7D33">${esc(L.verifiedOnSpec)} (${verified.length})</h4>
|
|
420
|
+
<ul class="findings">${verified.map((f) => findingLine(f, null, L)).join("")}</ul></div>` : "") + advGroups;
|
|
421
|
+
|
|
422
|
+
return `<section class="variant">
|
|
423
|
+
<h2>${idx + 1}. ${esc(v.name)} ${v.passed === null ? `<span class="cap">${esc(L.awaitingFigma)}</span>` : (devs.length > 0 ? `<span class="fail">${devs.length} ${esc(L.deviationsWord)}</span>` : `<span class="cap">${esc(L.noAutoFindings)}</span>`)}
|
|
424
|
+
<small>node ${esc(v.figmaNodeId || "-")}${v.perceptualPct === "-" ? "" : " · perceptual " + esc(v.perceptualPct) + "%"}</small></h2>
|
|
425
|
+
${twoUp(v, fileKey, L)}
|
|
426
|
+
${componentsBlock(v, fileKey, L)}
|
|
427
|
+
<div class="groups">${devGroups || `<p class="clean">${esc(L.noDeviations)}</p>`}${okGroups}</div>
|
|
428
|
+
${(() => { const fp = buildFixPrompt(v) || v.fixPrompt; return fp ? `<details class="fix" open><summary>${esc(L.fixPromptTitle)}</summary><pre>${esc(fp)}</pre></details>` : ""; })()}
|
|
429
|
+
</section>`;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function coverageSection(cv, L) {
|
|
433
|
+
if (!cv) return "";
|
|
434
|
+
const pct = Math.round(cv.pct * 100);
|
|
435
|
+
const fail = cv.gate === "fail";
|
|
436
|
+
const byGroup = (list) => {
|
|
437
|
+
const m = new Map();
|
|
438
|
+
for (const e of list) {
|
|
439
|
+
const g = e.group || "-";
|
|
440
|
+
if (!m.has(g)) m.set(g, []);
|
|
441
|
+
m.get(g).push(e);
|
|
442
|
+
}
|
|
443
|
+
return [...m.entries()];
|
|
444
|
+
};
|
|
445
|
+
const line = (e, withReason) => {
|
|
446
|
+
if (!e.id && e.count) return `<li><span class="norsn">${e.count} × ${esc(L.coverageUndeclared)}</span></li>`;
|
|
447
|
+
return `<li>${esc(e.id || "-")}${withReason ? ` - <i>${esc(e.reason)}</i>` : ` - <span class="norsn">${esc(L.coverageNoReason)}</span>`}</li>`;
|
|
448
|
+
};
|
|
449
|
+
const block = (title, list, withReason) => list.length ? `<div class="covgrp"><h4>${esc(title)} <span>(${tally(list)})</span></h4>
|
|
450
|
+
${byGroup(list).map(([g, items]) => `<div class="covsub"><b>${esc(g)}</b><ul>${items.map((e) => line(e, withReason)).join("")}</ul></div>`).join("")}</div>` : "";
|
|
451
|
+
|
|
452
|
+
return `<section class="coverage ${fail ? "covfail" : "covpass"}">
|
|
453
|
+
<h2>${esc(L.coverageTitle)}
|
|
454
|
+
<span class="covbadge ${fail ? "bad" : "good"}">${esc(fail ? L.coverageGateFail : L.coverageGatePass)}</span>
|
|
455
|
+
<small>${esc(L.coverageGateHint)}</small></h2>
|
|
456
|
+
<div class="covbar"><div class="covfill" style="width:${pct}%"></div></div>
|
|
457
|
+
<p class="covnum"><b>${cv.audited}</b> ${esc(L.coverageAudited)} / <b>${cv.target}</b> ${esc(L.coverageTargets)} · ${pct}%${cv.belowFloor ? ` · <span class="fail">${esc(L.coverageBelowFloor)} (${Math.round(cv.floor * 100)}%)</span>` : ""}</p>
|
|
458
|
+
${cv.truncatedInventory ? `<p class="covnum"><span class="fail">${esc(L.coverageTruncated)}</span></p>` : ""}
|
|
459
|
+
${block(L.coverageSkipped, cv.skipped, true)}
|
|
460
|
+
${block(L.coverageUnaccounted, cv.unaccounted, false)}
|
|
461
|
+
</section>`;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function renderHtml(report) {
|
|
465
|
+
const L = labels(report);
|
|
466
|
+
const variants = report.variants || [];
|
|
467
|
+
const fileKey = report.figmaFileKey || null;
|
|
468
|
+
const totalDev = variants.reduce((n, v) => n + (v.findings || []).filter((f) => f.status !== "verified" && !f.advisory).length, 0);
|
|
469
|
+
// Must exclude advisories on the same terms as totalDev and variantSection's
|
|
470
|
+
// devs: this drives the per-screen status label and the fail class, so counting
|
|
471
|
+
// them here made a screen whose only finding was advisory render as DEVIATION
|
|
472
|
+
// under a header that said 0 deviations.
|
|
473
|
+
const devCount = (v) => (v.findings || []).filter((f) => f.status !== "verified" && !f.advisory).length;
|
|
474
|
+
const statusLabel = (v) => v.passed === null ? L.statusCaptured : (devCount(v) > 0 ? L.statusDeviation : (v.perceptualPct === "-" ? L.statusPass : L.statusReview));
|
|
475
|
+
const statusCls = (v) => v.passed === null ? "" : (devCount(v) > 0 ? "fail" : "");
|
|
476
|
+
const rows = variants.map((v, i) => `<tr><td>${i + 1}. ${esc(v.name)}</td><td>${v.perceptualPct === "-" ? "-" : esc(v.perceptualPct) + "%"}</td>
|
|
477
|
+
<td>${devCount(v)}</td>
|
|
478
|
+
<td class="${statusCls(v)}">${esc(statusLabel(v))}</td></tr>`).join("");
|
|
479
|
+
const cv = coverageVerdict(report.coverage, variants.length);
|
|
480
|
+
|
|
481
|
+
return `<!doctype html><html lang="${esc(L.htmlLang)}"><head><meta charset="utf-8">
|
|
482
|
+
<title>Design Check - ${esc(report.project)} / ${esc(report.module)}</title>
|
|
483
|
+
<style>
|
|
484
|
+
:root{--fg:#1a1a1a;--muted:#666;--line:#e5e5e5;--card:#fafafa}
|
|
485
|
+
*{box-sizing:border-box}body{font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;color:var(--fg);margin:0;padding:32px;max-width:1180px;margin-inline:auto;background:#fff}
|
|
486
|
+
h1{font-size:22px;margin:0 0 4px}.sub{color:var(--muted);margin:0 0 20px}
|
|
487
|
+
table{border-collapse:collapse;width:100%;margin-bottom:28px}th,td{text-align:left;padding:8px 12px;border-bottom:1px solid var(--line)}
|
|
488
|
+
th{font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted)}
|
|
489
|
+
.pass{color:#0A7D33;font-weight:700}.fail{color:#cf222e;font-weight:700}.cap{color:#7A5AF8;font-weight:600;font-size:13px}
|
|
490
|
+
.variant{border:1px solid var(--line);border-radius:12px;padding:20px;margin-bottom:28px;background:var(--card)}
|
|
491
|
+
.variant h2{font-size:17px;margin:0 0 14px}.variant h2 small{display:block;font-weight:400;color:var(--muted);font-size:12px;margin-top:2px}
|
|
492
|
+
.twoup{overflow-x:auto;background:#fff;border:1px solid var(--line);border-radius:8px;padding:12px;margin-bottom:16px}
|
|
493
|
+
.groups{display:flex;flex-wrap:wrap;gap:20px}.grp{min-width:280px;flex:1}.grp.adv{min-width:100%;flex:0 0 100%;margin-top:6px}.grp.adv summary{outline:none}
|
|
494
|
+
.grp h4{font-size:12px;letter-spacing:.04em;margin:0 0 6px}.grp h4 span{color:var(--muted);font-weight:400}
|
|
495
|
+
ul.findings{margin:0;padding:0;list-style:none}
|
|
496
|
+
.finding{display:flex;gap:8px;align-items:baseline;padding:5px 0;border-bottom:1px dashed var(--line)}
|
|
497
|
+
.mark{font-weight:700;min-width:16px;text-align:center}
|
|
498
|
+
.fel{font-weight:600}.exp{color:var(--fg)}.exp b{color:#cf222e}.detail{color:var(--muted)}
|
|
499
|
+
code{background:#eee;padding:1px 5px;border-radius:4px;font-size:12px}
|
|
500
|
+
.verified .fel{color:#0A7D33}
|
|
501
|
+
.clean{color:#0A7D33}
|
|
502
|
+
.fix{margin-top:14px}.fix summary{cursor:pointer;font-weight:600;font-size:13px}
|
|
503
|
+
.fix pre{background:#0d1117;color:#e6edf3;padding:14px;border-radius:8px;overflow:auto;font-size:12px;white-space:pre-wrap}
|
|
504
|
+
.components{margin:8px 0 16px}.components h4{font-size:12px;letter-spacing:.04em;color:var(--muted);margin:0 0 8px}
|
|
505
|
+
.cvcomp{border:1px solid var(--line);border-radius:8px;padding:8px 12px;margin-bottom:8px;background:#fff}
|
|
506
|
+
.cvcomp summary{cursor:pointer;font-size:13px}.cvcomp summary b{color:#7A5AF8}.cvhint{color:var(--muted);font-weight:400;font-size:11px}
|
|
507
|
+
.cvgrid{display:flex;flex-wrap:wrap;gap:12px;margin-top:12px}
|
|
508
|
+
.cvgrid figure{margin:0;text-align:center}.cvgrid img{height:150px;border:1px solid var(--line);border-radius:6px;display:block}
|
|
509
|
+
.cvgrid figcaption{font-size:11px;color:var(--muted);margin-top:4px;max-width:160px}
|
|
510
|
+
.cvnote{color:var(--muted);font-size:12px;margin:10px 0 2px}
|
|
511
|
+
a{color:#0A66C2;text-decoration:none}a:hover{text-decoration:underline}
|
|
512
|
+
.coverage{border-radius:12px;padding:20px;margin-bottom:28px}
|
|
513
|
+
.covfail{border:2px solid #cf222e;background:#fff5f5}.covpass{border:1px solid #0A7D33;background:#f6fff8}
|
|
514
|
+
.coverage h2{font-size:16px;margin:0 0 12px}.coverage h2 small{display:block;font-weight:400;color:var(--muted);font-size:12px;margin-top:3px}
|
|
515
|
+
.covbadge{font-size:11px;font-weight:700;padding:3px 9px;border-radius:10px;margin-left:8px;vertical-align:middle}
|
|
516
|
+
.covbadge.bad{background:#cf222e;color:#fff}.covbadge.good{background:#0A7D33;color:#fff}
|
|
517
|
+
.covbar{height:8px;border-radius:4px;background:#e9e9e9;overflow:hidden;margin-bottom:8px}
|
|
518
|
+
.covfill{height:100%;background:#0A7D33}.covfail .covfill{background:#cf222e}
|
|
519
|
+
.covnum{margin:0 0 14px;font-size:13px;color:#333}
|
|
520
|
+
.covgrp{margin-bottom:14px}.covgrp h4{margin:0 0 6px;font-size:13px}.covgrp h4 span{color:var(--muted);font-weight:400}
|
|
521
|
+
.covsub{margin:0 0 8px}.covsub b{font-size:12px;color:#444}
|
|
522
|
+
.covsub ul{margin:2px 0 0;padding-left:18px;font-size:12.5px;color:#444}
|
|
523
|
+
.covsub i{color:var(--muted);font-style:normal}
|
|
524
|
+
.norsn{color:#cf222e;font-weight:600}
|
|
525
|
+
footer{color:var(--muted);font-size:12px;margin-top:24px}
|
|
526
|
+
</style></head><body>
|
|
527
|
+
<h1>${esc(L.reportTitle)}</h1>
|
|
528
|
+
<p class="sub">${esc(report.project)} · ${esc(L.moduleWord)} <b>${esc(report.module)}</b> · ${esc(report.platform || "")} · ${esc(report.timestamp)}<br>
|
|
529
|
+
${report.figmaUrl ? `Figma: <a href="${esc(report.figmaUrl)}">${esc(report.figmaUrl)}</a>` : ""}</p>
|
|
530
|
+
${coverageSection(cv, L)}
|
|
531
|
+
<table><thead><tr><th>${esc(L.thScreen)}</th><th>${esc(L.thPerceptual)}</th><th>${esc(L.thDeviations)}</th><th>${esc(L.thStatus)}</th></tr></thead><tbody>${rows}</tbody></table>
|
|
532
|
+
<p class="sub">${variants.length} ${esc(L.screensWord)} · ${totalDev} ${esc(L.deviationsWord)}</p>
|
|
533
|
+
${variants.map((v, i) => variantSection(v, i, fileKey, L)).join("")}
|
|
534
|
+
<footer>${esc(L.footerNote)}</footer>
|
|
535
|
+
</body></html>`;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export async function writeReport({ report, outDir, formats = ["html"] }) {
|
|
539
|
+
const html = renderHtml(report);
|
|
540
|
+
const out = {};
|
|
541
|
+
const cv = coverageVerdict(report.coverage, (report.variants || []).length);
|
|
542
|
+
if (cv) {
|
|
543
|
+
out.coverage = {
|
|
544
|
+
gate: cv.gate, audited: cv.audited, target: cv.target,
|
|
545
|
+
pct: Math.round(cv.pct * 1000) / 10,
|
|
546
|
+
skippedWithReason: cv.skipped.length,
|
|
547
|
+
unaccounted: cv.unaccountedCount,
|
|
548
|
+
unaccountedIds: cv.unaccounted.map((e) => e.id).filter(Boolean),
|
|
549
|
+
};
|
|
550
|
+
out.coverage.truncatedInventory = cv.truncatedInventory;
|
|
551
|
+
if (cv.gate === "fail") {
|
|
552
|
+
out.coverageError = cv.truncatedInventory
|
|
553
|
+
? "Coverage gate FAILED: the scenario inventory was truncated, so the target set is incomplete and coverage cannot be verified."
|
|
554
|
+
: cv.belowFloor
|
|
555
|
+
? `Coverage gate FAILED: ${cv.audited}/${cv.target} audited (${Math.round(cv.pct * 100)}%), below the ${Math.round(cv.floor * 100)}% floor.`
|
|
556
|
+
: `Coverage gate FAILED: ${cv.unaccountedCount} of ${cv.target} targets were neither audited nor skipped with a reason.`;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (formats.includes("html") || formats.includes("confluence")) {
|
|
560
|
+
out.html = join(outDir, "report.html");
|
|
561
|
+
writeFileSync(out.html, html, "utf-8");
|
|
562
|
+
}
|
|
563
|
+
if (formats.includes("pdf")) {
|
|
564
|
+
const pdfPath = join(outDir, "report.pdf");
|
|
565
|
+
let ok = false, err = "";
|
|
566
|
+
try {
|
|
567
|
+
const { chromium } = await import("playwright");
|
|
568
|
+
const browser = await chromium.launch();
|
|
569
|
+
const page = await browser.newPage();
|
|
570
|
+
await page.setContent(html, { waitUntil: "networkidle" });
|
|
571
|
+
await page.pdf({ path: pdfPath, format: "A4", printBackground: true, margin: { top: "12mm", bottom: "12mm", left: "10mm", right: "10mm" } });
|
|
572
|
+
await browser.close(); ok = true;
|
|
573
|
+
} catch (e) {
|
|
574
|
+
err = e.message;
|
|
575
|
+
// Fallback: use the chrome-headless-shell binary directly (no `playwright` package).
|
|
576
|
+
try {
|
|
577
|
+
const { execSync } = await import("node:child_process");
|
|
578
|
+
const bin = execSync('ls -d "$HOME"/Library/Caches/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-*/chrome-headless-shell 2>/dev/null | head -1', { encoding: "utf-8", shell: "/bin/bash" }).trim();
|
|
579
|
+
if (bin && existsSync(bin)) {
|
|
580
|
+
const htmlPath = join(outDir, ".report-print.html");
|
|
581
|
+
writeFileSync(htmlPath, html, "utf-8");
|
|
582
|
+
execSync(`"${bin}" --headless --disable-gpu --no-sandbox --no-pdf-header-footer --print-to-pdf="${pdfPath}" "file://${htmlPath}"`, { timeout: 90000 });
|
|
583
|
+
ok = existsSync(pdfPath);
|
|
584
|
+
}
|
|
585
|
+
} catch (e2) { err += " | headless-shell: " + e2.message; }
|
|
586
|
+
}
|
|
587
|
+
if (ok) out.pdf = pdfPath; else out.pdfError = `PDF skipped (${err}).`;
|
|
588
|
+
}
|
|
589
|
+
return out;
|
|
590
|
+
}
|