@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,79 @@
1
+ import { TrailmarkArtifactCaptureMode, TrailmarkArtifactOptions, TrailmarkMode } from "../types"
2
+
3
+ export interface ResolvedTrailmarkArtifactOptions {
4
+ enabled: boolean
5
+ outputDir: string
6
+ limitPerRun: number
7
+ capture: TrailmarkArtifactCaptureMode
8
+ includeViewport: boolean
9
+ includeElement: boolean
10
+ }
11
+
12
+ const DEFAULT_OUTPUT_DIR = ".trailmark/artifacts"
13
+ const DEFAULT_LIMIT_PER_RUN = 50
14
+
15
+ function parseBoolean(value: string | undefined): boolean | undefined {
16
+ if (!value) {
17
+ return undefined
18
+ }
19
+
20
+ const normalized = value.trim().toLowerCase()
21
+ if (normalized === "1" || normalized === "true") {
22
+ return true
23
+ }
24
+
25
+ if (normalized === "0" || normalized === "false") {
26
+ return false
27
+ }
28
+
29
+ return undefined
30
+ }
31
+
32
+ function parsePositiveInteger(value: string | undefined): number | undefined {
33
+ if (!value) {
34
+ return undefined
35
+ }
36
+
37
+ const parsed = Number.parseInt(value, 10)
38
+ if (!Number.isFinite(parsed) || parsed <= 0) {
39
+ return undefined
40
+ }
41
+
42
+ return parsed
43
+ }
44
+
45
+ function parseCaptureMode(value: string | undefined): TrailmarkArtifactCaptureMode | undefined {
46
+ if (!value) {
47
+ return undefined
48
+ }
49
+
50
+ if (value === "new-only" || value === "all") {
51
+ return value
52
+ }
53
+
54
+ return undefined
55
+ }
56
+
57
+ export function resolveArtifactOptions(
58
+ mode: TrailmarkMode,
59
+ options: TrailmarkArtifactOptions | undefined,
60
+ env: NodeJS.ProcessEnv = process.env,
61
+ ): ResolvedTrailmarkArtifactOptions {
62
+ // const defaultEnabled = mode === "check"
63
+ const defaultEnabled = false
64
+
65
+ const envEnabled = parseBoolean(env.TRAILMARK_ARTIFACTS_ENABLED)
66
+ const envIncludeViewport = parseBoolean(env.TRAILMARK_ARTIFACTS_INCLUDE_VIEWPORT)
67
+ const envIncludeElement = parseBoolean(env.TRAILMARK_ARTIFACTS_INCLUDE_ELEMENT)
68
+ const envLimitPerRun = parsePositiveInteger(env.TRAILMARK_ARTIFACTS_LIMIT_PER_RUN)
69
+ const envCaptureMode = parseCaptureMode(env.TRAILMARK_ARTIFACTS_CAPTURE)
70
+
71
+ return {
72
+ enabled: options?.enabled ?? envEnabled ?? defaultEnabled,
73
+ outputDir: options?.outputDir ?? env.TRAILMARK_ARTIFACTS_OUTPUT_DIR ?? DEFAULT_OUTPUT_DIR,
74
+ limitPerRun: options?.limitPerRun ?? envLimitPerRun ?? DEFAULT_LIMIT_PER_RUN,
75
+ capture: options?.capture ?? envCaptureMode ?? "new-only",
76
+ includeViewport: options?.includeViewport ?? envIncludeViewport ?? true,
77
+ includeElement: options?.includeElement ?? envIncludeElement ?? true,
78
+ }
79
+ }
@@ -0,0 +1,143 @@
1
+ import {
2
+ BaselineEntry,
3
+ BaselineFile,
4
+ TrailmarkAttachment,
5
+ TrailmarkFinding,
6
+ hashObject,
7
+ scopeKeyFromBaselineEntry,
8
+ scopeKeyFromMeta,
9
+ scopedIssueKey,
10
+ } from "@trailmark/core"
11
+ import { TrailmarkArtifactCaptureMode } from "../types"
12
+
13
+ function makeBaselineExactKey(entry: BaselineEntry): string {
14
+ return scopedIssueKey(entry.issueId, scopeKeyFromBaselineEntry(entry))
15
+ }
16
+
17
+ function makeFindingExactKey(finding: TrailmarkFinding): string {
18
+ return scopedIssueKey(finding.issueId, scopeKeyFromMeta(finding.meta))
19
+ }
20
+
21
+ function makeFindingRelaxedKey(finding: TrailmarkFinding): string {
22
+ return scopedIssueKey(finding.relaxedIssueId, scopeKeyFromMeta(finding.meta))
23
+ }
24
+
25
+ function makeRelaxedMap(baselineEntries: BaselineEntry[]): Map<string, BaselineEntry[]> {
26
+ const map = new Map<string, BaselineEntry[]>()
27
+ for (const entry of baselineEntries) {
28
+ const key = scopedIssueKey(entry.relaxedIssueId, scopeKeyFromBaselineEntry(entry))
29
+ const list = map.get(key) ?? []
30
+ list.push(entry)
31
+ map.set(key, list)
32
+ }
33
+
34
+ for (const entries of map.values()) {
35
+ entries.sort((left, right) =>
36
+ makeBaselineExactKey(left).localeCompare(makeBaselineExactKey(right)),
37
+ )
38
+ }
39
+
40
+ return map
41
+ }
42
+
43
+ function pickUnmatched(
44
+ entries: BaselineEntry[] | undefined,
45
+ matchedExactKeys: Set<string>,
46
+ ): BaselineEntry | undefined {
47
+ if (!entries || entries.length === 0) {
48
+ return undefined
49
+ }
50
+
51
+ return entries.find((entry) => !matchedExactKeys.has(makeBaselineExactKey(entry)))
52
+ }
53
+
54
+ export class CaptureDecisionRegistry {
55
+ private readonly seenIssueKeys = new Set<string>()
56
+
57
+ private readonly baselineByExact: Map<string, BaselineEntry>
58
+
59
+ private readonly baselineByRelaxed: Map<string, BaselineEntry[]>
60
+
61
+ private readonly matchedBaselineExactKeys = new Set<string>()
62
+
63
+ constructor(
64
+ private readonly captureMode: TrailmarkArtifactCaptureMode,
65
+ baseline: BaselineFile | undefined,
66
+ ) {
67
+ const entries = Object.values(baseline?.findings ?? {})
68
+ this.baselineByExact = new Map(entries.map((entry) => [makeBaselineExactKey(entry), entry]))
69
+ this.baselineByRelaxed = makeRelaxedMap(entries)
70
+ }
71
+
72
+ shouldCaptureIssue(finding: TrailmarkFinding): boolean {
73
+ const findingExactKey = makeFindingExactKey(finding)
74
+ if (this.seenIssueKeys.has(findingExactKey)) {
75
+ return false
76
+ }
77
+
78
+ this.seenIssueKeys.add(findingExactKey)
79
+
80
+ if (this.captureMode === "all") {
81
+ return true
82
+ }
83
+
84
+ const exact = this.baselineByExact.get(findingExactKey)
85
+ if (exact) {
86
+ this.matchedBaselineExactKeys.add(makeBaselineExactKey(exact))
87
+ return false
88
+ }
89
+
90
+ const relaxed = pickUnmatched(
91
+ this.baselineByRelaxed.get(makeFindingRelaxedKey(finding)),
92
+ this.matchedBaselineExactKeys,
93
+ )
94
+
95
+ if (relaxed) {
96
+ this.matchedBaselineExactKeys.add(makeBaselineExactKey(relaxed))
97
+ return false
98
+ }
99
+
100
+ return true
101
+ }
102
+ }
103
+
104
+ export class ArtifactRegistry {
105
+ private readonly occurrenceOrdinalByIssueKey = new Map<string, number>()
106
+
107
+ private readonly attachmentsByOccurrenceId = new Map<string, TrailmarkAttachment[]>()
108
+
109
+ registerOccurrence(finding: TrailmarkFinding): { occurrenceId: string; issueKey: string } {
110
+ const issueKey = makeFindingExactKey(finding)
111
+ const ordinal = (this.occurrenceOrdinalByIssueKey.get(issueKey) ?? 0) + 1
112
+ this.occurrenceOrdinalByIssueKey.set(issueKey, ordinal)
113
+
114
+ const occurrenceId = hashObject({
115
+ issueKey,
116
+ issueId: finding.issueId,
117
+ pageKey: finding.pageKey,
118
+ testId: finding.meta?.testId,
119
+ projectName: finding.meta?.projectName,
120
+ ordinal,
121
+ })
122
+
123
+ return {
124
+ occurrenceId,
125
+ issueKey,
126
+ }
127
+ }
128
+
129
+ registerAttachments(occurrenceId: string, attachments: TrailmarkAttachment[]): void {
130
+ this.attachmentsByOccurrenceId.set(occurrenceId, attachments)
131
+ }
132
+
133
+ attachmentIndex(): { byOccurrenceId: Record<string, TrailmarkAttachment[]> } {
134
+ const byOccurrenceId: Record<string, TrailmarkAttachment[]> = {}
135
+ for (const key of Array.from(this.attachmentsByOccurrenceId.keys()).sort((a, b) =>
136
+ a.localeCompare(b),
137
+ )) {
138
+ byOccurrenceId[key] = this.attachmentsByOccurrenceId.get(key) ?? []
139
+ }
140
+
141
+ return { byOccurrenceId }
142
+ }
143
+ }
@@ -0,0 +1 @@
1
+ export const TRAILMARK_ATTACHMENT_NAME = "trailmark-run"
package/src/fixture.ts ADDED
@@ -0,0 +1,239 @@
1
+ import { existsSync } from "node:fs"
2
+ import AxeBuilder from "@axe-core/playwright"
3
+ import { expect, test as base } from "@playwright/test"
4
+ import {
5
+ AxeLikeResults,
6
+ BaselineFile,
7
+ TrailmarkFindingMeta,
8
+ TrailmarkRun,
9
+ createEmptyRun,
10
+ normalizeAxeResults,
11
+ normalizeUrlToPageKey,
12
+ } from "@trailmark/core"
13
+ import { TRAILMARK_ATTACHMENT_NAME } from "./constants"
14
+ import { ArtifactManager, createArtifactRunId } from "./artifacts/artifactManager"
15
+ import { resolveArtifactOptions } from "./artifacts/options"
16
+ import { ArtifactRegistry, CaptureDecisionRegistry } from "./artifacts/registry"
17
+ import { ScanOptions, TrailmarkArtifactOptions, TrailmarkFixture, TrailmarkMode } from "./types"
18
+ import { resolveMode } from "./mode"
19
+ import { DEFAULT_BASELINE_PATH } from "./reporter"
20
+ import { readBaseline } from "./reporter/io"
21
+
22
+ function cloneRun(run: TrailmarkRun): TrailmarkRun {
23
+ return JSON.parse(JSON.stringify(run)) as TrailmarkRun
24
+ }
25
+
26
+ function applyAxeOptions(builder: AxeBuilder, opts: ScanOptions | undefined): AxeBuilder {
27
+ let configured = builder
28
+
29
+ for (const selector of opts?.axe?.include ?? []) {
30
+ configured = configured.include(selector)
31
+ }
32
+
33
+ for (const selector of opts?.axe?.exclude ?? []) {
34
+ configured = configured.exclude(selector)
35
+ }
36
+
37
+ if (opts?.axe?.tags && opts.axe.tags.length > 0) {
38
+ configured = configured.withTags(opts.axe.tags)
39
+ }
40
+
41
+ const rules: Record<string, { enabled: boolean }> = {
42
+ ...opts?.axe?.rules,
43
+ }
44
+
45
+ for (const ruleId of opts?.axe?.disableRules ?? []) {
46
+ rules[ruleId] = { enabled: false }
47
+ }
48
+
49
+ if (Object.keys(rules).length > 0) {
50
+ configured = configured.options({ rules })
51
+ }
52
+
53
+ return configured
54
+ }
55
+
56
+ function shouldSkipScan(opts: ScanOptions | undefined): boolean {
57
+ if (opts?.enabled === false) {
58
+ return true
59
+ }
60
+
61
+ const envToggle = process.env.TRAILMARK_DISABLE_SCAN
62
+ return envToggle === "1" || envToggle === "true"
63
+ }
64
+
65
+ function toError(input: unknown): Error {
66
+ if (input instanceof Error) {
67
+ return input
68
+ }
69
+
70
+ return new Error(typeof input === "string" ? input : "Unknown Trailmark scan failure.")
71
+ }
72
+
73
+ function loadBaselineForArtifacts(
74
+ mode: TrailmarkMode,
75
+ artifactOptions: TrailmarkArtifactOptions | undefined,
76
+ warnings: string[],
77
+ ): BaselineFile | undefined {
78
+ if (mode !== "check") {
79
+ return undefined
80
+ }
81
+
82
+ const captureMode = artifactOptions?.capture ?? "new-only"
83
+ if (captureMode !== "new-only") {
84
+ return undefined
85
+ }
86
+
87
+ const baselinePath = process.env.TRAILMARK_BASELINE_PATH ?? DEFAULT_BASELINE_PATH
88
+ if (!existsSync(baselinePath)) {
89
+ return undefined
90
+ }
91
+
92
+ const { data: baseline, error, message } = readBaseline(baselinePath)
93
+ if (error) {
94
+ warnings.push(
95
+ `trailmark: failed to read baseline for artifact capture decisions: ${message}. Falling back to capturing all issues.`,
96
+ )
97
+ return undefined
98
+ }
99
+ return baseline
100
+ }
101
+
102
+ export const test = base.extend<{ trailmark: TrailmarkFixture }>({
103
+ // oxlint-disable-next-line no-empty-pattern: First argument must use the object destructuring pattern: _fixtures https://github.com/microsoft/playwright/issues/14590
104
+ trailmark: async ({}, use, testInfo) => {
105
+ const mode = resolveMode()
106
+ const artifactWarnings: string[] = []
107
+ const resolvedArtifactOptions = resolveArtifactOptions(mode, undefined)
108
+ const artifactRunId = createArtifactRunId()
109
+ const artifactManager = new ArtifactManager({
110
+ ...resolvedArtifactOptions,
111
+ runId: artifactRunId,
112
+ })
113
+ const artifactRegistry = new ArtifactRegistry()
114
+
115
+ const baselineForCapture = loadBaselineForArtifacts(
116
+ mode,
117
+ resolvedArtifactOptions,
118
+ artifactWarnings,
119
+ )
120
+ const captureMode = baselineForCapture ? resolvedArtifactOptions.capture : "all"
121
+ const captureDecision = new CaptureDecisionRegistry(captureMode, baselineForCapture)
122
+
123
+ const run = createEmptyRun({
124
+ metadata: {
125
+ projectName: testInfo.project.name,
126
+ testFile: testInfo.file,
127
+ scanCount: 0,
128
+ skippedScanCount: 0,
129
+ artifactRunId,
130
+ artifactOptions: resolvedArtifactOptions,
131
+ },
132
+ })
133
+
134
+ if (!Array.isArray(run.scanErrors)) {
135
+ run.scanErrors = []
136
+ }
137
+
138
+ const fixture: TrailmarkFixture = {
139
+ async scan(page, opts) {
140
+ if (shouldSkipScan(opts)) {
141
+ run.metadata = {
142
+ ...run.metadata,
143
+ skippedScanCount: Number(run.metadata?.skippedScanCount ?? 0) + 1,
144
+ }
145
+ return
146
+ }
147
+
148
+ const meta: TrailmarkFindingMeta = {
149
+ testId: testInfo.testId,
150
+ testTitle: testInfo.title,
151
+ testFile: testInfo.file,
152
+ projectName: testInfo.project.name,
153
+ scanLabel: opts?.scanLabel,
154
+ ...opts?.meta,
155
+ }
156
+
157
+ const pageUrl = page.url()
158
+ const builder = applyAxeOptions(new AxeBuilder({ page }), opts)
159
+ const scanArtifactEnabled = opts?.artifacts?.enabled ?? resolvedArtifactOptions.enabled
160
+
161
+ try {
162
+ const results = (await builder.analyze()) as AxeLikeResults
163
+ const findings = normalizeAxeResults({
164
+ results,
165
+ pageUrl,
166
+ urlOptions: opts?.url,
167
+ meta,
168
+ })
169
+
170
+ const occurredAt = new Date().toISOString()
171
+ const finalizedFindings: TrailmarkRun["findings"] = []
172
+
173
+ for (const finding of findings) {
174
+ const { occurrenceId } = artifactRegistry.registerOccurrence(finding)
175
+ const withOccurrence = {
176
+ ...finding,
177
+ occurrenceId,
178
+ occurredAt,
179
+ }
180
+
181
+ if (scanArtifactEnabled && captureDecision.shouldCaptureIssue(withOccurrence)) {
182
+ const captureResult = await artifactManager.captureForIssue({
183
+ page,
184
+ finding: withOccurrence,
185
+ })
186
+ artifactRegistry.registerAttachments(occurrenceId, captureResult.attachments)
187
+ finalizedFindings.push({
188
+ ...withOccurrence,
189
+ attachments: captureResult.attachments,
190
+ })
191
+ continue
192
+ }
193
+
194
+ finalizedFindings.push(withOccurrence)
195
+ }
196
+
197
+ run.findings.push(...finalizedFindings)
198
+ run.metadata = {
199
+ ...run.metadata,
200
+ scanCount: Number(run.metadata?.scanCount ?? 0) + 1,
201
+ }
202
+ } catch (error) {
203
+ const normalized = toError(error)
204
+ run.scanErrors.push({
205
+ pageUrl,
206
+ pageKey: normalizeUrlToPageKey(pageUrl, opts?.url),
207
+ message: normalized.message,
208
+ stack: normalized.stack,
209
+ occurredAt: new Date().toISOString(),
210
+ meta,
211
+ })
212
+
213
+ if (!opts?.continueOnError) {
214
+ throw normalized
215
+ }
216
+ }
217
+ },
218
+ getRun() {
219
+ return cloneRun(run)
220
+ },
221
+ }
222
+
223
+ await use(fixture)
224
+
225
+ run.finishedAt = new Date().toISOString()
226
+ run.metadata = {
227
+ ...run.metadata,
228
+ artifactWarnings: [...artifactWarnings, ...artifactManager.getWarnings()],
229
+ artifactIndex: artifactRegistry.attachmentIndex(),
230
+ }
231
+
232
+ await testInfo.attach(TRAILMARK_ATTACHMENT_NAME, {
233
+ contentType: "application/json",
234
+ body: Buffer.from(JSON.stringify(run), "utf8"),
235
+ })
236
+ },
237
+ })
238
+
239
+ export { expect }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { expect, test } from "./fixture"
2
+ export type { ScanOptions, TrailmarkFixture, TrailmarkReporterOptions } from "./types"
3
+ export type { TrailmarkRun } from "@trailmark/core"
package/src/mode.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { TrailmarkMode, TrailmarkModeOptions } from "./types"
2
+
3
+ export function resolveMode(options?: TrailmarkModeOptions): TrailmarkMode {
4
+ const envMode = process.env.TRAILMARK_MODE as TrailmarkMode | undefined
5
+ const mode = options?.mode ?? envMode ?? "check"
6
+ return mode
7
+ }
@@ -0,0 +1,139 @@
1
+ import { Buffer } from "node:buffer"
2
+ import { existsSync, readFileSync } from "node:fs"
3
+ import type { TestResult } from "@playwright/test/reporter"
4
+ import { TrailmarkRun, createEmptyRun, hashObject } from "@trailmark/core"
5
+ import { TRAILMARK_ATTACHMENT_NAME } from "../constants"
6
+ import { withRunShape } from "./run"
7
+
8
+ type AttachmentLike = {
9
+ name: string
10
+ path?: string
11
+ body?: unknown
12
+ }
13
+
14
+ type StepLike = {
15
+ attachments?: AttachmentLike[]
16
+ steps?: StepLike[]
17
+ }
18
+
19
+ function readAttachmentBody(body: unknown): string | undefined {
20
+ if (!body) {
21
+ return undefined
22
+ }
23
+
24
+ if (typeof body === "string") {
25
+ return body
26
+ }
27
+
28
+ if (Buffer.isBuffer(body) || body instanceof Uint8Array) {
29
+ return Buffer.from(body).toString("utf8")
30
+ }
31
+
32
+ return undefined
33
+ }
34
+
35
+ function attachmentSignature(attachment: AttachmentLike): string {
36
+ if (attachment.path) {
37
+ return `${attachment.name}|path:${attachment.path}`
38
+ }
39
+
40
+ const body = readAttachmentBody(attachment.body)
41
+ if (body !== undefined) {
42
+ return `${attachment.name}|body:${body}`
43
+ }
44
+
45
+ return `${attachment.name}|fallback:${typeof attachment.body}`
46
+ }
47
+
48
+ function collectAttachments(result: TestResult): AttachmentLike[] {
49
+ const attachments: AttachmentLike[] = []
50
+ const seen = new Set<string>()
51
+
52
+ for (const attachment of result.attachments) {
53
+ const signature = attachmentSignature(attachment)
54
+ if (seen.has(signature)) {
55
+ continue
56
+ }
57
+ seen.add(signature)
58
+ attachments.push(attachment)
59
+ }
60
+
61
+ const queue: StepLike[] = [...result.steps]
62
+ while (queue.length > 0) {
63
+ const step = queue.pop()
64
+ if (!step) {
65
+ continue
66
+ }
67
+
68
+ for (const attachment of step.attachments ?? []) {
69
+ const signature = attachmentSignature(attachment)
70
+ if (seen.has(signature)) {
71
+ continue
72
+ }
73
+ seen.add(signature)
74
+ attachments.push(attachment)
75
+ }
76
+
77
+ queue.push(...(step.steps ?? []))
78
+ }
79
+
80
+ return attachments
81
+ }
82
+
83
+ export function readAttachmentPayload(result: TestResult): TrailmarkRun[] {
84
+ const runs: TrailmarkRun[] = []
85
+ const seenPayloadHashes = new Set<string>()
86
+
87
+ for (const attachment of collectAttachments(result)) {
88
+ if (attachment.name !== TRAILMARK_ATTACHMENT_NAME) {
89
+ continue
90
+ }
91
+
92
+ try {
93
+ let raw: string | undefined
94
+ if (attachment.body) {
95
+ raw = readAttachmentBody(attachment.body)
96
+ } else if (attachment.path && existsSync(attachment.path)) {
97
+ raw = readFileSync(attachment.path, "utf8")
98
+ }
99
+
100
+ if (!raw) {
101
+ continue
102
+ }
103
+
104
+ const parsed = JSON.parse(raw) as Partial<TrailmarkRun>
105
+ if (!Array.isArray(parsed.findings)) {
106
+ continue
107
+ }
108
+
109
+ const payloadHash = hashObject({
110
+ attachmentName: attachment.name,
111
+ version: parsed.version,
112
+ startedAt: parsed.startedAt,
113
+ finishedAt: parsed.finishedAt,
114
+ tool: parsed.tool,
115
+ findings: parsed.findings,
116
+ scanErrors: parsed.scanErrors ?? [],
117
+ metadata: parsed.metadata,
118
+ })
119
+ if (seenPayloadHashes.has(payloadHash)) {
120
+ continue
121
+ }
122
+ seenPayloadHashes.add(payloadHash)
123
+
124
+ const run = createEmptyRun({
125
+ metadata: parsed.metadata,
126
+ tool: parsed.tool,
127
+ startedAt: parsed.startedAt,
128
+ })
129
+ run.finishedAt = parsed.finishedAt
130
+ run.findings.push(...parsed.findings)
131
+ run.scanErrors.push(...(Array.isArray(parsed.scanErrors) ? parsed.scanErrors : []))
132
+ runs.push(withRunShape(run))
133
+ } catch {
134
+ continue
135
+ }
136
+ }
137
+
138
+ return runs
139
+ }
@@ -0,0 +1,6 @@
1
+ export class ReporterBaselineError extends Error {
2
+ constructor(message: string) {
3
+ super(message)
4
+ this.name = "ReporterBaselineError"
5
+ }
6
+ }