@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.
Files changed (94) hide show
  1. package/dist/baseline.d.ts +21 -0
  2. package/dist/baseline.d.ts.map +1 -0
  3. package/dist/baseline.js +187 -0
  4. package/dist/baseline.js.map +1 -0
  5. package/dist/diff.d.ts +3 -0
  6. package/dist/diff.d.ts.map +1 -0
  7. package/dist/diff.js +120 -0
  8. package/dist/diff.js.map +1 -0
  9. package/dist/fingerprint.d.ts +8 -0
  10. package/dist/fingerprint.d.ts.map +1 -0
  11. package/dist/fingerprint.js +85 -0
  12. package/dist/fingerprint.js.map +1 -0
  13. package/dist/hash.d.ts +4 -0
  14. package/dist/hash.d.ts.map +1 -0
  15. package/dist/hash.js +29 -0
  16. package/dist/hash.js.map +1 -0
  17. package/dist/impact.d.ts +5 -0
  18. package/dist/impact.d.ts.map +1 -0
  19. package/dist/impact.js +27 -0
  20. package/dist/impact.js.map +1 -0
  21. package/dist/index.d.ts +12 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +28 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/issue.d.ts +10 -0
  26. package/dist/issue.d.ts.map +1 -0
  27. package/dist/issue.js +40 -0
  28. package/dist/issue.js.map +1 -0
  29. package/dist/normalize.d.ts +3 -0
  30. package/dist/normalize.d.ts.map +1 -0
  31. package/dist/normalize.js +59 -0
  32. package/dist/normalize.js.map +1 -0
  33. package/dist/report/buildReport.d.ts +3 -0
  34. package/dist/report/buildReport.d.ts.map +1 -0
  35. package/dist/report/buildReport.js +223 -0
  36. package/dist/report/buildReport.js.map +1 -0
  37. package/dist/report/formatConsole.d.ts +3 -0
  38. package/dist/report/formatConsole.d.ts.map +1 -0
  39. package/dist/report/formatConsole.js +112 -0
  40. package/dist/report/formatConsole.js.map +1 -0
  41. package/dist/report/index.d.ts +4 -0
  42. package/dist/report/index.d.ts.map +1 -0
  43. package/dist/report/index.js +20 -0
  44. package/dist/report/index.js.map +1 -0
  45. package/dist/report/sort.d.ts +9 -0
  46. package/dist/report/sort.d.ts.map +1 -0
  47. package/dist/report/sort.js +20 -0
  48. package/dist/report/sort.js.map +1 -0
  49. package/dist/report/types.d.ts +85 -0
  50. package/dist/report/types.d.ts.map +1 -0
  51. package/dist/report/types.js +3 -0
  52. package/dist/report/types.js.map +1 -0
  53. package/dist/run.d.ts +8 -0
  54. package/dist/run.d.ts.map +1 -0
  55. package/dist/run.js +20 -0
  56. package/dist/run.js.map +1 -0
  57. package/dist/scope.d.ts +5 -0
  58. package/dist/scope.d.ts.map +1 -0
  59. package/dist/scope.js +23 -0
  60. package/dist/scope.js.map +1 -0
  61. package/dist/types.d.ts +150 -0
  62. package/dist/types.d.ts.map +1 -0
  63. package/dist/types.js +3 -0
  64. package/dist/types.js.map +1 -0
  65. package/dist/url.d.ts +3 -0
  66. package/dist/url.d.ts.map +1 -0
  67. package/dist/url.js +82 -0
  68. package/dist/url.js.map +1 -0
  69. package/package.json +23 -0
  70. package/src/baseline.ts +217 -0
  71. package/src/diff.ts +150 -0
  72. package/src/fingerprint.ts +107 -0
  73. package/src/hash.ts +29 -0
  74. package/src/impact.ts +27 -0
  75. package/src/index.ts +11 -0
  76. package/src/issue.ts +49 -0
  77. package/src/normalize.ts +66 -0
  78. package/src/report/buildReport.ts +276 -0
  79. package/src/report/formatConsole.ts +137 -0
  80. package/src/report/index.ts +3 -0
  81. package/src/report/sort.ts +28 -0
  82. package/src/report/types.ts +101 -0
  83. package/src/run.ts +22 -0
  84. package/src/scope.ts +25 -0
  85. package/src/types.ts +167 -0
  86. package/src/url.ts +102 -0
  87. package/test/baseline.test.ts +151 -0
  88. package/test/diff.test.ts +113 -0
  89. package/test/fingerprint.test.ts +41 -0
  90. package/test/issue.test.ts +79 -0
  91. package/test/report.build.test.ts +191 -0
  92. package/test/report.console.test.ts +102 -0
  93. package/test/url.test.ts +25 -0
  94. 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
+ })