@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.
Files changed (94) hide show
  1. package/.trailmark/current-run.json +136 -0
  2. package/dist/artifacts/artifactManager.d.ts +31 -0
  3. package/dist/artifacts/artifactManager.d.ts.map +1 -0
  4. package/dist/artifacts/artifactManager.js +140 -0
  5. package/dist/artifacts/artifactManager.js.map +1 -0
  6. package/dist/artifacts/capture.d.ts +4 -0
  7. package/dist/artifacts/capture.d.ts.map +1 -0
  8. package/dist/artifacts/capture.js +16 -0
  9. package/dist/artifacts/capture.js.map +1 -0
  10. package/dist/artifacts/options.d.ts +11 -0
  11. package/dist/artifacts/options.d.ts.map +1 -0
  12. package/dist/artifacts/options.js +55 -0
  13. package/dist/artifacts/options.js.map +1 -0
  14. package/dist/artifacts/registry.d.ts +24 -0
  15. package/dist/artifacts/registry.d.ts.map +1 -0
  16. package/dist/artifacts/registry.js +99 -0
  17. package/dist/artifacts/registry.js.map +1 -0
  18. package/dist/constants.d.ts +2 -0
  19. package/dist/constants.d.ts.map +1 -0
  20. package/dist/constants.js +5 -0
  21. package/dist/constants.js.map +1 -0
  22. package/dist/fixture.d.ts +7 -0
  23. package/dist/fixture.d.ts.map +1 -0
  24. package/dist/fixture.js +193 -0
  25. package/dist/fixture.js.map +1 -0
  26. package/dist/index.d.ts +4 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +7 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/mode.d.ts +3 -0
  31. package/dist/mode.d.ts.map +1 -0
  32. package/dist/mode.js +9 -0
  33. package/dist/mode.js.map +1 -0
  34. package/dist/reporter/attachments.d.ts +4 -0
  35. package/dist/reporter/attachments.d.ts.map +1 -0
  36. package/dist/reporter/attachments.js +112 -0
  37. package/dist/reporter/attachments.js.map +1 -0
  38. package/dist/reporter/errors.d.ts +4 -0
  39. package/dist/reporter/errors.d.ts.map +1 -0
  40. package/dist/reporter/errors.js +11 -0
  41. package/dist/reporter/errors.js.map +1 -0
  42. package/dist/reporter/index.d.ts +33 -0
  43. package/dist/reporter/index.d.ts.map +1 -0
  44. package/dist/reporter/index.js +407 -0
  45. package/dist/reporter/index.js.map +1 -0
  46. package/dist/reporter/io.d.ts +15 -0
  47. package/dist/reporter/io.d.ts.map +1 -0
  48. package/dist/reporter/io.js +34 -0
  49. package/dist/reporter/io.js.map +1 -0
  50. package/dist/reporter/normalization.d.ts +13 -0
  51. package/dist/reporter/normalization.d.ts.map +1 -0
  52. package/dist/reporter/normalization.js +91 -0
  53. package/dist/reporter/normalization.js.map +1 -0
  54. package/dist/reporter/output.d.ts +5 -0
  55. package/dist/reporter/output.d.ts.map +1 -0
  56. package/dist/reporter/output.js +90 -0
  57. package/dist/reporter/output.js.map +1 -0
  58. package/dist/reporter/run.d.ts +4 -0
  59. package/dist/reporter/run.d.ts.map +1 -0
  60. package/dist/reporter/run.js +18 -0
  61. package/dist/reporter/run.js.map +1 -0
  62. package/dist/types.d.ts +48 -0
  63. package/dist/types.d.ts.map +1 -0
  64. package/dist/types.js +3 -0
  65. package/dist/types.js.map +1 -0
  66. package/integration/app/base.html +16 -0
  67. package/integration/app/extra.html +17 -0
  68. package/integration/playwright.config.ts +44 -0
  69. package/integration/specs/sample.spec.ts +15 -0
  70. package/integration/tmp/baseline.json +46 -0
  71. package/integration/tmp/report.json +97 -0
  72. package/package.json +35 -0
  73. package/src/artifacts/artifactManager.ts +178 -0
  74. package/src/artifacts/capture.ts +18 -0
  75. package/src/artifacts/options.ts +79 -0
  76. package/src/artifacts/registry.ts +143 -0
  77. package/src/constants.ts +1 -0
  78. package/src/fixture.ts +239 -0
  79. package/src/index.ts +3 -0
  80. package/src/mode.ts +7 -0
  81. package/src/reporter/attachments.ts +139 -0
  82. package/src/reporter/errors.ts +6 -0
  83. package/src/reporter/index.ts +572 -0
  84. package/src/reporter/io.ts +33 -0
  85. package/src/reporter/normalization.ts +122 -0
  86. package/src/reporter/output.ts +103 -0
  87. package/src/reporter/run.ts +17 -0
  88. package/src/types.ts +53 -0
  89. package/test/artifactManager.unit.test.ts +141 -0
  90. package/test/reporter.integration.test.ts +153 -0
  91. package/test/reporter.unit.test.ts +337 -0
  92. package/test-results/.last-run.json +4 -0
  93. package/trailmark-playwright-0.1.0.tgz +0 -0
  94. 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
+ }