@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
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { hashObject } from "../hash"
|
|
2
|
+
import {
|
|
3
|
+
BuildReportInput,
|
|
4
|
+
TrailmarkReport,
|
|
5
|
+
TrailmarkReportIssue,
|
|
6
|
+
TrailmarkReportOccurrence,
|
|
7
|
+
} from "./types"
|
|
8
|
+
import { TrailmarkAttachment, TrailmarkFinding } from "../types"
|
|
9
|
+
import { impactKey, impactRank } from "../impact"
|
|
10
|
+
import { compareIssueLike } from "./sort"
|
|
11
|
+
|
|
12
|
+
type MutableIssue = TrailmarkReportIssue & {
|
|
13
|
+
occurrenceKeys: Set<string>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function dedupeAndSortTags(tags: string[]): string[] {
|
|
17
|
+
return Array.from(new Set(tags)).sort((left, right) => left.localeCompare(right))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function compareOccurrence(
|
|
21
|
+
left: TrailmarkReportOccurrence,
|
|
22
|
+
right: TrailmarkReportOccurrence,
|
|
23
|
+
): number {
|
|
24
|
+
const projectDiff = (left.projectName ?? "").localeCompare(right.projectName ?? "")
|
|
25
|
+
if (projectDiff !== 0) {
|
|
26
|
+
return projectDiff
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const testDiff = (left.testId ?? "").localeCompare(right.testId ?? "")
|
|
30
|
+
if (testDiff !== 0) {
|
|
31
|
+
return testDiff
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const urlDiff = left.url.localeCompare(right.url)
|
|
35
|
+
if (urlDiff !== 0) {
|
|
36
|
+
return urlDiff
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const timestampDiff = left.timestamp.localeCompare(right.timestamp)
|
|
40
|
+
if (timestampDiff !== 0) {
|
|
41
|
+
return timestampDiff
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (left.occurrenceId ?? "").localeCompare(right.occurrenceId ?? "")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function toSortedRecord(map: Map<string, number>): Record<string, number> {
|
|
48
|
+
const sorted = Array.from(map.entries()).sort((left, right) => left[0].localeCompare(right[0]))
|
|
49
|
+
const record: Record<string, number> = {}
|
|
50
|
+
for (const [key, value] of sorted) {
|
|
51
|
+
record[key] = value
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return record
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function toSortedAttachment(attachment: TrailmarkAttachment): TrailmarkAttachment {
|
|
58
|
+
return {
|
|
59
|
+
kind: attachment.kind,
|
|
60
|
+
path: attachment.path,
|
|
61
|
+
error: attachment.error,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function compareAttachment(left: TrailmarkAttachment, right: TrailmarkAttachment): number {
|
|
66
|
+
const kindDiff = left.kind.localeCompare(right.kind)
|
|
67
|
+
if (kindDiff !== 0) {
|
|
68
|
+
return kindDiff
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const pathDiff = (left.path ?? "").localeCompare(right.path ?? "")
|
|
72
|
+
if (pathDiff !== 0) {
|
|
73
|
+
return pathDiff
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (left.error ?? "").localeCompare(right.error ?? "")
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function resolveAttachments(
|
|
80
|
+
finding: TrailmarkFinding,
|
|
81
|
+
artifacts: BuildReportInput["artifacts"],
|
|
82
|
+
): TrailmarkAttachment[] {
|
|
83
|
+
const fromOccurrenceId = finding.occurrenceId
|
|
84
|
+
? artifacts?.byOccurrenceId?.[finding.occurrenceId]
|
|
85
|
+
: undefined
|
|
86
|
+
const fromIssueId = artifacts?.byIssueId?.[finding.issueId]
|
|
87
|
+
const source = fromOccurrenceId ?? finding.attachments ?? fromIssueId ?? []
|
|
88
|
+
|
|
89
|
+
return [...source].map(toSortedAttachment).sort(compareAttachment)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function occurrenceKey(finding: TrailmarkFinding, occurrence: TrailmarkReportOccurrence): string {
|
|
93
|
+
if (finding.occurrenceId) {
|
|
94
|
+
return finding.occurrenceId
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return hashObject({
|
|
98
|
+
issueId: finding.issueId,
|
|
99
|
+
testId: occurrence.testId,
|
|
100
|
+
projectName: occurrence.projectName,
|
|
101
|
+
url: occurrence.url,
|
|
102
|
+
selector: finding.target[0] ?? finding.selectorHint ?? "",
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function findingSortKey(finding: TrailmarkFinding): string {
|
|
107
|
+
return [
|
|
108
|
+
impactRank(finding.impact),
|
|
109
|
+
finding.ruleId,
|
|
110
|
+
finding.pageKey,
|
|
111
|
+
finding.issueId,
|
|
112
|
+
finding.meta?.projectName ?? "",
|
|
113
|
+
finding.meta?.testId ?? "",
|
|
114
|
+
finding.target[0] ?? finding.selectorHint ?? "",
|
|
115
|
+
].join("|")
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function uniqueSorted(values: string[]): string[] {
|
|
119
|
+
return Array.from(new Set(values)).sort((left, right) => left.localeCompare(right))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function buildReport(input: BuildReportInput): TrailmarkReport {
|
|
123
|
+
const createdAt = input.createdAt ?? new Date().toISOString()
|
|
124
|
+
const issuesById = new Map<string, MutableIssue>()
|
|
125
|
+
const scannedPages = new Set<string>()
|
|
126
|
+
const sortedFindings = [...input.run.findings].sort((left, right) =>
|
|
127
|
+
findingSortKey(left).localeCompare(findingSortKey(right)),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
for (const finding of sortedFindings) {
|
|
131
|
+
scannedPages.add(finding.pageKey)
|
|
132
|
+
|
|
133
|
+
const existing = issuesById.get(finding.issueId)
|
|
134
|
+
const issue = existing ?? {
|
|
135
|
+
issueId: finding.issueId,
|
|
136
|
+
ruleId: finding.ruleId,
|
|
137
|
+
impact: finding.impact,
|
|
138
|
+
tags: dedupeAndSortTags(finding.tags),
|
|
139
|
+
pageKey: finding.pageKey,
|
|
140
|
+
url: finding.pageUrl,
|
|
141
|
+
target: [...finding.target],
|
|
142
|
+
selectorHint: finding.selectorHint,
|
|
143
|
+
failureSummary: finding.failureSummary,
|
|
144
|
+
occurrences: [],
|
|
145
|
+
occurrenceKeys: new Set<string>(),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!existing) {
|
|
149
|
+
issuesById.set(finding.issueId, issue)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const occurrence: TrailmarkReportOccurrence = {
|
|
153
|
+
occurrenceId: finding.occurrenceId,
|
|
154
|
+
testId: finding.meta?.testId,
|
|
155
|
+
projectName: finding.meta?.projectName,
|
|
156
|
+
url: finding.pageUrl,
|
|
157
|
+
timestamp: finding.occurredAt ?? input.run.finishedAt ?? input.run.startedAt,
|
|
158
|
+
attachments: resolveAttachments(finding, input.artifacts),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const key = occurrenceKey(finding, occurrence)
|
|
162
|
+
if (issue.occurrenceKeys.has(key)) {
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
issue.occurrenceKeys.add(key)
|
|
167
|
+
issue.occurrences.push(occurrence)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const issues = Array.from(issuesById.values())
|
|
171
|
+
.map((issue) => ({
|
|
172
|
+
issueId: issue.issueId,
|
|
173
|
+
ruleId: issue.ruleId,
|
|
174
|
+
impact: issue.impact,
|
|
175
|
+
tags: issue.tags,
|
|
176
|
+
pageKey: issue.pageKey,
|
|
177
|
+
url: issue.url,
|
|
178
|
+
target: issue.target,
|
|
179
|
+
selectorHint: issue.selectorHint,
|
|
180
|
+
failureSummary: issue.failureSummary,
|
|
181
|
+
occurrences: [...issue.occurrences].sort(compareOccurrence),
|
|
182
|
+
}))
|
|
183
|
+
.sort(compareIssueLike)
|
|
184
|
+
|
|
185
|
+
const byImpact = new Map<string, number>()
|
|
186
|
+
const byRuleId = new Map<string, number>()
|
|
187
|
+
const byPageKey = new Map<string, number>()
|
|
188
|
+
|
|
189
|
+
for (const issue of issues) {
|
|
190
|
+
const impact = impactKey(issue.impact)
|
|
191
|
+
byImpact.set(impact, (byImpact.get(impact) ?? 0) + 1)
|
|
192
|
+
byRuleId.set(issue.ruleId, (byRuleId.get(issue.ruleId) ?? 0) + 1)
|
|
193
|
+
byPageKey.set(issue.pageKey, (byPageKey.get(issue.pageKey) ?? 0) + 1)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const newIssueIds = uniqueSorted(input.diff?.newFindings.map((finding) => finding.issueId) ?? [])
|
|
197
|
+
const existingIssueIds = uniqueSorted(
|
|
198
|
+
input.diff?.existing.map((entry) => entry.finding.issueId) ?? [],
|
|
199
|
+
)
|
|
200
|
+
const resolvedIssueIds = uniqueSorted(input.diff?.resolved.map((entry) => entry.issueId) ?? [])
|
|
201
|
+
|
|
202
|
+
const summary = {
|
|
203
|
+
scannedPages: scannedPages.size,
|
|
204
|
+
totalIssues: issues.length,
|
|
205
|
+
totalOccurrences: sortedFindings.length,
|
|
206
|
+
scanErrors: input.run.scanErrors.length,
|
|
207
|
+
newIssues: newIssueIds.length,
|
|
208
|
+
existingIssues: existingIssueIds.length,
|
|
209
|
+
resolvedIssues: resolvedIssueIds.length,
|
|
210
|
+
byImpact: toSortedRecord(byImpact),
|
|
211
|
+
byRuleId: toSortedRecord(byRuleId),
|
|
212
|
+
byPageKey: toSortedRecord(byPageKey),
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const report: TrailmarkReport = {
|
|
216
|
+
run: {
|
|
217
|
+
reportVersion: 2,
|
|
218
|
+
mode: input.mode,
|
|
219
|
+
createdAt,
|
|
220
|
+
startedAt: input.run.startedAt,
|
|
221
|
+
finishedAt: input.run.finishedAt,
|
|
222
|
+
configHash: input.configHash,
|
|
223
|
+
tool: input.run.tool,
|
|
224
|
+
engine: {
|
|
225
|
+
scanner: input.run.tool.scanner,
|
|
226
|
+
runtime: "playwright",
|
|
227
|
+
},
|
|
228
|
+
metadata: input.run.metadata,
|
|
229
|
+
},
|
|
230
|
+
summary,
|
|
231
|
+
issues,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (input.diff) {
|
|
235
|
+
const issuesByIssueId = new Map(issues.map((issue) => [issue.issueId, issue]))
|
|
236
|
+
const newIssues = newIssueIds
|
|
237
|
+
.map((issueId) => {
|
|
238
|
+
const issue = issuesByIssueId.get(issueId)
|
|
239
|
+
if (!issue) {
|
|
240
|
+
const fallback = input.diff?.newFindings.find((finding) => finding.issueId === issueId)
|
|
241
|
+
if (!fallback) {
|
|
242
|
+
return undefined
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
issueId,
|
|
247
|
+
ruleId: fallback.ruleId,
|
|
248
|
+
impact: fallback.impact,
|
|
249
|
+
pageKey: fallback.pageKey,
|
|
250
|
+
selectorHint: fallback.target[0] ?? fallback.selectorHint,
|
|
251
|
+
occurrenceCount: 1,
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
issueId,
|
|
257
|
+
ruleId: issue.ruleId,
|
|
258
|
+
impact: issue.impact,
|
|
259
|
+
pageKey: issue.pageKey,
|
|
260
|
+
selectorHint: issue.target[0] ?? issue.selectorHint,
|
|
261
|
+
occurrenceCount: issue.occurrences.length,
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))
|
|
265
|
+
.sort(compareIssueLike)
|
|
266
|
+
|
|
267
|
+
report.diff = {
|
|
268
|
+
newIssueIds,
|
|
269
|
+
existingIssueIds,
|
|
270
|
+
resolvedIssueIds,
|
|
271
|
+
newIssues,
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return report
|
|
276
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { FormatConsoleOptions, TrailmarkReport, TrailmarkReportIssue } from "./types"
|
|
2
|
+
import { TrailmarkImpactKey } from "../types"
|
|
3
|
+
import { impactKey, impactRank } from "../impact"
|
|
4
|
+
import { compareIssueLike } from "./sort"
|
|
5
|
+
|
|
6
|
+
function topPages(issues: TrailmarkReportIssue[], limit: number): string {
|
|
7
|
+
const counts = new Map<string, number>()
|
|
8
|
+
for (const issue of issues) {
|
|
9
|
+
counts.set(issue.pageKey, (counts.get(issue.pageKey) ?? 0) + 1)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return Array.from(counts.entries())
|
|
13
|
+
.sort((left, right) => {
|
|
14
|
+
const countDiff = right[1] - left[1]
|
|
15
|
+
if (countDiff !== 0) {
|
|
16
|
+
return countDiff
|
|
17
|
+
}
|
|
18
|
+
return left[0].localeCompare(right[0])
|
|
19
|
+
})
|
|
20
|
+
.slice(0, limit)
|
|
21
|
+
.map(([page]) => page)
|
|
22
|
+
.join(", ")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function firstArtifactPath(issue: TrailmarkReportIssue): string | undefined {
|
|
26
|
+
for (const occurrence of issue.occurrences) {
|
|
27
|
+
for (const attachment of occurrence.attachments) {
|
|
28
|
+
if (attachment.path) {
|
|
29
|
+
return attachment.path
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function firstReproHint(issue: TrailmarkReportIssue): string {
|
|
38
|
+
const occurrence = issue.occurrences[0]
|
|
39
|
+
if (!occurrence) {
|
|
40
|
+
return `url=${issue.url}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const segments: string[] = []
|
|
44
|
+
if (occurrence.testId) {
|
|
45
|
+
segments.push(`testId=${occurrence.testId}`)
|
|
46
|
+
}
|
|
47
|
+
segments.push(`url=${occurrence.url}`)
|
|
48
|
+
return segments.join(" ")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatConsoleReport(
|
|
52
|
+
report: TrailmarkReport,
|
|
53
|
+
options: FormatConsoleOptions = {},
|
|
54
|
+
): string {
|
|
55
|
+
const failOnImpact = options.failOnImpact ?? ["serious", "critical"]
|
|
56
|
+
const topPagesPerRule = options.topPagesPerRule ?? 3
|
|
57
|
+
const lines: string[] = []
|
|
58
|
+
|
|
59
|
+
lines.push(
|
|
60
|
+
`trailmark: scanned pages=${report.summary.scannedPages} | total issues=${report.summary.totalIssues} | NEW=${report.summary.newIssues} | fail on=${failOnImpact.join(", ")}`,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if (!report.diff) {
|
|
64
|
+
if (report.summary.scanErrors > 0) {
|
|
65
|
+
lines.push(`trailmark: scan errors=${report.summary.scanErrors}`)
|
|
66
|
+
}
|
|
67
|
+
return lines.join("\n")
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (report.diff.newIssueIds.length === 0) {
|
|
71
|
+
lines.push("trailmark: no new accessibility violations compared to baseline.")
|
|
72
|
+
if (report.summary.scanErrors > 0) {
|
|
73
|
+
lines.push(`trailmark: scan errors=${report.summary.scanErrors}`)
|
|
74
|
+
}
|
|
75
|
+
return lines.join("\n")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const newIssueIds = new Set(report.diff.newIssueIds)
|
|
79
|
+
const newIssues = report.issues
|
|
80
|
+
.filter((issue) => newIssueIds.has(issue.issueId))
|
|
81
|
+
.sort(compareIssueLike)
|
|
82
|
+
|
|
83
|
+
const byImpact = new Map<TrailmarkImpactKey, TrailmarkReportIssue[]>()
|
|
84
|
+
for (const issue of newIssues) {
|
|
85
|
+
const impact = impactKey(issue.impact)
|
|
86
|
+
const list = byImpact.get(impact) ?? []
|
|
87
|
+
list.push(issue)
|
|
88
|
+
byImpact.set(impact, list)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const sortedImpactKeys = Array.from(byImpact.keys()).sort((left, right) => {
|
|
92
|
+
const leftRank = impactRank(left)
|
|
93
|
+
const rightRank = impactRank(right)
|
|
94
|
+
if (leftRank !== rightRank) {
|
|
95
|
+
return leftRank - rightRank
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return left.localeCompare(right)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
for (const impact of sortedImpactKeys) {
|
|
102
|
+
const issues = byImpact.get(impact) ?? []
|
|
103
|
+
lines.push(`trailmark: [${impact}] ${issues.length} NEW issue(s)`)
|
|
104
|
+
|
|
105
|
+
const byRule = new Map<string, TrailmarkReportIssue[]>()
|
|
106
|
+
for (const issue of issues) {
|
|
107
|
+
const list = byRule.get(issue.ruleId) ?? []
|
|
108
|
+
list.push(issue)
|
|
109
|
+
byRule.set(issue.ruleId, list)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const sortedRuleIds = Array.from(byRule.keys()).sort((left, right) => left.localeCompare(right))
|
|
113
|
+
for (const ruleId of sortedRuleIds) {
|
|
114
|
+
const ruleIssues = (byRule.get(ruleId) ?? []).sort(compareIssueLike)
|
|
115
|
+
const pages = topPages(ruleIssues, topPagesPerRule)
|
|
116
|
+
lines.push(` rule ${ruleId} (${ruleIssues.length}) top pages: ${pages || "n/a"}`)
|
|
117
|
+
|
|
118
|
+
for (const issue of ruleIssues) {
|
|
119
|
+
const locator = issue.target[0] ?? issue.selectorHint ?? "<unknown-target>"
|
|
120
|
+
lines.push(` - ${impact} | ${issue.ruleId} | ${issue.pageKey} | ${locator}`)
|
|
121
|
+
|
|
122
|
+
const artifactPath = firstArtifactPath(issue)
|
|
123
|
+
if (artifactPath) {
|
|
124
|
+
lines.push(` artifact: ${artifactPath}`)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
lines.push(` reproduce: ${firstReproHint(issue)}`)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (report.summary.scanErrors > 0) {
|
|
133
|
+
lines.push(`trailmark: scan errors=${report.summary.scanErrors}`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return lines.join("\n")
|
|
137
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { impactRank } from "../impact"
|
|
2
|
+
import { TrailmarkImpact } from "../types"
|
|
3
|
+
|
|
4
|
+
export interface IssueSortInput {
|
|
5
|
+
issueId: string
|
|
6
|
+
ruleId: string
|
|
7
|
+
pageKey: string
|
|
8
|
+
impact?: TrailmarkImpact
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function compareIssueLike(left: IssueSortInput, right: IssueSortInput): number {
|
|
12
|
+
const impactDiff = impactRank(left.impact) - impactRank(right.impact)
|
|
13
|
+
if (impactDiff !== 0) {
|
|
14
|
+
return impactDiff
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ruleDiff = left.ruleId.localeCompare(right.ruleId)
|
|
18
|
+
if (ruleDiff !== 0) {
|
|
19
|
+
return ruleDiff
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const pageDiff = left.pageKey.localeCompare(right.pageKey)
|
|
23
|
+
if (pageDiff !== 0) {
|
|
24
|
+
return pageDiff
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return left.issueId.localeCompare(right.issueId)
|
|
28
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DiffResult,
|
|
3
|
+
TrailmarkAttachment,
|
|
4
|
+
TrailmarkImpact,
|
|
5
|
+
TrailmarkRun,
|
|
6
|
+
TrailmarkToolInfo,
|
|
7
|
+
} from "../types"
|
|
8
|
+
|
|
9
|
+
export type TrailmarkReportMode = "baseline" | "check"
|
|
10
|
+
|
|
11
|
+
export interface TrailmarkArtifactIndex {
|
|
12
|
+
byOccurrenceId?: Record<string, TrailmarkAttachment[]>
|
|
13
|
+
byIssueId?: Record<string, TrailmarkAttachment[]>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TrailmarkReportOccurrence {
|
|
17
|
+
occurrenceId?: string
|
|
18
|
+
testId?: string
|
|
19
|
+
projectName?: string
|
|
20
|
+
url: string
|
|
21
|
+
timestamp: string
|
|
22
|
+
attachments: TrailmarkAttachment[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TrailmarkReportIssue {
|
|
26
|
+
issueId: string
|
|
27
|
+
ruleId: string
|
|
28
|
+
impact?: TrailmarkImpact
|
|
29
|
+
tags: string[]
|
|
30
|
+
pageKey: string
|
|
31
|
+
url: string
|
|
32
|
+
target: string[]
|
|
33
|
+
selectorHint?: string
|
|
34
|
+
failureSummary?: string
|
|
35
|
+
occurrences: TrailmarkReportOccurrence[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TrailmarkReportDiffIssueSummary {
|
|
39
|
+
issueId: string
|
|
40
|
+
ruleId: string
|
|
41
|
+
impact?: TrailmarkImpact
|
|
42
|
+
pageKey: string
|
|
43
|
+
selectorHint?: string
|
|
44
|
+
occurrenceCount: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TrailmarkReportDiff {
|
|
48
|
+
newIssueIds: string[]
|
|
49
|
+
existingIssueIds: string[]
|
|
50
|
+
resolvedIssueIds: string[]
|
|
51
|
+
newIssues: TrailmarkReportDiffIssueSummary[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface TrailmarkReportSummary {
|
|
55
|
+
scannedPages: number
|
|
56
|
+
totalIssues: number
|
|
57
|
+
totalOccurrences: number
|
|
58
|
+
scanErrors: number
|
|
59
|
+
newIssues: number
|
|
60
|
+
existingIssues: number
|
|
61
|
+
resolvedIssues: number
|
|
62
|
+
byImpact: Record<string, number>
|
|
63
|
+
byRuleId: Record<string, number>
|
|
64
|
+
byPageKey: Record<string, number>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface TrailmarkReportRunMetadata {
|
|
68
|
+
reportVersion: 2
|
|
69
|
+
mode: TrailmarkReportMode
|
|
70
|
+
createdAt: string
|
|
71
|
+
startedAt: string
|
|
72
|
+
finishedAt?: string
|
|
73
|
+
configHash: string
|
|
74
|
+
tool: TrailmarkToolInfo
|
|
75
|
+
engine: {
|
|
76
|
+
scanner: string
|
|
77
|
+
runtime: "playwright"
|
|
78
|
+
}
|
|
79
|
+
metadata?: Record<string, unknown>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface TrailmarkReport {
|
|
83
|
+
run: TrailmarkReportRunMetadata
|
|
84
|
+
summary: TrailmarkReportSummary
|
|
85
|
+
issues: TrailmarkReportIssue[]
|
|
86
|
+
diff?: TrailmarkReportDiff
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface BuildReportInput {
|
|
90
|
+
run: TrailmarkRun
|
|
91
|
+
mode: TrailmarkReportMode
|
|
92
|
+
configHash: string
|
|
93
|
+
diff?: DiffResult
|
|
94
|
+
createdAt?: string
|
|
95
|
+
artifacts?: TrailmarkArtifactIndex
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface FormatConsoleOptions {
|
|
99
|
+
failOnImpact?: string[]
|
|
100
|
+
topPagesPerRule?: number
|
|
101
|
+
}
|
package/src/run.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { TrailmarkRun, TrailmarkToolInfo } from "./types"
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_TOOL_INFO: TrailmarkToolInfo = {
|
|
4
|
+
name: "trailmark",
|
|
5
|
+
version: "0.1.0",
|
|
6
|
+
scanner: "@axe-core/playwright",
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createEmptyRun(options?: {
|
|
10
|
+
metadata?: Record<string, unknown>
|
|
11
|
+
tool?: TrailmarkToolInfo
|
|
12
|
+
startedAt?: string
|
|
13
|
+
}): TrailmarkRun {
|
|
14
|
+
return {
|
|
15
|
+
version: 1,
|
|
16
|
+
startedAt: options?.startedAt ?? new Date().toISOString(),
|
|
17
|
+
tool: options?.tool ?? DEFAULT_TOOL_INFO,
|
|
18
|
+
findings: [],
|
|
19
|
+
scanErrors: [],
|
|
20
|
+
metadata: options?.metadata,
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/scope.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { BaselineEntry, TrailmarkFindingMeta } from "./types"
|
|
2
|
+
|
|
3
|
+
const DEFAULT_SCOPE_KEY = "default"
|
|
4
|
+
|
|
5
|
+
function normalizeScopeValue(value: string | undefined): string {
|
|
6
|
+
const normalized = value?.trim().toLowerCase()
|
|
7
|
+
if (!normalized) {
|
|
8
|
+
return DEFAULT_SCOPE_KEY
|
|
9
|
+
}
|
|
10
|
+
return normalized
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function scopeKeyFromMeta(meta: TrailmarkFindingMeta | undefined): string {
|
|
14
|
+
return normalizeScopeValue(meta?.projectName)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function scopeKeyFromBaselineEntry(
|
|
18
|
+
entry: Pick<BaselineEntry, "scopeKey" | "projectName">,
|
|
19
|
+
): string {
|
|
20
|
+
return normalizeScopeValue(entry.scopeKey ?? entry.projectName)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function scopedIssueKey(issueId: string, scopeKey: string): string {
|
|
24
|
+
return `${scopeKey}::${issueId}`
|
|
25
|
+
}
|