@trailmark/playwright 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/.trailmark/current-run.json +136 -0
- package/dist/artifacts/artifactManager.d.ts +31 -0
- package/dist/artifacts/artifactManager.d.ts.map +1 -0
- package/dist/artifacts/artifactManager.js +140 -0
- package/dist/artifacts/artifactManager.js.map +1 -0
- package/dist/artifacts/capture.d.ts +4 -0
- package/dist/artifacts/capture.d.ts.map +1 -0
- package/dist/artifacts/capture.js +16 -0
- package/dist/artifacts/capture.js.map +1 -0
- package/dist/artifacts/options.d.ts +11 -0
- package/dist/artifacts/options.d.ts.map +1 -0
- package/dist/artifacts/options.js +55 -0
- package/dist/artifacts/options.js.map +1 -0
- package/dist/artifacts/registry.d.ts +24 -0
- package/dist/artifacts/registry.d.ts.map +1 -0
- package/dist/artifacts/registry.js +99 -0
- package/dist/artifacts/registry.js.map +1 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +5 -0
- package/dist/constants.js.map +1 -0
- package/dist/fixture.d.ts +7 -0
- package/dist/fixture.d.ts.map +1 -0
- package/dist/fixture.js +193 -0
- package/dist/fixture.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/mode.d.ts +3 -0
- package/dist/mode.d.ts.map +1 -0
- package/dist/mode.js +9 -0
- package/dist/mode.js.map +1 -0
- package/dist/reporter/attachments.d.ts +4 -0
- package/dist/reporter/attachments.d.ts.map +1 -0
- package/dist/reporter/attachments.js +112 -0
- package/dist/reporter/attachments.js.map +1 -0
- package/dist/reporter/errors.d.ts +4 -0
- package/dist/reporter/errors.d.ts.map +1 -0
- package/dist/reporter/errors.js +11 -0
- package/dist/reporter/errors.js.map +1 -0
- package/dist/reporter/index.d.ts +33 -0
- package/dist/reporter/index.d.ts.map +1 -0
- package/dist/reporter/index.js +407 -0
- package/dist/reporter/index.js.map +1 -0
- package/dist/reporter/io.d.ts +15 -0
- package/dist/reporter/io.d.ts.map +1 -0
- package/dist/reporter/io.js +34 -0
- package/dist/reporter/io.js.map +1 -0
- package/dist/reporter/normalization.d.ts +13 -0
- package/dist/reporter/normalization.d.ts.map +1 -0
- package/dist/reporter/normalization.js +91 -0
- package/dist/reporter/normalization.js.map +1 -0
- package/dist/reporter/output.d.ts +5 -0
- package/dist/reporter/output.d.ts.map +1 -0
- package/dist/reporter/output.js +90 -0
- package/dist/reporter/output.js.map +1 -0
- package/dist/reporter/run.d.ts +4 -0
- package/dist/reporter/run.d.ts.map +1 -0
- package/dist/reporter/run.js +18 -0
- package/dist/reporter/run.js.map +1 -0
- package/dist/types.d.ts +48 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/integration/app/base.html +16 -0
- package/integration/app/extra.html +17 -0
- package/integration/playwright.config.ts +44 -0
- package/integration/specs/sample.spec.ts +15 -0
- package/integration/tmp/baseline.json +46 -0
- package/integration/tmp/report.json +97 -0
- package/package.json +35 -0
- package/src/artifacts/artifactManager.ts +178 -0
- package/src/artifacts/capture.ts +18 -0
- package/src/artifacts/options.ts +79 -0
- package/src/artifacts/registry.ts +143 -0
- package/src/constants.ts +1 -0
- package/src/fixture.ts +239 -0
- package/src/index.ts +3 -0
- package/src/mode.ts +7 -0
- package/src/reporter/attachments.ts +139 -0
- package/src/reporter/errors.ts +6 -0
- package/src/reporter/index.ts +572 -0
- package/src/reporter/io.ts +33 -0
- package/src/reporter/normalization.ts +122 -0
- package/src/reporter/output.ts +103 -0
- package/src/reporter/run.ts +17 -0
- package/src/types.ts +53 -0
- package/test/artifactManager.unit.test.ts +141 -0
- package/test/reporter.integration.test.ts +153 -0
- package/test/reporter.unit.test.ts +337 -0
- package/test-results/.last-run.json +4 -0
- package/trailmark-playwright-0.1.0.tgz +0 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import type {
|
|
3
|
+
FullConfig,
|
|
4
|
+
FullResult,
|
|
5
|
+
Reporter,
|
|
6
|
+
TestCase,
|
|
7
|
+
TestResult,
|
|
8
|
+
} from "@playwright/test/reporter"
|
|
9
|
+
import {
|
|
10
|
+
BaselineFile,
|
|
11
|
+
DiffResult,
|
|
12
|
+
TrailmarkArtifactIndex,
|
|
13
|
+
TrailmarkAttachment,
|
|
14
|
+
TrailmarkRun,
|
|
15
|
+
buildBaselineFromRun,
|
|
16
|
+
buildReport,
|
|
17
|
+
computeConfigHash,
|
|
18
|
+
createEmptyRun,
|
|
19
|
+
diffRuns,
|
|
20
|
+
formatConsoleReport,
|
|
21
|
+
hashObject,
|
|
22
|
+
} from "@trailmark/core"
|
|
23
|
+
import { resolveArtifactOptions } from "../artifacts/options"
|
|
24
|
+
import { TrailmarkMode, TrailmarkReporterOptions } from "../types"
|
|
25
|
+
import { readAttachmentPayload } from "./attachments"
|
|
26
|
+
import { readBaseline, writeJsonAtomic } from "./io"
|
|
27
|
+
import { applyUrlNormalization, detectIdentityCollisions } from "./normalization"
|
|
28
|
+
import { summarizeScanErrors } from "./output"
|
|
29
|
+
import { withRunShape } from "./run"
|
|
30
|
+
import { resolveMode } from "../mode"
|
|
31
|
+
import { ReporterBaselineError } from "./errors"
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_BASELINE_PATH = ".trailmark/baseline.json"
|
|
34
|
+
export const DEFAULT_REPORT_PATH = ".trailmark/report.json"
|
|
35
|
+
export const CURRENT_RUN_PATH = ".trailmark/current-run.json"
|
|
36
|
+
|
|
37
|
+
interface NormalizedReporterOptions {
|
|
38
|
+
baselinePath: string
|
|
39
|
+
reportPath: string
|
|
40
|
+
mode: TrailmarkMode
|
|
41
|
+
failOnImpact: string[]
|
|
42
|
+
failOnScanError: boolean
|
|
43
|
+
onConfigMismatch: "warn" | "error" | "ignore"
|
|
44
|
+
verbose: boolean
|
|
45
|
+
url: TrailmarkReporterOptions["url"]
|
|
46
|
+
artifacts: ReturnType<typeof resolveArtifactOptions>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type ReporterStatus = { status: "passed" | "failed" } | void
|
|
50
|
+
|
|
51
|
+
type ConfigMismatch = {
|
|
52
|
+
baselineConfigHash: string
|
|
53
|
+
currentConfigHash: string
|
|
54
|
+
action: "warn" | "error" | "ignore"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
58
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function toStringArray(value: unknown): string[] {
|
|
62
|
+
if (!Array.isArray(value)) {
|
|
63
|
+
return []
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return value.filter((entry): entry is string => typeof entry === "string")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function toAttachments(value: unknown): TrailmarkAttachment[] {
|
|
70
|
+
if (!Array.isArray(value)) {
|
|
71
|
+
return []
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return value
|
|
75
|
+
.filter((entry): entry is Record<string, unknown> => isRecord(entry))
|
|
76
|
+
.map((entry) => ({
|
|
77
|
+
kind: typeof entry.kind === "string" ? entry.kind : "unknown",
|
|
78
|
+
path: typeof entry.path === "string" ? entry.path : undefined,
|
|
79
|
+
error: typeof entry.error === "string" ? entry.error : undefined,
|
|
80
|
+
}))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function toArtifactIndex(value: unknown): TrailmarkArtifactIndex {
|
|
84
|
+
if (!isRecord(value)) {
|
|
85
|
+
return {}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const byOccurrenceId: Record<string, TrailmarkAttachment[]> = {}
|
|
89
|
+
const byIssueId: Record<string, TrailmarkAttachment[]> = {}
|
|
90
|
+
|
|
91
|
+
if (isRecord(value.byOccurrenceId)) {
|
|
92
|
+
for (const key of Object.keys(value.byOccurrenceId).sort((a, b) => a.localeCompare(b))) {
|
|
93
|
+
byOccurrenceId[key] = toAttachments(value.byOccurrenceId[key])
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (isRecord(value.byIssueId)) {
|
|
98
|
+
for (const key of Object.keys(value.byIssueId).sort((a, b) => a.localeCompare(b))) {
|
|
99
|
+
byIssueId[key] = toAttachments(value.byIssueId[key])
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
byOccurrenceId,
|
|
105
|
+
byIssueId,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function mergeArtifactIndex(
|
|
110
|
+
left: TrailmarkArtifactIndex,
|
|
111
|
+
right: TrailmarkArtifactIndex,
|
|
112
|
+
): TrailmarkArtifactIndex {
|
|
113
|
+
const byOccurrenceId: Record<string, TrailmarkAttachment[]> = {
|
|
114
|
+
...left.byOccurrenceId,
|
|
115
|
+
...right.byOccurrenceId,
|
|
116
|
+
}
|
|
117
|
+
const byIssueId: Record<string, TrailmarkAttachment[]> = {
|
|
118
|
+
...left.byIssueId,
|
|
119
|
+
...right.byIssueId,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
byOccurrenceId,
|
|
124
|
+
byIssueId,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function uniqueStrings(values: string[]): string[] {
|
|
129
|
+
return Array.from(new Set(values)).sort((left, right) => left.localeCompare(right))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function summarizeImpactCounts(counts: Record<string, number>): string {
|
|
133
|
+
const orderedImpacts = ["critical", "serious", "moderate", "minor", "unknown"]
|
|
134
|
+
const orderedEntries: Array<[string, number]> = []
|
|
135
|
+
|
|
136
|
+
for (const impact of orderedImpacts) {
|
|
137
|
+
const count = counts[impact]
|
|
138
|
+
if (!count) {
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
orderedEntries.push([impact, count])
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const impact of Object.keys(counts).sort((left, right) => left.localeCompare(right))) {
|
|
146
|
+
if (orderedImpacts.includes(impact)) {
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const count = counts[impact]
|
|
151
|
+
if (!count) {
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
orderedEntries.push([impact, count])
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (orderedEntries.length === 0) {
|
|
159
|
+
return "none"
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return orderedEntries.map(([impact, count]) => `${impact}=${count}`).join(", ")
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export default class TrailmarkReporter implements Reporter {
|
|
166
|
+
private readonly options: NormalizedReporterOptions
|
|
167
|
+
|
|
168
|
+
private config: FullConfig | undefined
|
|
169
|
+
|
|
170
|
+
private sourceRuns = 0
|
|
171
|
+
|
|
172
|
+
private seenRunHashes = new Set<string>()
|
|
173
|
+
|
|
174
|
+
private mergedRun = this.createMergedRun()
|
|
175
|
+
|
|
176
|
+
constructor(options: TrailmarkReporterOptions = {}) {
|
|
177
|
+
const mode = resolveMode(options)
|
|
178
|
+
const envVerbose = process.env.TRAILMARK_VERBOSE
|
|
179
|
+
const verboseFromEnv = envVerbose === "1" || envVerbose === "true"
|
|
180
|
+
|
|
181
|
+
this.options = {
|
|
182
|
+
baselinePath: options.baselinePath ?? DEFAULT_BASELINE_PATH,
|
|
183
|
+
reportPath: options.reportPath ?? DEFAULT_REPORT_PATH,
|
|
184
|
+
mode,
|
|
185
|
+
failOnImpact: options.failOnImpact ?? ["serious", "critical"],
|
|
186
|
+
failOnScanError: options.failOnScanError ?? true,
|
|
187
|
+
onConfigMismatch: options.onConfigMismatch ?? "warn",
|
|
188
|
+
verbose: options.verbose ?? verboseFromEnv,
|
|
189
|
+
url: options.url ?? {
|
|
190
|
+
stripHash: true,
|
|
191
|
+
stripQuery: true,
|
|
192
|
+
},
|
|
193
|
+
artifacts: resolveArtifactOptions(mode, options.artifacts),
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
this.syncFixtureEnv()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private syncFixtureEnv(): void {
|
|
200
|
+
process.env.TRAILMARK_MODE = this.options.mode
|
|
201
|
+
process.env.TRAILMARK_BASELINE_PATH = this.options.baselinePath
|
|
202
|
+
process.env.TRAILMARK_ARTIFACTS_ENABLED = String(this.options.artifacts.enabled)
|
|
203
|
+
process.env.TRAILMARK_ARTIFACTS_OUTPUT_DIR = this.options.artifacts.outputDir
|
|
204
|
+
process.env.TRAILMARK_ARTIFACTS_LIMIT_PER_RUN = String(this.options.artifacts.limitPerRun)
|
|
205
|
+
process.env.TRAILMARK_ARTIFACTS_CAPTURE = this.options.artifacts.capture
|
|
206
|
+
process.env.TRAILMARK_ARTIFACTS_INCLUDE_VIEWPORT = String(
|
|
207
|
+
this.options.artifacts.includeViewport,
|
|
208
|
+
)
|
|
209
|
+
process.env.TRAILMARK_ARTIFACTS_INCLUDE_ELEMENT = String(this.options.artifacts.includeElement)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private createMergedRun(): TrailmarkRun {
|
|
213
|
+
return withRunShape(
|
|
214
|
+
createEmptyRun({
|
|
215
|
+
metadata: {
|
|
216
|
+
sourceRuns: 0,
|
|
217
|
+
artifactRunIds: [],
|
|
218
|
+
artifactWarnings: [],
|
|
219
|
+
artifactIndex: {},
|
|
220
|
+
},
|
|
221
|
+
}),
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private debug(message: string, context?: Record<string, unknown>): void {
|
|
226
|
+
if (!this.options.verbose) {
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const suffix = context ? ` ${JSON.stringify(context)}` : ""
|
|
231
|
+
console.log(`trailmark:debug ${message}${suffix}`)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private appendRun(run: TrailmarkRun): void {
|
|
235
|
+
const runHash = hashObject({
|
|
236
|
+
version: run.version,
|
|
237
|
+
startedAt: run.startedAt,
|
|
238
|
+
finishedAt: run.finishedAt,
|
|
239
|
+
tool: run.tool,
|
|
240
|
+
findings: run.findings,
|
|
241
|
+
scanErrors: run.scanErrors,
|
|
242
|
+
metadata: run.metadata,
|
|
243
|
+
})
|
|
244
|
+
if (this.seenRunHashes.has(runHash)) {
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
this.seenRunHashes.add(runHash)
|
|
248
|
+
|
|
249
|
+
this.sourceRuns += 1
|
|
250
|
+
this.mergedRun.findings.push(...run.findings)
|
|
251
|
+
this.mergedRun.scanErrors.push(...run.scanErrors)
|
|
252
|
+
|
|
253
|
+
const previousMetadata = isRecord(this.mergedRun.metadata) ? this.mergedRun.metadata : {}
|
|
254
|
+
const runMetadata = isRecord(run.metadata) ? run.metadata : {}
|
|
255
|
+
const mergedArtifactIndex = mergeArtifactIndex(
|
|
256
|
+
toArtifactIndex(previousMetadata.artifactIndex),
|
|
257
|
+
toArtifactIndex(runMetadata.artifactIndex),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
const artifactRunIds = uniqueStrings([
|
|
261
|
+
...toStringArray(previousMetadata.artifactRunIds),
|
|
262
|
+
...toStringArray(runMetadata.artifactRunIds),
|
|
263
|
+
...(typeof runMetadata.artifactRunId === "string" ? [runMetadata.artifactRunId] : []),
|
|
264
|
+
])
|
|
265
|
+
|
|
266
|
+
const artifactWarnings = uniqueStrings([
|
|
267
|
+
...toStringArray(previousMetadata.artifactWarnings),
|
|
268
|
+
...toStringArray(runMetadata.artifactWarnings),
|
|
269
|
+
])
|
|
270
|
+
|
|
271
|
+
this.mergedRun.metadata = {
|
|
272
|
+
...previousMetadata,
|
|
273
|
+
sourceRuns: this.sourceRuns,
|
|
274
|
+
scanCount: Number(previousMetadata.scanCount ?? 0) + Number(runMetadata.scanCount ?? 0),
|
|
275
|
+
skippedScanCount:
|
|
276
|
+
Number(previousMetadata.skippedScanCount ?? 0) + Number(runMetadata.skippedScanCount ?? 0),
|
|
277
|
+
artifactRunIds,
|
|
278
|
+
artifactWarnings,
|
|
279
|
+
artifactIndex: mergedArtifactIndex,
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private currentRunPayloadBase(configHash: string, run: TrailmarkRun): Record<string, unknown> {
|
|
284
|
+
return {
|
|
285
|
+
generatedAt: new Date().toISOString(),
|
|
286
|
+
mode: this.options.mode,
|
|
287
|
+
configHash,
|
|
288
|
+
run,
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private computeConfigHash(): string {
|
|
293
|
+
return computeConfigHash({
|
|
294
|
+
failOnImpact: this.options.failOnImpact,
|
|
295
|
+
failOnScanError: this.options.failOnScanError,
|
|
296
|
+
onConfigMismatch: this.options.onConfigMismatch,
|
|
297
|
+
url: this.options.url,
|
|
298
|
+
projects: this.config?.projects.map((project) => project.name) ?? [],
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private emitCollisionWarnings(finalizedRun: TrailmarkRun, warnings: string[]): void {
|
|
303
|
+
const collisions = detectIdentityCollisions(finalizedRun.findings)
|
|
304
|
+
if (collisions.length === 0) {
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const collisionSummary = collisions
|
|
309
|
+
.slice(0, 5)
|
|
310
|
+
.map((entry) => `${entry.scopeKey}:${entry.ruleId}:${entry.pageKey} x${entry.count}`)
|
|
311
|
+
.join(", ")
|
|
312
|
+
const warning =
|
|
313
|
+
"trailmark: detected potential target-hash collisions in current run. " +
|
|
314
|
+
`Repeated elements may merge into one issue identity (${collisionSummary}).`
|
|
315
|
+
|
|
316
|
+
warnings.push(warning)
|
|
317
|
+
console.warn(warning)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private emitArtifactWarnings(finalizedRun: TrailmarkRun, warnings: string[]): void {
|
|
321
|
+
if (!isRecord(finalizedRun.metadata)) {
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
for (const warning of toStringArray(finalizedRun.metadata.artifactWarnings)) {
|
|
326
|
+
if (warnings.includes(warning)) {
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
warnings.push(warning)
|
|
331
|
+
console.warn(warning)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private handleScanErrors(finalizedRun: TrailmarkRun): boolean {
|
|
336
|
+
if (finalizedRun.scanErrors.length === 0) {
|
|
337
|
+
return false
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
summarizeScanErrors(finalizedRun.scanErrors)
|
|
341
|
+
return this.options.failOnScanError
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private runDiff(
|
|
345
|
+
finalizedRun: TrailmarkRun,
|
|
346
|
+
configHash: string,
|
|
347
|
+
warnings: string[],
|
|
348
|
+
errors: string[],
|
|
349
|
+
): { diff?: DiffResult; configMismatch?: ConfigMismatch; shouldFail: boolean } {
|
|
350
|
+
let shouldFail = false
|
|
351
|
+
let diff: DiffResult | undefined
|
|
352
|
+
let configMismatch: ConfigMismatch | undefined
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
const { data: baseline, error, message } = readBaseline(this.options.baselinePath)
|
|
356
|
+
if (error) {
|
|
357
|
+
throw new ReporterBaselineError(message)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (baseline.configHash !== configHash) {
|
|
361
|
+
configMismatch = {
|
|
362
|
+
baselineConfigHash: baseline.configHash,
|
|
363
|
+
currentConfigHash: configHash,
|
|
364
|
+
action: this.options.onConfigMismatch,
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const mismatchMessage =
|
|
368
|
+
"trailmark: baseline config hash mismatch detected. " +
|
|
369
|
+
`baseline=${baseline.configHash} current=${configHash}. ` +
|
|
370
|
+
"Diff classification may shift if normalization/gating settings changed."
|
|
371
|
+
|
|
372
|
+
if (this.options.onConfigMismatch === "warn") {
|
|
373
|
+
warnings.push(mismatchMessage)
|
|
374
|
+
console.warn(mismatchMessage)
|
|
375
|
+
} else if (this.options.onConfigMismatch === "error") {
|
|
376
|
+
errors.push(mismatchMessage)
|
|
377
|
+
console.error(mismatchMessage)
|
|
378
|
+
shouldFail = true
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
diff = diffRuns(baseline, finalizedRun, {
|
|
383
|
+
failOnImpact: this.options.failOnImpact,
|
|
384
|
+
onDebug: this.options.verbose ? this.debug.bind(this) : undefined,
|
|
385
|
+
})
|
|
386
|
+
} catch (error) {
|
|
387
|
+
const message =
|
|
388
|
+
error instanceof Error
|
|
389
|
+
? error.message
|
|
390
|
+
: `trailmark: failed to process baseline at ${this.options.baselinePath}.`
|
|
391
|
+
errors.push(message)
|
|
392
|
+
console.error(message)
|
|
393
|
+
shouldFail = true
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return { diff, configMismatch, shouldFail }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private updateBaseline(
|
|
400
|
+
playwrightStatus: string,
|
|
401
|
+
finalizedRun: TrailmarkRun,
|
|
402
|
+
configHash: string,
|
|
403
|
+
errors: string[],
|
|
404
|
+
shouldFail: boolean,
|
|
405
|
+
): boolean {
|
|
406
|
+
if (playwrightStatus !== "passed") {
|
|
407
|
+
const errorMessage =
|
|
408
|
+
"trailmark: skipping baseline update because Playwright run did not pass."
|
|
409
|
+
errors.push(errorMessage)
|
|
410
|
+
console.error(errorMessage)
|
|
411
|
+
return true
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (this.options.failOnScanError && finalizedRun.scanErrors.length > 0) {
|
|
415
|
+
const errorMessage =
|
|
416
|
+
"trailmark: skipping baseline update because scan errors were captured in this run."
|
|
417
|
+
errors.push(errorMessage)
|
|
418
|
+
console.error(errorMessage)
|
|
419
|
+
return true
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const baselineRead = readBaseline(this.options.baselinePath)
|
|
423
|
+
if (baselineRead.error && baselineRead.error !== "file-missing") {
|
|
424
|
+
errors.push(baselineRead.message)
|
|
425
|
+
console.error(baselineRead.message)
|
|
426
|
+
return true
|
|
427
|
+
}
|
|
428
|
+
const previousBaseline = baselineRead.data ?? undefined
|
|
429
|
+
|
|
430
|
+
if (shouldFail) {
|
|
431
|
+
return true
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
let baselineDelta: DiffResult | undefined
|
|
435
|
+
if (previousBaseline) {
|
|
436
|
+
baselineDelta = diffRuns(previousBaseline, finalizedRun, {
|
|
437
|
+
failOnImpact: this.options.failOnImpact,
|
|
438
|
+
onDebug: this.options.verbose ? this.debug.bind(this) : undefined,
|
|
439
|
+
})
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const baseline = buildBaselineFromRun(finalizedRun, {
|
|
443
|
+
configHash,
|
|
444
|
+
previousBaseline,
|
|
445
|
+
})
|
|
446
|
+
writeJsonAtomic(this.options.baselinePath, baseline)
|
|
447
|
+
console.log(
|
|
448
|
+
`trailmark: baseline updated at ${this.options.baselinePath} with ${Object.keys(baseline.findings).length} issue(s).`,
|
|
449
|
+
)
|
|
450
|
+
if (baselineDelta) {
|
|
451
|
+
const exactMatches = baselineDelta.existing.filter(
|
|
452
|
+
(entry) => entry.matchedBy === "exact",
|
|
453
|
+
).length
|
|
454
|
+
const relaxedMatches = baselineDelta.existing.length - exactMatches
|
|
455
|
+
console.log(
|
|
456
|
+
"trailmark: baseline delta vs previous | " +
|
|
457
|
+
`new=${baselineDelta.stats.newCount} ` +
|
|
458
|
+
`resolved=${baselineDelta.stats.resolvedCount} ` +
|
|
459
|
+
`existing=${baselineDelta.stats.existingCount} ` +
|
|
460
|
+
`(exact=${exactMatches}, relaxed=${relaxedMatches}) ` +
|
|
461
|
+
`blocking=${baselineDelta.blockingNew.length} ` +
|
|
462
|
+
`total previous=${baselineDelta.stats.totalBaseline} ` +
|
|
463
|
+
`total current=${baselineDelta.stats.totalCurrent}`,
|
|
464
|
+
)
|
|
465
|
+
console.log(
|
|
466
|
+
`trailmark: baseline delta impacts (new): ${summarizeImpactCounts(baselineDelta.stats.newByImpact)}`,
|
|
467
|
+
)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return false
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private reportArtifactIndex(finalizedRun: TrailmarkRun): TrailmarkArtifactIndex | undefined {
|
|
474
|
+
if (!isRecord(finalizedRun.metadata)) {
|
|
475
|
+
return undefined
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return toArtifactIndex(finalizedRun.metadata.artifactIndex)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
onBegin(config: FullConfig): void {
|
|
482
|
+
this.config = config
|
|
483
|
+
this.sourceRuns = 0
|
|
484
|
+
this.seenRunHashes = new Set<string>()
|
|
485
|
+
this.mergedRun = this.createMergedRun()
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
onTestEnd(_test: TestCase, result: TestResult): void {
|
|
489
|
+
for (const run of readAttachmentPayload(result)) {
|
|
490
|
+
this.appendRun(run)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async onEnd(result: FullResult): Promise<ReporterStatus> {
|
|
495
|
+
const playwrightStatus = typeof result.status === "string" ? result.status : "passed"
|
|
496
|
+
const finalizedRun = applyUrlNormalization(
|
|
497
|
+
withRunShape({
|
|
498
|
+
...this.mergedRun,
|
|
499
|
+
finishedAt: new Date().toISOString(),
|
|
500
|
+
}),
|
|
501
|
+
this.options.url,
|
|
502
|
+
this.options.verbose ? this.debug.bind(this) : undefined,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
const configHash = this.computeConfigHash()
|
|
506
|
+
const warnings: string[] = []
|
|
507
|
+
const errors: string[] = []
|
|
508
|
+
let shouldFail = this.handleScanErrors(finalizedRun)
|
|
509
|
+
|
|
510
|
+
this.emitCollisionWarnings(finalizedRun, warnings)
|
|
511
|
+
this.emitArtifactWarnings(finalizedRun, warnings)
|
|
512
|
+
|
|
513
|
+
let diff: DiffResult | undefined
|
|
514
|
+
let configMismatch: ConfigMismatch | undefined
|
|
515
|
+
|
|
516
|
+
if (this.options.mode === "baseline") {
|
|
517
|
+
shouldFail = this.updateBaseline(
|
|
518
|
+
playwrightStatus,
|
|
519
|
+
finalizedRun,
|
|
520
|
+
configHash,
|
|
521
|
+
errors,
|
|
522
|
+
shouldFail,
|
|
523
|
+
)
|
|
524
|
+
} else {
|
|
525
|
+
const diffResult = this.runDiff(finalizedRun, configHash, warnings, errors)
|
|
526
|
+
shouldFail ||= diffResult.shouldFail
|
|
527
|
+
diff = diffResult.diff
|
|
528
|
+
configMismatch = diffResult.configMismatch
|
|
529
|
+
|
|
530
|
+
if (diff?.blockingNew.length) {
|
|
531
|
+
shouldFail = true
|
|
532
|
+
console.error(
|
|
533
|
+
`trailmark: ${diff.blockingNew.length} blocking new issue(s) found for impacts ${this.options.failOnImpact.join(", ")}.`,
|
|
534
|
+
)
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const report = buildReport({
|
|
539
|
+
run: finalizedRun,
|
|
540
|
+
mode: this.options.mode,
|
|
541
|
+
configHash,
|
|
542
|
+
diff,
|
|
543
|
+
artifacts: this.reportArtifactIndex(finalizedRun),
|
|
544
|
+
})
|
|
545
|
+
writeJsonAtomic(this.options.reportPath, report)
|
|
546
|
+
|
|
547
|
+
console.log(
|
|
548
|
+
formatConsoleReport(report, {
|
|
549
|
+
failOnImpact: this.options.failOnImpact,
|
|
550
|
+
}),
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
writeJsonAtomic(CURRENT_RUN_PATH, {
|
|
554
|
+
...this.currentRunPayloadBase(configHash, finalizedRun),
|
|
555
|
+
configMismatch,
|
|
556
|
+
diff,
|
|
557
|
+
reportPath: this.options.reportPath,
|
|
558
|
+
warnings,
|
|
559
|
+
errors,
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
if (this.options.failOnScanError && finalizedRun.scanErrors.length > 0) {
|
|
563
|
+
return { status: "failed" }
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (shouldFail) {
|
|
567
|
+
return { status: "failed" }
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return undefined
|
|
571
|
+
}
|
|
572
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { dirname } from "node:path"
|
|
3
|
+
import { parseBaselineFile } from "@trailmark/core"
|
|
4
|
+
|
|
5
|
+
function ensureParentDir(filePath: string): void {
|
|
6
|
+
mkdirSync(dirname(filePath), { recursive: true })
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function writeJsonAtomic(filePath: string, payload: unknown): void {
|
|
10
|
+
ensureParentDir(filePath)
|
|
11
|
+
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
writeFileSync(tempPath, JSON.stringify(payload, null, 2), "utf8")
|
|
15
|
+
renameSync(tempPath, filePath)
|
|
16
|
+
} finally {
|
|
17
|
+
if (existsSync(tempPath)) {
|
|
18
|
+
rmSync(tempPath, { force: true })
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function readBaseline(baselinePath: string) {
|
|
24
|
+
if (!existsSync(baselinePath)) {
|
|
25
|
+
return {
|
|
26
|
+
data: null,
|
|
27
|
+
error: "file-missing" as const,
|
|
28
|
+
message: `trailmark: baseline file not found at ${baselinePath}. Run in baseline mode first.`,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return parseBaselineFile(readFileSync(baselinePath, "utf8"))
|
|
33
|
+
}
|