@trailmark/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/baseline.d.ts +21 -0
- package/dist/baseline.d.ts.map +1 -0
- package/dist/baseline.js +187 -0
- package/dist/baseline.js.map +1 -0
- package/dist/diff.d.ts +3 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +120 -0
- package/dist/diff.js.map +1 -0
- package/dist/fingerprint.d.ts +8 -0
- package/dist/fingerprint.d.ts.map +1 -0
- package/dist/fingerprint.js +85 -0
- package/dist/fingerprint.js.map +1 -0
- package/dist/hash.d.ts +4 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +29 -0
- package/dist/hash.js.map +1 -0
- package/dist/impact.d.ts +5 -0
- package/dist/impact.d.ts.map +1 -0
- package/dist/impact.js +27 -0
- package/dist/impact.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/issue.d.ts +10 -0
- package/dist/issue.d.ts.map +1 -0
- package/dist/issue.js +40 -0
- package/dist/issue.js.map +1 -0
- package/dist/normalize.d.ts +3 -0
- package/dist/normalize.d.ts.map +1 -0
- package/dist/normalize.js +59 -0
- package/dist/normalize.js.map +1 -0
- package/dist/report/buildReport.d.ts +3 -0
- package/dist/report/buildReport.d.ts.map +1 -0
- package/dist/report/buildReport.js +223 -0
- package/dist/report/buildReport.js.map +1 -0
- package/dist/report/formatConsole.d.ts +3 -0
- package/dist/report/formatConsole.d.ts.map +1 -0
- package/dist/report/formatConsole.js +112 -0
- package/dist/report/formatConsole.js.map +1 -0
- package/dist/report/index.d.ts +4 -0
- package/dist/report/index.d.ts.map +1 -0
- package/dist/report/index.js +20 -0
- package/dist/report/index.js.map +1 -0
- package/dist/report/sort.d.ts +9 -0
- package/dist/report/sort.d.ts.map +1 -0
- package/dist/report/sort.js +20 -0
- package/dist/report/sort.js.map +1 -0
- package/dist/report/types.d.ts +85 -0
- package/dist/report/types.d.ts.map +1 -0
- package/dist/report/types.js +3 -0
- package/dist/report/types.js.map +1 -0
- package/dist/run.d.ts +8 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +20 -0
- package/dist/run.js.map +1 -0
- package/dist/scope.d.ts +5 -0
- package/dist/scope.d.ts.map +1 -0
- package/dist/scope.js +23 -0
- package/dist/scope.js.map +1 -0
- package/dist/types.d.ts +150 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/url.d.ts +3 -0
- package/dist/url.d.ts.map +1 -0
- package/dist/url.js +82 -0
- package/dist/url.js.map +1 -0
- package/package.json +23 -0
- package/src/baseline.ts +217 -0
- package/src/diff.ts +150 -0
- package/src/fingerprint.ts +107 -0
- package/src/hash.ts +29 -0
- package/src/impact.ts +27 -0
- package/src/index.ts +11 -0
- package/src/issue.ts +49 -0
- package/src/normalize.ts +66 -0
- package/src/report/buildReport.ts +276 -0
- package/src/report/formatConsole.ts +137 -0
- package/src/report/index.ts +3 -0
- package/src/report/sort.ts +28 -0
- package/src/report/types.ts +101 -0
- package/src/run.ts +22 -0
- package/src/scope.ts +25 -0
- package/src/types.ts +167 -0
- package/src/url.ts +102 -0
- package/test/baseline.test.ts +151 -0
- package/test/diff.test.ts +113 -0
- package/test/fingerprint.test.ts +41 -0
- package/test/issue.test.ts +79 -0
- package/test/report.build.test.ts +191 -0
- package/test/report.console.test.ts +102 -0
- package/test/url.test.ts +25 -0
- package/tsconfig.json +8 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
export type TrailmarkImpactKey = "minor" | "moderate" | "serious" | "critical" | "unknown"
|
|
2
|
+
|
|
3
|
+
export type TrailmarkImpact = "minor" | "moderate" | "serious" | "critical" | null | undefined
|
|
4
|
+
|
|
5
|
+
export interface UrlRoutePattern {
|
|
6
|
+
match: string
|
|
7
|
+
replace: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface UrlNormalizationOptions {
|
|
11
|
+
stripHash?: boolean
|
|
12
|
+
stripQuery?: boolean | { allow: string[] }
|
|
13
|
+
routePatterns?: UrlRoutePattern[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TrailmarkFindingMeta {
|
|
17
|
+
testId?: string
|
|
18
|
+
testTitle?: string
|
|
19
|
+
testFile?: string
|
|
20
|
+
projectName?: string
|
|
21
|
+
scanLabel?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TrailmarkScanError {
|
|
25
|
+
pageUrl?: string
|
|
26
|
+
pageKey?: string
|
|
27
|
+
message: string
|
|
28
|
+
stack?: string
|
|
29
|
+
occurredAt: string
|
|
30
|
+
meta?: TrailmarkFindingMeta
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TrailmarkAttachment {
|
|
34
|
+
kind: string
|
|
35
|
+
path?: string
|
|
36
|
+
error?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TrailmarkFinding {
|
|
40
|
+
issueId: string
|
|
41
|
+
relaxedIssueId: string
|
|
42
|
+
ruleId: string
|
|
43
|
+
impact?: TrailmarkImpact
|
|
44
|
+
tags: string[]
|
|
45
|
+
help?: string
|
|
46
|
+
helpUrl?: string
|
|
47
|
+
pageUrl: string
|
|
48
|
+
pageKey: string
|
|
49
|
+
target: string[]
|
|
50
|
+
selectorHint?: string
|
|
51
|
+
targetHash: string
|
|
52
|
+
nodeHtml?: string
|
|
53
|
+
failureSummary?: string
|
|
54
|
+
failureHint?: string
|
|
55
|
+
occurredAt?: string
|
|
56
|
+
occurrenceId?: string
|
|
57
|
+
attachments?: TrailmarkAttachment[]
|
|
58
|
+
meta?: TrailmarkFindingMeta
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface TrailmarkToolInfo {
|
|
62
|
+
name: string
|
|
63
|
+
version: string
|
|
64
|
+
scanner: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface TrailmarkRun {
|
|
68
|
+
version: 1
|
|
69
|
+
startedAt: string
|
|
70
|
+
finishedAt?: string
|
|
71
|
+
tool: TrailmarkToolInfo
|
|
72
|
+
findings: TrailmarkFinding[]
|
|
73
|
+
scanErrors: TrailmarkScanError[]
|
|
74
|
+
metadata?: Record<string, unknown>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface BaselineEntry {
|
|
78
|
+
issueId: string
|
|
79
|
+
scopeKey?: string
|
|
80
|
+
projectName?: string
|
|
81
|
+
relaxedIssueId: string
|
|
82
|
+
ruleId: string
|
|
83
|
+
impact?: TrailmarkImpact
|
|
84
|
+
pageKey: string
|
|
85
|
+
pageUrl: string
|
|
86
|
+
targetHash: string
|
|
87
|
+
selectorHint?: string
|
|
88
|
+
sampleTarget?: string
|
|
89
|
+
help?: string
|
|
90
|
+
helpUrl?: string
|
|
91
|
+
tags: string[]
|
|
92
|
+
failureHint?: string
|
|
93
|
+
occurrences: number
|
|
94
|
+
firstSeenAt: string
|
|
95
|
+
lastSeenAt: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface BaselineFile {
|
|
99
|
+
version: 1
|
|
100
|
+
createdAt: string
|
|
101
|
+
tool: TrailmarkToolInfo
|
|
102
|
+
configHash: string
|
|
103
|
+
findings: Record<string, BaselineEntry>
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface DiffMatch {
|
|
107
|
+
finding: TrailmarkFinding
|
|
108
|
+
matchedBy: "exact" | "relaxed"
|
|
109
|
+
baselineIssueId: string
|
|
110
|
+
baselineScopeKey?: string
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface DiffStats {
|
|
114
|
+
totalCurrent: number
|
|
115
|
+
totalBaseline: number
|
|
116
|
+
newCount: number
|
|
117
|
+
existingCount: number
|
|
118
|
+
resolvedCount: number
|
|
119
|
+
newByImpact: Record<string, number>
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface DiffResult {
|
|
123
|
+
newFindings: TrailmarkFinding[]
|
|
124
|
+
existing: DiffMatch[]
|
|
125
|
+
resolved: BaselineEntry[]
|
|
126
|
+
blockingNew: TrailmarkFinding[]
|
|
127
|
+
stats: DiffStats
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface DiffOptions {
|
|
131
|
+
failOnImpact?: string[]
|
|
132
|
+
onDebug?: (message: string, context?: Record<string, unknown>) => void
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface AxeNodeResult {
|
|
136
|
+
target: string[]
|
|
137
|
+
html?: string
|
|
138
|
+
failureSummary?: string
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface AxeViolation {
|
|
142
|
+
id: string
|
|
143
|
+
impact?: TrailmarkImpact
|
|
144
|
+
tags?: string[]
|
|
145
|
+
help?: string
|
|
146
|
+
helpUrl?: string
|
|
147
|
+
nodes: AxeNodeResult[]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface AxeLikeResults {
|
|
151
|
+
violations: AxeViolation[]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface NormalizeInput {
|
|
155
|
+
results: AxeLikeResults
|
|
156
|
+
pageUrl: string
|
|
157
|
+
urlOptions?: UrlNormalizationOptions
|
|
158
|
+
meta?: TrailmarkFindingMeta
|
|
159
|
+
htmlMaxLength?: number
|
|
160
|
+
failureSummaryMaxLength?: number
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface TargetFingerprintCanonical {
|
|
164
|
+
tag: string | null
|
|
165
|
+
stableAttrs: Record<string, string>
|
|
166
|
+
structuralHint: string | null
|
|
167
|
+
}
|
package/src/url.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { UrlNormalizationOptions } from "./types"
|
|
2
|
+
|
|
3
|
+
const DEFAULT_OPTIONS: Required<Pick<UrlNormalizationOptions, "stripHash" | "stripQuery">> = {
|
|
4
|
+
stripHash: true,
|
|
5
|
+
stripQuery: true,
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function normalizeQuery(params: URLSearchParams, allow?: Set<string>): string {
|
|
9
|
+
const keys = Array.from(new Set(params.keys()))
|
|
10
|
+
.filter((key) => !allow || allow.has(key))
|
|
11
|
+
.sort((a, b) => a.localeCompare(b))
|
|
12
|
+
|
|
13
|
+
const pairs: string[] = []
|
|
14
|
+
for (const key of keys) {
|
|
15
|
+
const values = params.getAll(key).sort((a, b) => a.localeCompare(b))
|
|
16
|
+
for (const value of values) {
|
|
17
|
+
pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return pairs.join("&")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function stripQueryFromFallback(
|
|
25
|
+
raw: string,
|
|
26
|
+
stripQuery: UrlNormalizationOptions["stripQuery"],
|
|
27
|
+
): string {
|
|
28
|
+
if (stripQuery === true) {
|
|
29
|
+
return raw.replace(/\?.*$/, "")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (stripQuery && typeof stripQuery === "object") {
|
|
33
|
+
const [base, rawQuery = ""] = raw.split("?")
|
|
34
|
+
if (!rawQuery) {
|
|
35
|
+
return base
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const allow = new Set(stripQuery.allow)
|
|
39
|
+
const params = new URLSearchParams(rawQuery)
|
|
40
|
+
const query = normalizeQuery(params, allow)
|
|
41
|
+
return query ? `${base}?${query}` : base
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return raw
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function applyRoutePatterns(
|
|
48
|
+
pageKey: string,
|
|
49
|
+
routePatterns: UrlNormalizationOptions["routePatterns"],
|
|
50
|
+
): string {
|
|
51
|
+
if (!routePatterns || routePatterns.length === 0) {
|
|
52
|
+
return pageKey
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let result = pageKey
|
|
56
|
+
for (const pattern of routePatterns) {
|
|
57
|
+
try {
|
|
58
|
+
const matcher = new RegExp(pattern.match)
|
|
59
|
+
if (matcher.test(result)) {
|
|
60
|
+
result = result.replace(matcher, pattern.replace)
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return result
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function normalizeUrlToPageKey(
|
|
71
|
+
rawUrl: string,
|
|
72
|
+
options: UrlNormalizationOptions = {},
|
|
73
|
+
): string {
|
|
74
|
+
const merged: UrlNormalizationOptions = {
|
|
75
|
+
...DEFAULT_OPTIONS,
|
|
76
|
+
...options,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let pageKey: string
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const parsed = new URL(rawUrl)
|
|
83
|
+
const host = parsed.host.toLowerCase()
|
|
84
|
+
const pathname = parsed.pathname || "/"
|
|
85
|
+
|
|
86
|
+
let query = ""
|
|
87
|
+
if (merged.stripQuery === false) {
|
|
88
|
+
query = normalizeQuery(parsed.searchParams)
|
|
89
|
+
} else if (merged.stripQuery && typeof merged.stripQuery === "object") {
|
|
90
|
+
query = normalizeQuery(parsed.searchParams, new Set(merged.stripQuery.allow))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const hash = !merged.stripHash ? parsed.hash : ""
|
|
94
|
+
pageKey = `${host}${pathname}${query ? `?${query}` : ""}${hash}`
|
|
95
|
+
} catch {
|
|
96
|
+
const lowered = rawUrl.trim().toLowerCase()
|
|
97
|
+
const withoutHash = merged.stripHash ? lowered.replace(/#.*$/, "") : lowered
|
|
98
|
+
pageKey = stripQueryFromFallback(withoutHash, merged.stripQuery)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return applyRoutePatterns(pageKey, merged.routePatterns)
|
|
102
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { buildBaselineFromRun, parseBaselineFile } from "../src/baseline"
|
|
3
|
+
import { diffRuns } from "../src/diff"
|
|
4
|
+
import { createEmptyRun } from "../src/run"
|
|
5
|
+
import { TrailmarkFinding } from "../src/types"
|
|
6
|
+
|
|
7
|
+
function makeFinding(overrides: Partial<TrailmarkFinding> = {}): TrailmarkFinding {
|
|
8
|
+
return {
|
|
9
|
+
issueId: "issue-1",
|
|
10
|
+
relaxedIssueId: "relaxed-1",
|
|
11
|
+
ruleId: "color-contrast",
|
|
12
|
+
impact: "serious",
|
|
13
|
+
tags: ["wcag2aa"],
|
|
14
|
+
pageUrl: "https://example.com/checkout?order=123",
|
|
15
|
+
pageKey: "example.com/checkout",
|
|
16
|
+
target: ["button.cta"],
|
|
17
|
+
selectorHint: "button.cta",
|
|
18
|
+
targetHash: "target-1",
|
|
19
|
+
failureHint: "contrast ratio below threshold",
|
|
20
|
+
...overrides,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("buildBaselineFromRun", () => {
|
|
25
|
+
it("roundtrips as JSON with schema version and stable entry shape", () => {
|
|
26
|
+
const run = createEmptyRun({
|
|
27
|
+
startedAt: "2026-01-01T00:00:00.000Z",
|
|
28
|
+
tool: {
|
|
29
|
+
name: "trailmark",
|
|
30
|
+
version: "0.1.0",
|
|
31
|
+
scanner: "@axe-core/playwright",
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
run.findings.push(makeFinding())
|
|
36
|
+
run.findings.push(makeFinding())
|
|
37
|
+
|
|
38
|
+
const baseline = buildBaselineFromRun(run, {
|
|
39
|
+
createdAt: "2026-01-02T00:00:00.000Z",
|
|
40
|
+
configHash: "cfg-123",
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const parsed = JSON.parse(JSON.stringify(baseline)) as typeof baseline
|
|
44
|
+
expect(parsed.version).toBe(1)
|
|
45
|
+
expect(parsed.createdAt).toBe("2026-01-02T00:00:00.000Z")
|
|
46
|
+
expect(parsed.configHash).toBe("cfg-123")
|
|
47
|
+
expect(parsed.tool).toEqual(run.tool)
|
|
48
|
+
|
|
49
|
+
const entries = Object.values(parsed.findings)
|
|
50
|
+
expect(entries).toHaveLength(1)
|
|
51
|
+
expect(entries[0].occurrences).toBe(2)
|
|
52
|
+
|
|
53
|
+
const current = createEmptyRun()
|
|
54
|
+
current.findings.push(makeFinding())
|
|
55
|
+
|
|
56
|
+
const diff = diffRuns(parsed, current, { failOnImpact: ["serious", "critical"] })
|
|
57
|
+
expect(diff.newFindings).toHaveLength(0)
|
|
58
|
+
expect(diff.existing).toHaveLength(1)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("reuses previous baseline timestamps when findings are unchanged", () => {
|
|
62
|
+
const run = createEmptyRun({ startedAt: "2026-01-01T00:00:00.000Z" })
|
|
63
|
+
run.findings.push(makeFinding())
|
|
64
|
+
|
|
65
|
+
const first = buildBaselineFromRun(run, {
|
|
66
|
+
createdAt: "2026-01-02T00:00:00.000Z",
|
|
67
|
+
configHash: "cfg-123",
|
|
68
|
+
})
|
|
69
|
+
const second = buildBaselineFromRun(run, {
|
|
70
|
+
configHash: "cfg-123",
|
|
71
|
+
previousBaseline: first,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
expect(second).toEqual(first)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe("parseBaselineFile", () => {
|
|
79
|
+
function makeBaselinePayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
80
|
+
return {
|
|
81
|
+
version: 1,
|
|
82
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
83
|
+
tool: { name: "trailmark", version: "0.1.0", scanner: "@axe-core/playwright" },
|
|
84
|
+
configHash: "cfg",
|
|
85
|
+
findings: {
|
|
86
|
+
"issue-1::scope": {
|
|
87
|
+
issueId: "issue-1",
|
|
88
|
+
scopeKey: "scope",
|
|
89
|
+
projectName: "project-a",
|
|
90
|
+
relaxedIssueId: "relaxed-1",
|
|
91
|
+
ruleId: "color-contrast",
|
|
92
|
+
impact: "serious",
|
|
93
|
+
pageKey: "example.com/checkout",
|
|
94
|
+
pageUrl: "https://example.com/checkout",
|
|
95
|
+
targetHash: "target-1",
|
|
96
|
+
selectorHint: "button.cta",
|
|
97
|
+
sampleTarget: "button.cta",
|
|
98
|
+
help: "Fix contrast",
|
|
99
|
+
helpUrl: "https://example.com/help",
|
|
100
|
+
tags: ["wcag2a", "wcag2aa", "wcag2a"],
|
|
101
|
+
failureHint: "contrast ratio below threshold",
|
|
102
|
+
occurrences: 1,
|
|
103
|
+
firstSeenAt: "2026-01-01T00:00:00.000Z",
|
|
104
|
+
lastSeenAt: "2026-01-01T00:00:00.000Z",
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
...overrides,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
it("rejects unsupported versions with a clear error", () => {
|
|
112
|
+
const payload = {
|
|
113
|
+
version: 999,
|
|
114
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
115
|
+
tool: { name: "trailmark", version: "0.1.0", scanner: "@axe-core/playwright" },
|
|
116
|
+
configHash: "cfg",
|
|
117
|
+
findings: {},
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
expect(parseBaselineFile(JSON.stringify(payload)).error).toBeTruthy()
|
|
121
|
+
expect(parseBaselineFile(JSON.stringify(payload)).message).toContain(
|
|
122
|
+
"unsupported baseline version",
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it("normalizes tags while parsing", () => {
|
|
127
|
+
const { data: baseline, error } = parseBaselineFile(JSON.stringify(makeBaselinePayload()))
|
|
128
|
+
expect(error).toBeFalsy()
|
|
129
|
+
expect(baseline).not.toBeNull()
|
|
130
|
+
const entry = baseline!.findings["issue-1::scope"]
|
|
131
|
+
expect(entry.tags).toEqual(["wcag2a", "wcag2aa"])
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("reports nested schema violations", () => {
|
|
135
|
+
const findings = makeBaselinePayload().findings as Record<string, Record<string, unknown>>
|
|
136
|
+
|
|
137
|
+
const payload = makeBaselinePayload({
|
|
138
|
+
findings: {
|
|
139
|
+
"issue-1::scope": {
|
|
140
|
+
...findings["issue-1::scope"],
|
|
141
|
+
occurrences: -1,
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
expect(parseBaselineFile(JSON.stringify(payload)).error).toBeTruthy()
|
|
147
|
+
expect(parseBaselineFile(JSON.stringify(payload)).message).toContain(
|
|
148
|
+
'trailmark: invalid baseline format at "findings.issue-1::scope.occurrences"',
|
|
149
|
+
)
|
|
150
|
+
})
|
|
151
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { buildBaselineFromRun } from "../src/baseline"
|
|
3
|
+
import { diffRuns } from "../src/diff"
|
|
4
|
+
import { TrailmarkFinding, TrailmarkRun } from "../src/types"
|
|
5
|
+
|
|
6
|
+
function makeFinding(overrides: Partial<TrailmarkFinding>): TrailmarkFinding {
|
|
7
|
+
return {
|
|
8
|
+
issueId: "issue-a",
|
|
9
|
+
relaxedIssueId: "relaxed-a",
|
|
10
|
+
ruleId: "color-contrast",
|
|
11
|
+
impact: "serious",
|
|
12
|
+
tags: [],
|
|
13
|
+
pageUrl: "https://example.com/checkout",
|
|
14
|
+
pageKey: "example.com/checkout",
|
|
15
|
+
target: ["button.cta"],
|
|
16
|
+
targetHash: "target-a",
|
|
17
|
+
...overrides,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function makeRun(findings: TrailmarkFinding[]): TrailmarkRun {
|
|
22
|
+
return {
|
|
23
|
+
version: 1,
|
|
24
|
+
startedAt: "2026-01-01T00:00:00.000Z",
|
|
25
|
+
finishedAt: "2026-01-01T00:00:01.000Z",
|
|
26
|
+
tool: { name: "trailmark", version: "0.1.0", scanner: "@axe-core/playwright" },
|
|
27
|
+
findings,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("diffRuns", () => {
|
|
32
|
+
it("matches exact and relaxed signatures", () => {
|
|
33
|
+
const baselineRun = makeRun([
|
|
34
|
+
makeFinding({
|
|
35
|
+
issueId: "exact-id",
|
|
36
|
+
relaxedIssueId: "relaxed-same",
|
|
37
|
+
failureHint: "old hint",
|
|
38
|
+
}),
|
|
39
|
+
makeFinding({
|
|
40
|
+
issueId: "resolved-id",
|
|
41
|
+
relaxedIssueId: "resolved-relaxed",
|
|
42
|
+
pageKey: "example.com/account",
|
|
43
|
+
pageUrl: "https://example.com/account",
|
|
44
|
+
}),
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
const baseline = buildBaselineFromRun(baselineRun, { configHash: "cfg" })
|
|
48
|
+
|
|
49
|
+
const currentRun = makeRun([
|
|
50
|
+
makeFinding({
|
|
51
|
+
issueId: "exact-id",
|
|
52
|
+
relaxedIssueId: "relaxed-same",
|
|
53
|
+
}),
|
|
54
|
+
makeFinding({
|
|
55
|
+
issueId: "new-id-derived-from-new-failure",
|
|
56
|
+
relaxedIssueId: "relaxed-same",
|
|
57
|
+
failureHint: "changed hint",
|
|
58
|
+
}),
|
|
59
|
+
makeFinding({
|
|
60
|
+
issueId: "brand-new-id",
|
|
61
|
+
relaxedIssueId: "brand-new-relaxed",
|
|
62
|
+
impact: "critical",
|
|
63
|
+
pageKey: "example.com/cart",
|
|
64
|
+
pageUrl: "https://example.com/cart",
|
|
65
|
+
}),
|
|
66
|
+
])
|
|
67
|
+
|
|
68
|
+
const diff = diffRuns(baseline, currentRun, { failOnImpact: ["serious", "critical"] })
|
|
69
|
+
|
|
70
|
+
expect(diff.existing).toHaveLength(1)
|
|
71
|
+
expect(diff.existing[0].matchedBy).toBe("exact")
|
|
72
|
+
expect(diff.newFindings).toHaveLength(2)
|
|
73
|
+
expect(new Set(diff.newFindings.map((finding) => finding.issueId))).toEqual(
|
|
74
|
+
new Set(["new-id-derived-from-new-failure", "brand-new-id"]),
|
|
75
|
+
)
|
|
76
|
+
expect(diff.resolved).toHaveLength(1)
|
|
77
|
+
expect(diff.blockingNew).toHaveLength(2)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("enforces one-to-one relaxed matching cardinality", () => {
|
|
81
|
+
const baselineRun = makeRun([
|
|
82
|
+
makeFinding({
|
|
83
|
+
issueId: "baseline-id",
|
|
84
|
+
relaxedIssueId: "relaxed-shared",
|
|
85
|
+
failureHint: "old hint",
|
|
86
|
+
}),
|
|
87
|
+
])
|
|
88
|
+
const baseline = buildBaselineFromRun(baselineRun, { configHash: "cfg" })
|
|
89
|
+
|
|
90
|
+
const currentRun = makeRun([
|
|
91
|
+
makeFinding({
|
|
92
|
+
issueId: "candidate-1",
|
|
93
|
+
relaxedIssueId: "relaxed-shared",
|
|
94
|
+
failureHint: "changed hint one",
|
|
95
|
+
}),
|
|
96
|
+
makeFinding({
|
|
97
|
+
issueId: "candidate-2",
|
|
98
|
+
relaxedIssueId: "relaxed-shared",
|
|
99
|
+
failureHint: "changed hint two",
|
|
100
|
+
}),
|
|
101
|
+
])
|
|
102
|
+
|
|
103
|
+
const diff = diffRuns(baseline, currentRun, { failOnImpact: ["serious", "critical"] })
|
|
104
|
+
|
|
105
|
+
expect(diff.existing).toHaveLength(1)
|
|
106
|
+
expect(diff.existing[0].matchedBy).toBe("relaxed")
|
|
107
|
+
expect(diff.existing[0].baselineIssueId).toBe("baseline-id")
|
|
108
|
+
expect(diff.newFindings).toHaveLength(1)
|
|
109
|
+
expect(new Set(diff.newFindings.map((finding) => finding.issueId))).toEqual(
|
|
110
|
+
new Set(["candidate-2"]),
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { buildTargetFingerprint } from "../src/fingerprint"
|
|
3
|
+
|
|
4
|
+
describe("buildTargetFingerprint", () => {
|
|
5
|
+
it("produces deterministic hashes regardless of attribute order", () => {
|
|
6
|
+
const first = buildTargetFingerprint({
|
|
7
|
+
target: ["main > button:nth-child(5)"],
|
|
8
|
+
html: '<button data-testid="save" aria-label="Save" type="button">Save</button>',
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const second = buildTargetFingerprint({
|
|
12
|
+
target: ["main > button:nth-child(2)"],
|
|
13
|
+
html: '<button type="button" aria-label="Save" data-testid="save">Save</button>',
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
expect(first.targetHash).toBe(second.targetHash)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it("sanitizes selector hints", () => {
|
|
20
|
+
const result = buildTargetFingerprint({
|
|
21
|
+
target: ["#app > ul > li:nth-child(12) > a[data-id=123456]"],
|
|
22
|
+
html: '<a data-testid="item-link" href="/items/123456">Item</a>',
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
expect(result.selectorHint).toBe("a[data-id]")
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("extracts only stable attributes from node html", () => {
|
|
29
|
+
const result = buildTargetFingerprint({
|
|
30
|
+
target: ["form > input#email"],
|
|
31
|
+
html: '<input id="email" data-testid="email-input" class="input" aria-label="Email" />',
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
expect(result.canonical.tag).toBe("input")
|
|
35
|
+
expect(result.canonical.stableAttrs).toEqual({
|
|
36
|
+
"aria-label": "Email",
|
|
37
|
+
"data-testid": "email-input",
|
|
38
|
+
id: "email",
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { normalizeAxeResults } from "../src/normalize"
|
|
3
|
+
|
|
4
|
+
describe("issue identity", () => {
|
|
5
|
+
it("stays stable when impact changes", () => {
|
|
6
|
+
const base = {
|
|
7
|
+
violations: [
|
|
8
|
+
{
|
|
9
|
+
id: "color-contrast",
|
|
10
|
+
impact: "critical",
|
|
11
|
+
help: "Fix contrast",
|
|
12
|
+
helpUrl: "https://example.com",
|
|
13
|
+
tags: ["wcag2aa"],
|
|
14
|
+
nodes: [
|
|
15
|
+
{
|
|
16
|
+
target: ["button.cta"],
|
|
17
|
+
html: '<button data-testid="cta" aria-label="Continue">Continue</button>',
|
|
18
|
+
failureSummary: "Element has insufficient color contrast of 2.9:1",
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const changedImpact = {
|
|
26
|
+
...base,
|
|
27
|
+
violations: [{ ...base.violations[0], impact: "serious" }],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const first = normalizeAxeResults({ results: base, pageUrl: "https://example.com/checkout" })[0]
|
|
31
|
+
const second = normalizeAxeResults({
|
|
32
|
+
results: changedImpact,
|
|
33
|
+
pageUrl: "https://example.com/checkout",
|
|
34
|
+
})[0]
|
|
35
|
+
|
|
36
|
+
expect(first.issueId).toBe(second.issueId)
|
|
37
|
+
expect(first.relaxedIssueId).toBe(second.relaxedIssueId)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("normalizes numeric noise in failure summaries", () => {
|
|
41
|
+
const first = normalizeAxeResults({
|
|
42
|
+
results: {
|
|
43
|
+
violations: [
|
|
44
|
+
{
|
|
45
|
+
id: "aria-valid-attr-value",
|
|
46
|
+
nodes: [
|
|
47
|
+
{
|
|
48
|
+
target: ["input"],
|
|
49
|
+
html: '<input id="zip" />',
|
|
50
|
+
failureSummary: 'Expected value "123" but found "999"',
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
pageUrl: "https://example.com/form",
|
|
57
|
+
})[0]
|
|
58
|
+
|
|
59
|
+
const second = normalizeAxeResults({
|
|
60
|
+
results: {
|
|
61
|
+
violations: [
|
|
62
|
+
{
|
|
63
|
+
id: "aria-valid-attr-value",
|
|
64
|
+
nodes: [
|
|
65
|
+
{
|
|
66
|
+
target: ["input"],
|
|
67
|
+
html: '<input id="zip" />',
|
|
68
|
+
failureSummary: 'Expected value "456" but found "111"',
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
pageUrl: "https://example.com/form",
|
|
75
|
+
})[0]
|
|
76
|
+
|
|
77
|
+
expect(first.issueId).toBe(second.issueId)
|
|
78
|
+
})
|
|
79
|
+
})
|