@capagents/forgeqe 0.0.0-stage → 1.0.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/HOW_TO_USE.md +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +781 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +146 -0
- package/src/update.ts +311 -0
- package/src/usage.ts +158 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_VISUAL, visualConfig, visualLine, type VisualConfig, type VisualReport } from "./visual.ts"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Accessibility (WCAG) and performance (Core Web Vitals) of the pages a run visited, judged against budgets. A run
|
|
8
|
+
* fails on quality only when a budget set in forgeqe.yaml is exceeded; without budgets both are reported, never
|
|
9
|
+
* failed. Accessibility comes from axe-core, run on each page with the rules of the chosen standard; timings come
|
|
10
|
+
* from the browser's own performance entries.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const STANDARDS: Record<string, { label: string; tags: string[] }> = {
|
|
14
|
+
wcag2a: { label: "WCAG 2.x A", tags: ["wcag2a"] },
|
|
15
|
+
wcag21aa: { label: "WCAG 2.1 AA", tags: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] },
|
|
16
|
+
wcag22aa: { label: "WCAG 2.2 AA", tags: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"] },
|
|
17
|
+
wcag21aaa: { label: "WCAG 2.1 AAA", tags: ["wcag2a", "wcag2aa", "wcag2aaa", "wcag21a", "wcag21aa", "wcag21aaa"] },
|
|
18
|
+
section508: { label: "Section 508", tags: ["section508", "wcag2a", "wcag2aa"] },
|
|
19
|
+
en301549: { label: "EN 301 549", tags: ["EN-301-549", "wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] },
|
|
20
|
+
"best-practice": { label: "Best practice", tags: ["best-practice"] },
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const DISCLAIMER = "Automated checks find a part of the WCAG problems (often 30–50%); they are a pre-check, not conformance. A manual review still matters."
|
|
24
|
+
|
|
25
|
+
export type Impact = "critical" | "serious" | "moderate" | "minor"
|
|
26
|
+
export const IMPACTS: Impact[] = ["critical", "serious", "moderate", "minor"]
|
|
27
|
+
|
|
28
|
+
export type A11yBudget = Partial<Record<Impact, number>> & { /** Rules that must not fail at all. */ rules?: string[]; /** Rules left out of the budget. */ ignore?: string[] }
|
|
29
|
+
|
|
30
|
+
export const METRICS = ["lcp_ms", "cls", "inp_ms", "fcp_ms", "ttfb_ms", "load_ms"] as const
|
|
31
|
+
export type Metric = (typeof METRICS)[number]
|
|
32
|
+
export type PerfLimits = Partial<Record<Metric, number>>
|
|
33
|
+
export type PerfBudget = PerfLimits & { /** Limits for pages whose path starts with the key (/checkout). */ pages?: Record<string, PerfLimits> }
|
|
34
|
+
|
|
35
|
+
/** Google's "good" thresholds: `performance: web-vitals` in forgeqe.yaml. */
|
|
36
|
+
export const WEB_VITALS: PerfLimits = { lcp_ms: 2500, cls: 0.1, inp_ms: 200, fcp_ms: 1800, ttfb_ms: 800 }
|
|
37
|
+
|
|
38
|
+
export type QualityConfig = {
|
|
39
|
+
/** Screenshots of each page compared with approved baselines (quality.visual). */
|
|
40
|
+
visual: VisualConfig
|
|
41
|
+
/** A key of STANDARDS (default wcag21aa). */
|
|
42
|
+
standard: string
|
|
43
|
+
/** Also run axe-core's best-practice rules (reported, counted in budgets like the others). */
|
|
44
|
+
bestPractice: boolean
|
|
45
|
+
/** axe (default) or basic: ForgeQE's own checks when axe-core cannot be used. */
|
|
46
|
+
engine: "axe" | "basic"
|
|
47
|
+
/** visual: the most a page may differ from its baseline, in percent of its pixels. */
|
|
48
|
+
budgets: { accessibility?: A11yBudget; performance?: PerfBudget; visual?: number }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const DEFAULT_QUALITY: QualityConfig = { standard: "wcag21aa", bestPractice: false, engine: "axe", budgets: {}, visual: DEFAULT_VISUAL }
|
|
52
|
+
|
|
53
|
+
export function axeTags(q: QualityConfig): string[] {
|
|
54
|
+
const tags = STANDARDS[q.standard]?.tags || STANDARDS.wcag21aa.tags
|
|
55
|
+
return q.bestPractice && !tags.includes("best-practice") ? [...tags, "best-practice"] : tags
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const num = (v: unknown) => (v === undefined || v === null || v === "" || Number.isNaN(Number(v)) ? undefined : Number(v))
|
|
59
|
+
|
|
60
|
+
function limits(raw: Record<string, unknown>): PerfLimits {
|
|
61
|
+
const out: PerfLimits = {}
|
|
62
|
+
for (const m of METRICS) {
|
|
63
|
+
const v = num(raw[m] ?? raw[m.replace(/_ms$/, "")])
|
|
64
|
+
if (v !== undefined) out[m] = v
|
|
65
|
+
}
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** quality: in forgeqe.yaml. */
|
|
70
|
+
export function qualityConfig(raw: unknown): QualityConfig {
|
|
71
|
+
const q = (raw && typeof raw === "object" ? raw : {}) as Record<string, any>
|
|
72
|
+
const a11y = (q.accessibility || {}) as Record<string, any>
|
|
73
|
+
const budgets = (q.budgets || {}) as Record<string, any>
|
|
74
|
+
const standard = String(a11y.standard || "wcag21aa").toLowerCase().replace(/[^a-z0-9-]/g, "")
|
|
75
|
+
if (!STANDARDS[standard]) throw new Error(`quality.accessibility.standard "${a11y.standard}" is not one of ${Object.keys(STANDARDS).join(", ")}.`)
|
|
76
|
+
const out: QualityConfig = { standard, bestPractice: a11y.best_practice === true, engine: a11y.engine === "basic" ? "basic" : "axe", budgets: {}, visual: visualConfig(q.visual) }
|
|
77
|
+
const vb = budgets.visual
|
|
78
|
+
const vlimit = vb === true ? 0.1 : num(vb && typeof vb === "object" ? vb.max_diff_percent : vb)
|
|
79
|
+
if (vlimit !== undefined) out.budgets.visual = vlimit
|
|
80
|
+
const ab = budgets.accessibility
|
|
81
|
+
if (ab && typeof ab === "object") {
|
|
82
|
+
const b: A11yBudget = {}
|
|
83
|
+
for (const i of IMPACTS) if (num(ab[i]) !== undefined) b[i] = num(ab[i])
|
|
84
|
+
if (Array.isArray(ab.rules)) b.rules = ab.rules.map(String)
|
|
85
|
+
if (Array.isArray(ab.ignore)) b.ignore = ab.ignore.map(String)
|
|
86
|
+
out.budgets.accessibility = b
|
|
87
|
+
} else if (ab === true) out.budgets.accessibility = { critical: 0, serious: 0 }
|
|
88
|
+
const pb = budgets.performance
|
|
89
|
+
if (pb === "web-vitals" || pb === true) out.budgets.performance = { ...WEB_VITALS }
|
|
90
|
+
else if (pb && typeof pb === "object") {
|
|
91
|
+
const base = pb.preset === "web-vitals" ? { ...WEB_VITALS } : {}
|
|
92
|
+
out.budgets.performance = { ...base, ...limits(pb), ...(pb.pages && typeof pb.pages === "object" ? { pages: Object.fromEntries(Object.entries(pb.pages as Record<string, Record<string, unknown>>).map(([k, v]) => [k, limits(v || {})])) } : {}) }
|
|
93
|
+
}
|
|
94
|
+
return out
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// -- reading a run --------------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
export type A11yIssue = { rule: string; wcag?: string; level?: string; impact: Impact; help: string; helpUrl?: string; count: number; examples?: { html?: string; target?: string; text?: string }[] }
|
|
100
|
+
export type A11yPage = { url: string; title?: string; engine?: string; tags?: string[]; passed?: string[]; incomplete?: string[]; issues: A11yIssue[] }
|
|
101
|
+
export type PerfPage = { url: string; lcp?: number | null; cls?: number | null; inp?: number | null; fcp?: number | null; ttfb?: number | null; dcl?: number | null; load?: number | null; requests?: number; kb?: number }
|
|
102
|
+
|
|
103
|
+
const readJson = <T>(file: string): T | null => {
|
|
104
|
+
try {
|
|
105
|
+
return existsSync(file) ? (JSON.parse(readFileSync(file, "utf8")) as T) : null
|
|
106
|
+
} catch {
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const readA11y = (runDir: string) => readJson<A11yPage[]>(path.join(runDir, "accessibility.json")) || []
|
|
112
|
+
export const readPerf = (runDir: string) => readJson<PerfPage[]>(path.join(runDir, "performance.json")) || []
|
|
113
|
+
|
|
114
|
+
const VALUE: Record<Metric, keyof PerfPage> = { lcp_ms: "lcp", cls: "cls", inp_ms: "inp", fcp_ms: "fcp", ttfb_ms: "ttfb", load_ms: "load" }
|
|
115
|
+
export const METRIC_LABEL: Record<Metric, string> = { lcp_ms: "LCP", cls: "CLS", inp_ms: "INP", fcp_ms: "FCP", ttfb_ms: "TTFB", load_ms: "Load" }
|
|
116
|
+
export const fmtMetric = (m: Metric, v: number) => (m === "cls" ? v.toFixed(3).replace(/0+$/, "").replace(/\.$/, "") : v >= 1000 ? `${(v / 1000).toFixed(1)}s` : `${Math.round(v)}ms`)
|
|
117
|
+
|
|
118
|
+
export type Breach = { kind: "accessibility" | "performance" | "visual"; page: string; text: string }
|
|
119
|
+
|
|
120
|
+
export type QualityReport = {
|
|
121
|
+
standard: string
|
|
122
|
+
engine: string
|
|
123
|
+
accessibility: {
|
|
124
|
+
pages: number
|
|
125
|
+
/** Each rule that failed somewhere: on how many pages, how many elements. */
|
|
126
|
+
failed: { rule: string; wcag?: string; level?: string; impact: Impact; help: string; helpUrl?: string; pages: number; count: number }[]
|
|
127
|
+
/** Rules that passed on every page they ran on. */
|
|
128
|
+
passed: string[]
|
|
129
|
+
byImpact: Record<Impact, number>
|
|
130
|
+
}
|
|
131
|
+
performance: { pages: (PerfPage & { over: string[] })[] }
|
|
132
|
+
/** Each page against its visual baseline. */
|
|
133
|
+
visual?: VisualReport
|
|
134
|
+
budgets: { accessibility: boolean; performance: boolean; visual?: boolean }
|
|
135
|
+
breaches: Breach[]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const pathOf = (url: string) => {
|
|
139
|
+
try {
|
|
140
|
+
return new URL(url).pathname
|
|
141
|
+
} catch {
|
|
142
|
+
return url
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The limits for a page: the budget's, with the longest matching path prefix's on top. */
|
|
147
|
+
export function limitsFor(b: PerfBudget, url: string): PerfLimits {
|
|
148
|
+
const p = pathOf(url)
|
|
149
|
+
const { pages, ...base } = b
|
|
150
|
+
const match = Object.keys(pages || {})
|
|
151
|
+
.filter((k) => p === k || p.startsWith(k.endsWith("/") ? k : `${k}/`) || (k !== "/" && p.startsWith(k)))
|
|
152
|
+
.sort((a, b) => b.length - a.length)[0]
|
|
153
|
+
return { ...base, ...(match ? pages![match] : {}) }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Judge a run's pages: WCAG rules passed and failed, Core Web Vitals, and every budget exceeded. */
|
|
157
|
+
export function evaluateQuality(runDir: string, q: QualityConfig): QualityReport | null {
|
|
158
|
+
const a11y = readA11y(runDir)
|
|
159
|
+
const perf = readPerf(runDir)
|
|
160
|
+
if (!a11y.length && !perf.length) return null
|
|
161
|
+
const ignore = new Set(q.budgets.accessibility?.ignore || [])
|
|
162
|
+
const failed = new Map<string, QualityReport["accessibility"]["failed"][number]>()
|
|
163
|
+
const passedAnywhere = new Set<string>()
|
|
164
|
+
const byImpact: Record<Impact, number> = { critical: 0, serious: 0, moderate: 0, minor: 0 }
|
|
165
|
+
const breaches: Breach[] = []
|
|
166
|
+
for (const page of a11y) {
|
|
167
|
+
for (const r of page.passed || []) passedAnywhere.add(r)
|
|
168
|
+
const counted: Record<Impact, A11yIssue[]> = { critical: [], serious: [], moderate: [], minor: [] }
|
|
169
|
+
for (const i of page.issues) {
|
|
170
|
+
const impact = (IMPACTS.includes(i.impact) ? i.impact : "minor") as Impact
|
|
171
|
+
byImpact[impact] += i.count
|
|
172
|
+
const f = failed.get(i.rule) || { rule: i.rule, wcag: i.wcag, level: i.level, impact, help: i.help, helpUrl: i.helpUrl, pages: 0, count: 0 }
|
|
173
|
+
f.pages++
|
|
174
|
+
f.count += i.count
|
|
175
|
+
failed.set(i.rule, f)
|
|
176
|
+
if (!ignore.has(i.rule)) counted[impact].push(i)
|
|
177
|
+
}
|
|
178
|
+
const b = q.budgets.accessibility
|
|
179
|
+
if (b) {
|
|
180
|
+
for (const impact of IMPACTS) {
|
|
181
|
+
const limit = b[impact]
|
|
182
|
+
const n = counted[impact].reduce((a, i) => a + i.count, 0)
|
|
183
|
+
if (limit !== undefined && n > limit)
|
|
184
|
+
breaches.push({ kind: "accessibility", page: page.url, text: `${n} ${impact} accessibility violation${n === 1 ? "" : "s"} (budget ${limit}): ${counted[impact].map((i) => `${i.rule}${i.wcag ? ` (WCAG ${i.wcag})` : ""}`).join(", ")}` })
|
|
185
|
+
}
|
|
186
|
+
for (const rule of b.rules || []) {
|
|
187
|
+
const hit = page.issues.find((i) => i.rule === rule)
|
|
188
|
+
if (hit) breaches.push({ kind: "accessibility", page: page.url, text: `${rule} fails on ${hit.count} element${hit.count === 1 ? "" : "s"} (budget: must pass)` })
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const perfPages = perf.map((p) => {
|
|
193
|
+
const over: string[] = []
|
|
194
|
+
if (q.budgets.performance) {
|
|
195
|
+
const lim = limitsFor(q.budgets.performance, p.url)
|
|
196
|
+
for (const m of METRICS) {
|
|
197
|
+
const v = p[VALUE[m]] as number | null | undefined
|
|
198
|
+
const limit = lim[m]
|
|
199
|
+
if (limit === undefined || v === null || v === undefined) continue
|
|
200
|
+
if (v > limit) over.push(`${METRIC_LABEL[m]} ${fmtMetric(m, v)} (budget ${fmtMetric(m, limit)})`)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (over.length) breaches.push({ kind: "performance", page: p.url, text: over.join(", ") })
|
|
204
|
+
return { ...p, over }
|
|
205
|
+
})
|
|
206
|
+
const engine = a11y.find((p) => p.engine)?.engine || (a11y.length ? "forgeqe" : "")
|
|
207
|
+
return {
|
|
208
|
+
standard: STANDARDS[q.standard]?.label || q.standard,
|
|
209
|
+
engine,
|
|
210
|
+
accessibility: {
|
|
211
|
+
pages: a11y.length,
|
|
212
|
+
failed: [...failed.values()].sort((a, b) => IMPACTS.indexOf(a.impact) - IMPACTS.indexOf(b.impact) || b.count - a.count),
|
|
213
|
+
passed: [...passedAnywhere].filter((r) => !failed.has(r)).sort(),
|
|
214
|
+
byImpact,
|
|
215
|
+
},
|
|
216
|
+
performance: { pages: perfPages },
|
|
217
|
+
budgets: { accessibility: Boolean(q.budgets.accessibility), performance: Boolean(q.budgets.performance) },
|
|
218
|
+
breaches,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** A report with no accessibility or timing data, for a run that only has visual captures. */
|
|
223
|
+
export function emptyReport(q: QualityConfig): QualityReport {
|
|
224
|
+
return {
|
|
225
|
+
standard: STANDARDS[q.standard]?.label || q.standard,
|
|
226
|
+
engine: "",
|
|
227
|
+
accessibility: { pages: 0, failed: [], passed: [], byImpact: { critical: 0, serious: 0, moderate: 0, minor: 0 } },
|
|
228
|
+
performance: { pages: [] },
|
|
229
|
+
budgets: { accessibility: Boolean(q.budgets.accessibility), performance: Boolean(q.budgets.performance) },
|
|
230
|
+
breaches: [],
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** One line for summaries: WCAG rules, vitals, and budgets. */
|
|
235
|
+
export function qualityLine(r: QualityReport): string {
|
|
236
|
+
const parts: string[] = []
|
|
237
|
+
if (r.accessibility.pages) {
|
|
238
|
+
const n = r.accessibility.failed.length
|
|
239
|
+
parts.push(`${r.standard}: ${r.accessibility.passed.length} rules passed, ${n} failed${n ? ` (${IMPACTS.filter((i) => r.accessibility.byImpact[i]).map((i) => `${r.accessibility.byImpact[i]} ${i}`).join(", ")})` : ""}`)
|
|
240
|
+
}
|
|
241
|
+
const timed = r.performance.pages.filter((p) => p.lcp != null || p.cls != null)
|
|
242
|
+
if (timed.length) {
|
|
243
|
+
const worst = (k: "lcp" | "cls" | "inp") => Math.max(...timed.map((p) => Number(p[k] ?? 0)))
|
|
244
|
+
parts.push(`worst LCP ${fmtMetric("lcp_ms", worst("lcp"))}, CLS ${fmtMetric("cls", worst("cls"))}${timed.some((p) => p.inp) ? `, INP ${fmtMetric("inp_ms", worst("inp"))}` : ""}`)
|
|
245
|
+
}
|
|
246
|
+
if (r.visual?.pages.length) parts.push(`visual: ${visualLine(r.visual)}`)
|
|
247
|
+
if (r.budgets.accessibility || r.budgets.performance || r.budgets.visual) parts.push(r.breaches.length ? `${r.breaches.length} budget${r.breaches.length === 1 ? "" : "s"} exceeded` : "within budgets")
|
|
248
|
+
return parts.join(" · ")
|
|
249
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import type { Card } from "../card.ts"
|
|
5
|
+
import { fmtMetric, IMPACTS, type Impact, type Metric, type QualityReport } from "./budgets.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Each page's accessibility and timings over time, so a run shows what got better or worse: in
|
|
9
|
+
* quality-history.json in the reports folder (keep it between CI runs as a cache to track a pipeline's trend).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const HISTORY_NAME = "quality-history.json"
|
|
13
|
+
const KEEP = 50
|
|
14
|
+
const WINDOW = 10
|
|
15
|
+
|
|
16
|
+
type Entry = { at: string; run: string; a11y?: Record<Impact, number> & { rules: string[] }; perf?: Partial<Record<"lcp" | "cls" | "inp" | "fcp" | "ttfb" | "load", number>> }
|
|
17
|
+
type HistoryFile = { version: 1; pages: Record<string, Entry[]> }
|
|
18
|
+
|
|
19
|
+
export const historyFile = (outDir: string) => path.join(outDir, HISTORY_NAME)
|
|
20
|
+
|
|
21
|
+
/** A page's key: its origin and path, without query or hash. */
|
|
22
|
+
export function pageKey(url: string): string {
|
|
23
|
+
try {
|
|
24
|
+
const u = new URL(url)
|
|
25
|
+
return `${u.origin}${u.pathname.replace(/\/+$/, "") || "/"}`
|
|
26
|
+
} catch {
|
|
27
|
+
return url
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function readQualityHistory(file: string): HistoryFile {
|
|
32
|
+
try {
|
|
33
|
+
const d = JSON.parse(readFileSync(file, "utf8")) as HistoryFile
|
|
34
|
+
return { version: 1, pages: d?.pages && typeof d.pages === "object" ? d.pages : {} }
|
|
35
|
+
} catch {
|
|
36
|
+
return { version: 1, pages: {} }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Add one run's pages. */
|
|
41
|
+
export function recordQuality(file: string, report: QualityReport, a11yPages: { url: string; issues: { rule: string; impact: string; count: number }[] }[], run: string, at = new Date().toISOString()): void {
|
|
42
|
+
const h = readQualityHistory(file)
|
|
43
|
+
const entries = new Map<string, Entry>()
|
|
44
|
+
const entry = (url: string) => {
|
|
45
|
+
const k = pageKey(url)
|
|
46
|
+
const e = entries.get(k) || { at, run }
|
|
47
|
+
entries.set(k, e)
|
|
48
|
+
return e
|
|
49
|
+
}
|
|
50
|
+
for (const p of a11yPages) {
|
|
51
|
+
const counts: Record<Impact, number> = { critical: 0, serious: 0, moderate: 0, minor: 0 }
|
|
52
|
+
for (const i of p.issues) counts[(IMPACTS.includes(i.impact as Impact) ? i.impact : "minor") as Impact] += i.count
|
|
53
|
+
entry(p.url).a11y = { ...counts, rules: [...new Set(p.issues.map((i) => i.rule))].sort() }
|
|
54
|
+
}
|
|
55
|
+
for (const p of report.performance.pages) {
|
|
56
|
+
const perf: Entry["perf"] = {}
|
|
57
|
+
for (const k of ["lcp", "cls", "inp", "fcp", "ttfb", "load"] as const) if (typeof p[k] === "number") perf[k] = p[k] as number
|
|
58
|
+
if (Object.keys(perf).length) entry(p.url).perf = perf
|
|
59
|
+
}
|
|
60
|
+
for (const [k, e] of entries) h.pages[k] = [...(h.pages[k] || []), e].slice(-KEEP)
|
|
61
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
62
|
+
writeFileSync(file, `${JSON.stringify(h, null, 1)}\n`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const median = (xs: number[]) => {
|
|
66
|
+
const s = [...xs].sort((a, b) => a - b)
|
|
67
|
+
return s.length ? (s.length % 2 ? s[(s.length - 1) / 2] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2) : NaN
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** How much worse than the usual value counts as a regression: a share, and an absolute amount so noise on fast pages does not count. */
|
|
71
|
+
const WORSE: Record<"lcp" | "cls" | "inp" | "fcp" | "ttfb" | "load", { share: number; min: number; metric: Metric }> = {
|
|
72
|
+
lcp: { share: 0.2, min: 300, metric: "lcp_ms" },
|
|
73
|
+
cls: { share: 0.25, min: 0.05, metric: "cls" },
|
|
74
|
+
inp: { share: 0.25, min: 50, metric: "inp_ms" },
|
|
75
|
+
fcp: { share: 0.2, min: 300, metric: "fcp_ms" },
|
|
76
|
+
ttfb: { share: 0.3, min: 200, metric: "ttfb_ms" },
|
|
77
|
+
load: { share: 0.25, min: 500, metric: "load_ms" },
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type PageTrend = {
|
|
81
|
+
page: string
|
|
82
|
+
runs: number
|
|
83
|
+
/** Accessibility violations by impact, oldest first, last WINDOW runs. */
|
|
84
|
+
a11y: { latest?: Record<Impact, number>; trail: number[]; newRules: string[]; fixedRules: string[] }
|
|
85
|
+
perf: { latest: Entry["perf"]; usual: Entry["perf"]; worse: string[]; better: string[] }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Each page's trend: the latest run against the one before (rules) and against the usual values of the last runs (timings). */
|
|
89
|
+
export function qualityTrends(file: string, filter?: string): PageTrend[] {
|
|
90
|
+
const h = readQualityHistory(file)
|
|
91
|
+
const f = (filter || "").toLowerCase()
|
|
92
|
+
return Object.entries(h.pages)
|
|
93
|
+
.filter(([k]) => !f || k.toLowerCase().includes(f))
|
|
94
|
+
.map(([page, entries]) => {
|
|
95
|
+
const withA11y = entries.filter((e) => e.a11y)
|
|
96
|
+
const last = withA11y.at(-1)?.a11y
|
|
97
|
+
const prev = withA11y.at(-2)?.a11y
|
|
98
|
+
const withPerf = entries.filter((e) => e.perf)
|
|
99
|
+
const latest = withPerf.at(-1)?.perf || {}
|
|
100
|
+
const before = withPerf.slice(-WINDOW - 1, -1)
|
|
101
|
+
const usual: Entry["perf"] = {}
|
|
102
|
+
const worse: string[] = []
|
|
103
|
+
const better: string[] = []
|
|
104
|
+
for (const k of Object.keys(WORSE) as (keyof typeof WORSE)[]) {
|
|
105
|
+
const xs = before.map((e) => e.perf?.[k]).filter((v): v is number => typeof v === "number")
|
|
106
|
+
const now = latest[k]
|
|
107
|
+
if (!xs.length || typeof now !== "number") continue
|
|
108
|
+
const m = median(xs)
|
|
109
|
+
usual[k] = m
|
|
110
|
+
const { share, min, metric } = WORSE[k]
|
|
111
|
+
if (now - m > Math.max(min, m * share)) worse.push(`${k.toUpperCase()} ${fmtMetric(metric, now)} (usually ${fmtMetric(metric, m)})`)
|
|
112
|
+
else if (m - now > Math.max(min, m * share)) better.push(`${k.toUpperCase()} ${fmtMetric(metric, now)} (usually ${fmtMetric(metric, m)})`)
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
page,
|
|
116
|
+
runs: entries.length,
|
|
117
|
+
a11y: {
|
|
118
|
+
...(last ? { latest: { critical: last.critical, serious: last.serious, moderate: last.moderate, minor: last.minor } } : {}),
|
|
119
|
+
trail: withA11y.slice(-WINDOW).map((e) => IMPACTS.reduce((a, i) => a + (e.a11y![i] || 0), 0)),
|
|
120
|
+
newRules: last && prev ? last.rules.filter((r) => !prev.rules.includes(r)) : [],
|
|
121
|
+
fixedRules: last && prev ? prev.rules.filter((r) => !last.rules.includes(r)) : [],
|
|
122
|
+
},
|
|
123
|
+
perf: { latest, usual, worse, better },
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
.sort((a, b) => b.perf.worse.length + b.a11y.newRules.length - (a.perf.worse.length + a.a11y.newRules.length) || b.runs - a.runs)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function trendsCard(trends: PageTrend[], file: string, cwd: string): Card {
|
|
130
|
+
const body: string[] = []
|
|
131
|
+
for (const t of trends.slice(0, 20)) {
|
|
132
|
+
const a = t.a11y.latest
|
|
133
|
+
const total = a ? IMPACTS.reduce((s, i) => s + a[i], 0) : undefined
|
|
134
|
+
body.push(`▸ ${t.page} · ${t.runs} run${t.runs === 1 ? "" : "s"}`)
|
|
135
|
+
if (a) body.push(` accessibility: ${total} violation${total === 1 ? "" : "s"}${total ? ` (${IMPACTS.filter((i) => a[i]).map((i) => `${a[i]} ${i}`).join(", ")})` : ""} trend ${t.a11y.trail.join(" → ")}`)
|
|
136
|
+
if (t.a11y.newRules.length) body.push(` ✗ new: ${t.a11y.newRules.join(", ")}`)
|
|
137
|
+
if (t.a11y.fixedRules.length) body.push(` ✓ fixed: ${t.a11y.fixedRules.join(", ")}`)
|
|
138
|
+
const l = t.perf.latest || {}
|
|
139
|
+
const shown = (["lcp", "cls", "inp", "fcp", "ttfb"] as const).filter((k) => typeof l[k] === "number").map((k) => `${k.toUpperCase()} ${fmtMetric(WORSE[k].metric, l[k]!)}`)
|
|
140
|
+
if (shown.length) body.push(` timings: ${shown.join(", ")}`)
|
|
141
|
+
for (const w of t.perf.worse) body.push(` ✗ slower: ${w}`)
|
|
142
|
+
for (const b of t.perf.better) body.push(` ✓ faster: ${b}`)
|
|
143
|
+
}
|
|
144
|
+
if (trends.length > 20) body.push(`and ${trends.length - 20} more pages`)
|
|
145
|
+
const rel = path.relative(cwd, file)
|
|
146
|
+
body.push("", `History ${rel && !rel.startsWith("..") ? rel : file}`)
|
|
147
|
+
const regress = trends.filter((t) => t.perf.worse.length || t.a11y.newRules.length).length
|
|
148
|
+
return { status: regress ? "fail" : trends.length ? "ok" : "stopped", title: trends.length ? `Accessibility and performance over time: ${regress ? `${regress} page${regress === 1 ? "" : "s"} got worse` : "nothing got worse"}` : "No accessibility or performance history yet", stats: `${trends.length} page${trends.length === 1 ? "" : "s"}`, body }
|
|
149
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import pixelmatch from "pixelmatch"
|
|
5
|
+
import { PNG } from "pngjs"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Visual comparison: each page a run visits is captured (animations frozen, masked regions hidden) and compared
|
|
9
|
+
* pixel by pixel with its approved baseline. A page with no baseline yet gets its first capture as the baseline. The
|
|
10
|
+
* changed share of pixels is reported for every page; a run fails on it only when quality.budgets.visual sets a
|
|
11
|
+
* limit. Baselines are per page, viewport size, and operating system (fonts render differently), in
|
|
12
|
+
* .forgeqe/visual-baselines/ next to forgeqe.yaml: commit them to share them, and approve a change on purpose.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type VisualConfig = {
|
|
16
|
+
enabled: boolean
|
|
17
|
+
/** Selectors hidden on every page before capture (dates, ads, carousels). */
|
|
18
|
+
mask: string[]
|
|
19
|
+
/** Per path prefix: more selectors to hide, and a page's own limit. */
|
|
20
|
+
pages: Record<string, { mask?: string[]; maxDiffPercent?: number }>
|
|
21
|
+
fullPage: boolean
|
|
22
|
+
/** How different two pixels' colours must be to count (0–1, pixelmatch's threshold). */
|
|
23
|
+
threshold: number
|
|
24
|
+
/** A page's first capture becomes its baseline. */
|
|
25
|
+
autoBaseline: boolean
|
|
26
|
+
baselineDir?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_VISUAL: VisualConfig = { enabled: true, mask: [], pages: {}, fullPage: false, threshold: 0.1, autoBaseline: true }
|
|
30
|
+
|
|
31
|
+
export function visualConfig(raw: unknown): VisualConfig {
|
|
32
|
+
const v = (raw && typeof raw === "object" ? raw : raw === false ? { enabled: false } : {}) as Record<string, any>
|
|
33
|
+
const pages: VisualConfig["pages"] = {}
|
|
34
|
+
for (const [k, p] of Object.entries((v.pages || {}) as Record<string, any>)) {
|
|
35
|
+
pages[k] = { ...(Array.isArray(p?.mask) ? { mask: p.mask.map(String) } : {}), ...(Number.isFinite(Number(p?.max_diff_percent)) && p?.max_diff_percent !== undefined ? { maxDiffPercent: Number(p.max_diff_percent) } : {}) }
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
enabled: v.enabled !== false,
|
|
39
|
+
mask: Array.isArray(v.mask) ? v.mask.map(String) : [],
|
|
40
|
+
pages,
|
|
41
|
+
fullPage: v.full_page === true,
|
|
42
|
+
threshold: Number(v.threshold) > 0 && Number(v.threshold) < 1 ? Number(v.threshold) : 0.1,
|
|
43
|
+
autoBaseline: v.auto_baseline !== false,
|
|
44
|
+
...(v.baseline_dir ? { baselineDir: String(v.baseline_dir) } : {}),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What the engine needs: masks by path prefix, and whether to capture the full page. */
|
|
49
|
+
export function engineVisual(v: VisualConfig): { mask: string[]; pages: Record<string, string[]>; full_page: boolean } | undefined {
|
|
50
|
+
if (!v.enabled) return undefined
|
|
51
|
+
return { mask: v.mask, pages: Object.fromEntries(Object.entries(v.pages).filter(([, p]) => p.mask?.length).map(([k, p]) => [k, p.mask!])), full_page: v.fullPage }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type Capture = { page: string; url: string; title?: string; file: string; full_page?: boolean }
|
|
55
|
+
export type VisualStatus = "same" | "changed" | "new" | "no_baseline"
|
|
56
|
+
export type VisualPage = {
|
|
57
|
+
page: string
|
|
58
|
+
url: string
|
|
59
|
+
title?: string
|
|
60
|
+
status: VisualStatus
|
|
61
|
+
/** Share of pixels that differ, in percent (two decimals). */
|
|
62
|
+
diffPercent?: number
|
|
63
|
+
diffPixels?: number
|
|
64
|
+
/** The run's capture, the baseline it was compared with, and the diff image (changed pixels in red). */
|
|
65
|
+
current: string
|
|
66
|
+
baseline?: string
|
|
67
|
+
diff?: string
|
|
68
|
+
size?: string
|
|
69
|
+
/** The page's limit from the budget, when one is set. */
|
|
70
|
+
limit?: number
|
|
71
|
+
over?: boolean
|
|
72
|
+
/** The sizes differ: a taller or wider page (counted as changed pixels). */
|
|
73
|
+
resized?: string
|
|
74
|
+
}
|
|
75
|
+
export type VisualReport = { baselineDir: string; pages: VisualPage[] }
|
|
76
|
+
|
|
77
|
+
export const readCaptures = (runDir: string): Capture[] => {
|
|
78
|
+
try {
|
|
79
|
+
const v = JSON.parse(readFileSync(path.join(runDir, "visual.json"), "utf8"))
|
|
80
|
+
return Array.isArray(v) ? v : []
|
|
81
|
+
} catch {
|
|
82
|
+
return []
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A page's baseline file: host/path, the image size, and the operating system. */
|
|
87
|
+
export function baselineName(page: string, png: { width: number; height: number }, fullPage?: boolean): string {
|
|
88
|
+
let host = "page"
|
|
89
|
+
let p = page
|
|
90
|
+
try {
|
|
91
|
+
const u = new URL(page)
|
|
92
|
+
host = u.host.replace(/[^A-Za-z0-9.-]+/g, "_")
|
|
93
|
+
p = u.pathname
|
|
94
|
+
} catch {}
|
|
95
|
+
const slug = p.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 80) || "home"
|
|
96
|
+
return path.join(host, `${slug}-${fullPage ? `${png.width}w-full` : `${png.width}x${png.height}`}-${process.platform}.png`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function limitFor(v: VisualConfig, budget: number | undefined, url: string): number | undefined {
|
|
100
|
+
let p = url
|
|
101
|
+
try {
|
|
102
|
+
p = new URL(url).pathname
|
|
103
|
+
} catch {}
|
|
104
|
+
const match = Object.keys(v.pages)
|
|
105
|
+
.filter((k) => v.pages[k].maxDiffPercent !== undefined && (p === k || p.startsWith(k.endsWith("/") ? k : `${k}/`) || (k !== "/" && p.startsWith(k))))
|
|
106
|
+
.sort((a, b) => b.length - a.length)[0]
|
|
107
|
+
return match ? v.pages[match].maxDiffPercent : budget
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const read = (file: string) => PNG.sync.read(readFileSync(file))
|
|
111
|
+
|
|
112
|
+
/** Two images on one canvas of the larger size: pixels only one of them has count as changed. */
|
|
113
|
+
export function diffImages(a: PNG, b: PNG, threshold: number): { pixels: number; total: number; diff: PNG } {
|
|
114
|
+
const width = Math.max(a.width, b.width)
|
|
115
|
+
const height = Math.max(a.height, b.height)
|
|
116
|
+
const pad = (img: PNG) => {
|
|
117
|
+
if (img.width === width && img.height === height) return img.data
|
|
118
|
+
const out = new PNG({ width, height })
|
|
119
|
+
PNG.bitblt(img, out, 0, 0, img.width, img.height, 0, 0)
|
|
120
|
+
return out.data
|
|
121
|
+
}
|
|
122
|
+
const diff = new PNG({ width, height })
|
|
123
|
+
const pixels = pixelmatch(pad(a), pad(b), diff.data, width, height, { threshold })
|
|
124
|
+
return { pixels, total: width * height, diff }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Compare each page the run captured with its baseline, write the diff images, and make baselines for new pages. */
|
|
128
|
+
export function compareVisual(runDir: string, v: VisualConfig, baselineDir: string, budget?: number): VisualReport | null {
|
|
129
|
+
const captures = readCaptures(runDir)
|
|
130
|
+
if (!captures.length) return null
|
|
131
|
+
const pages: VisualPage[] = []
|
|
132
|
+
for (const c of captures) {
|
|
133
|
+
const current = path.join(runDir, c.file)
|
|
134
|
+
if (!existsSync(current)) continue
|
|
135
|
+
const img = read(current)
|
|
136
|
+
const name = baselineName(c.page, img, c.full_page)
|
|
137
|
+
const baseline = path.join(baselineDir, name)
|
|
138
|
+
const limit = limitFor(v, budget, c.url)
|
|
139
|
+
const base: VisualPage = { page: c.page, url: c.url, ...(c.title ? { title: c.title } : {}), status: "new", current, size: `${img.width}×${img.height}`, ...(limit !== undefined ? { limit } : {}) }
|
|
140
|
+
if (!existsSync(baseline)) {
|
|
141
|
+
if (v.autoBaseline) {
|
|
142
|
+
mkdirSync(path.dirname(baseline), { recursive: true })
|
|
143
|
+
copyFileSync(current, baseline)
|
|
144
|
+
recordApproval(baselineDir, name, c, path.basename(runDir), "first capture")
|
|
145
|
+
pages.push({ ...base, status: "new", baseline })
|
|
146
|
+
} else pages.push({ ...base, status: "no_baseline" })
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
const old = read(baseline)
|
|
150
|
+
const { pixels, total, diff } = diffImages(old, img, v.threshold)
|
|
151
|
+
const percent = Math.round((pixels / total) * 10000) / 100
|
|
152
|
+
const resized = old.width !== img.width || old.height !== img.height ? `${old.width}×${old.height} → ${img.width}×${img.height}` : undefined
|
|
153
|
+
let diffFile: string | undefined
|
|
154
|
+
if (pixels) {
|
|
155
|
+
diffFile = current.replace(/\.png$/, "-diff.png")
|
|
156
|
+
writeFileSync(diffFile, PNG.sync.write(diff))
|
|
157
|
+
}
|
|
158
|
+
pages.push({ ...base, status: pixels ? "changed" : "same", diffPercent: percent, diffPixels: pixels, baseline, ...(diffFile ? { diff: diffFile } : {}), ...(resized ? { resized } : {}), ...(limit !== undefined ? { over: percent > limit } : {}) })
|
|
159
|
+
}
|
|
160
|
+
const report = { baselineDir, pages }
|
|
161
|
+
writeFileSync(path.join(runDir, "visual-result.json"), `${JSON.stringify(report, null, 2)}\n`)
|
|
162
|
+
return report
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
type Approval = { file: string; page: string; run: string; at: string; why: string }
|
|
166
|
+
|
|
167
|
+
function recordApproval(baselineDir: string, name: string, c: Pick<Capture, "page">, run: string, why: string): void {
|
|
168
|
+
const index = path.join(baselineDir, "baselines.json")
|
|
169
|
+
let data: Record<string, Approval> = {}
|
|
170
|
+
try {
|
|
171
|
+
data = JSON.parse(readFileSync(index, "utf8"))
|
|
172
|
+
} catch {}
|
|
173
|
+
data[name.split(path.sep).join("/")] = { file: name.split(path.sep).join("/"), page: c.page, run, at: new Date().toISOString(), why }
|
|
174
|
+
mkdirSync(baselineDir, { recursive: true })
|
|
175
|
+
writeFileSync(index, `${JSON.stringify(Object.fromEntries(Object.entries(data).sort(([a], [b]) => a.localeCompare(b))), null, 2)}\n`)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Make a run's captures the baselines: every page, or the pages whose URL contains one of `only`. */
|
|
179
|
+
export function approveVisual(runDir: string, baselineDir: string, only: string[] = [], why = "approved"): { page: string; baseline: string }[] {
|
|
180
|
+
const out: { page: string; baseline: string }[] = []
|
|
181
|
+
const filter = only.map((o) => o.toLowerCase())
|
|
182
|
+
for (const c of readCaptures(runDir)) {
|
|
183
|
+
if (filter.length && !filter.some((f) => c.page.toLowerCase().includes(f) || c.url.toLowerCase().includes(f))) continue
|
|
184
|
+
const current = path.join(runDir, c.file)
|
|
185
|
+
if (!existsSync(current)) continue
|
|
186
|
+
const name = baselineName(c.page, read(current), c.full_page)
|
|
187
|
+
const target = path.join(baselineDir, name)
|
|
188
|
+
mkdirSync(path.dirname(target), { recursive: true })
|
|
189
|
+
copyFileSync(current, target)
|
|
190
|
+
recordApproval(baselineDir, name, c, path.basename(runDir), why)
|
|
191
|
+
out.push({ page: c.page, baseline: target })
|
|
192
|
+
}
|
|
193
|
+
return out
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function visualLine(r: VisualReport): string {
|
|
197
|
+
const n = (s: VisualStatus) => r.pages.filter((p) => p.status === s).length
|
|
198
|
+
const changed = r.pages.filter((p) => p.status === "changed")
|
|
199
|
+
const most = changed.sort((a, b) => (b.diffPercent || 0) - (a.diffPercent || 0))[0]
|
|
200
|
+
return [`${n("same")} same`, changed.length ? `${changed.length} changed${most ? ` (up to ${most.diffPercent}%)` : ""}` : "", n("new") ? `${n("new")} new baseline${n("new") === 1 ? "" : "s"}` : "", n("no_baseline") ? `${n("no_baseline")} without a baseline` : ""].filter(Boolean).join(", ")
|
|
201
|
+
}
|