@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,122 @@
1
+ import {
2
+ TrailmarkFinding,
3
+ TrailmarkRun,
4
+ buildIssueId,
5
+ buildRelaxedIssueId,
6
+ hashObject,
7
+ normalizeUrlToPageKey,
8
+ } from "@trailmark/core"
9
+ import { TrailmarkReporterOptions } from "../types"
10
+ import { scopeKeyFromMetaLocal } from "./run"
11
+
12
+ const GLOBAL_HASH_INPUT_DEBUG_SEEN = new Set<string>()
13
+
14
+ export interface IdentityCollision {
15
+ count: number
16
+ scopeKey: string
17
+ ruleId: string
18
+ pageKey: string
19
+ targetHash: string
20
+ issueIds: string[]
21
+ }
22
+
23
+ export function applyUrlNormalization(
24
+ run: TrailmarkRun,
25
+ options: TrailmarkReporterOptions["url"],
26
+ onDebug?: (message: string, context?: Record<string, unknown>) => void,
27
+ ): TrailmarkRun {
28
+ const seenHashInputDebugs = new Set<string>()
29
+
30
+ const findings = run.findings.map((finding) => {
31
+ const pageKey = normalizeUrlToPageKey(finding.pageUrl, options)
32
+ const scopeKey = scopeKeyFromMetaLocal(finding.meta)
33
+ const relaxedIssueId = buildRelaxedIssueId({
34
+ ruleId: finding.ruleId,
35
+ pageKey,
36
+ targetHash: finding.targetHash,
37
+ })
38
+ const issueId = buildIssueId({
39
+ ruleId: finding.ruleId,
40
+ pageKey,
41
+ targetHash: finding.targetHash,
42
+ failureHint: finding.failureHint,
43
+ })
44
+
45
+ const debugKey = hashObject({
46
+ ruleId: finding.ruleId,
47
+ pageKey,
48
+ targetHash: finding.targetHash,
49
+ failureHint: finding.failureHint,
50
+ issueId,
51
+ relaxedIssueId,
52
+ scopeKey,
53
+ })
54
+
55
+ if (!seenHashInputDebugs.has(debugKey) && !GLOBAL_HASH_INPUT_DEBUG_SEEN.has(debugKey)) {
56
+ seenHashInputDebugs.add(debugKey)
57
+ GLOBAL_HASH_INPUT_DEBUG_SEEN.add(debugKey)
58
+ onDebug?.("hash-input", {
59
+ scopeKey,
60
+ ruleId: finding.ruleId,
61
+ pageKey,
62
+ targetHash: finding.targetHash,
63
+ failureHint: finding.failureHint,
64
+ issueId,
65
+ relaxedIssueId,
66
+ })
67
+ }
68
+
69
+ return {
70
+ ...finding,
71
+ pageKey,
72
+ issueId,
73
+ relaxedIssueId,
74
+ }
75
+ })
76
+
77
+ const scanErrors = run.scanErrors.map((scanError) => {
78
+ const pageKey =
79
+ scanError.pageKey ??
80
+ (scanError.pageUrl ? normalizeUrlToPageKey(scanError.pageUrl, options) : undefined)
81
+
82
+ return {
83
+ ...scanError,
84
+ pageKey,
85
+ }
86
+ })
87
+
88
+ return {
89
+ ...run,
90
+ findings,
91
+ scanErrors,
92
+ }
93
+ }
94
+
95
+ export function detectIdentityCollisions(findings: TrailmarkFinding[]): IdentityCollision[] {
96
+ const map = new Map<string, IdentityCollision>()
97
+
98
+ for (const finding of findings) {
99
+ const scopeKey = scopeKeyFromMetaLocal(finding.meta)
100
+ const collisionKey = `${scopeKey}|${finding.ruleId}|${finding.pageKey}|${finding.targetHash}`
101
+ const existing = map.get(collisionKey)
102
+
103
+ if (existing) {
104
+ existing.count += 1
105
+ existing.issueIds.push(finding.issueId)
106
+ continue
107
+ }
108
+
109
+ map.set(collisionKey, {
110
+ count: 1,
111
+ scopeKey,
112
+ ruleId: finding.ruleId,
113
+ pageKey: finding.pageKey,
114
+ targetHash: finding.targetHash,
115
+ issueIds: [finding.issueId],
116
+ })
117
+ }
118
+
119
+ return Array.from(map.values())
120
+ .filter((entry) => new Set(entry.issueIds).size > 1)
121
+ .sort((left, right) => right.count - left.count)
122
+ }
@@ -0,0 +1,103 @@
1
+ import { DiffResult, TrailmarkRun } from "@trailmark/core"
2
+
3
+ function impactWeight(impact: string | undefined): number {
4
+ switch ((impact ?? "unknown").toLowerCase()) {
5
+ case "critical":
6
+ return 0
7
+ case "serious":
8
+ return 1
9
+ case "moderate":
10
+ return 2
11
+ case "minor":
12
+ return 3
13
+ default:
14
+ return 4
15
+ }
16
+ }
17
+
18
+ export function summarizeRunEnvelope(sourceRuns: number, findingsCount: number): void {
19
+ if (sourceRuns === 0) {
20
+ console.log("trailmark: 0 scans / no findings.")
21
+ return
22
+ }
23
+
24
+ if (findingsCount === 0) {
25
+ console.log(`trailmark: ${sourceRuns} scan run(s), 0 findings.`)
26
+ }
27
+ }
28
+
29
+ export function summarizeScanErrors(scanErrors: TrailmarkRun["scanErrors"]): void {
30
+ if (scanErrors.length === 0) {
31
+ return
32
+ }
33
+
34
+ console.error(`trailmark: ${scanErrors.length} scan error(s) captured during run.`)
35
+ for (const scanError of scanErrors.slice(0, 10)) {
36
+ const location = scanError.pageKey ?? scanError.pageUrl ?? "<unknown-page>"
37
+ console.error(`- ${location} :: ${scanError.message}`)
38
+ }
39
+ }
40
+
41
+ export function summarizeNewFindings(diff: DiffResult): void {
42
+ if (diff.newFindings.length === 0) {
43
+ console.log("trailmark: no new accessibility violations compared to baseline.")
44
+ return
45
+ }
46
+
47
+ const byImpact = Object.entries(diff.stats.newByImpact)
48
+ .sort((left, right) => impactWeight(left[0]) - impactWeight(right[0]))
49
+ .map(([impact, count]) => `${impact}:${count}`)
50
+ .join(", ")
51
+
52
+ const ruleCounts = new Map<string, number>()
53
+ for (const finding of diff.newFindings) {
54
+ ruleCounts.set(finding.ruleId, (ruleCounts.get(finding.ruleId) ?? 0) + 1)
55
+ }
56
+
57
+ const topRules = Array.from(ruleCounts.entries())
58
+ .sort((left, right) => {
59
+ const countDiff = right[1] - left[1]
60
+ if (countDiff !== 0) {
61
+ return countDiff
62
+ }
63
+ return left[0].localeCompare(right[0])
64
+ })
65
+ .slice(0, 5)
66
+ .map(([ruleId, count]) => `${ruleId}:${count}`)
67
+ .join(", ")
68
+
69
+ console.log(
70
+ `trailmark: new=${diff.stats.newCount} existing=${diff.stats.existingCount} resolved=${diff.stats.resolvedCount}.`,
71
+ )
72
+ console.log(`trailmark: new by impact -> ${byImpact || "none"}`)
73
+ console.log(`trailmark: top new rules -> ${topRules || "none"}`)
74
+
75
+ const sortedFindings = [...diff.newFindings].sort((left, right) => {
76
+ const impactDiff =
77
+ impactWeight(left.impact as string | undefined) -
78
+ impactWeight(right.impact as string | undefined)
79
+ if (impactDiff !== 0) {
80
+ return impactDiff
81
+ }
82
+
83
+ const ruleDiff = left.ruleId.localeCompare(right.ruleId)
84
+ if (ruleDiff !== 0) {
85
+ return ruleDiff
86
+ }
87
+
88
+ const pageDiff = left.pageKey.localeCompare(right.pageKey)
89
+ if (pageDiff !== 0) {
90
+ return pageDiff
91
+ }
92
+
93
+ const leftSelector = left.target[0] ?? left.selectorHint ?? ""
94
+ const rightSelector = right.target[0] ?? right.selectorHint ?? ""
95
+ return leftSelector.localeCompare(rightSelector)
96
+ })
97
+
98
+ for (const finding of sortedFindings) {
99
+ const impact = (finding.impact ?? "unknown").toLowerCase()
100
+ const selector = finding.target[0] ?? finding.selectorHint ?? "<unknown-target>"
101
+ console.log(`- ${impact} | ${finding.ruleId} | ${finding.pageKey} | ${selector}`)
102
+ }
103
+ }
@@ -0,0 +1,17 @@
1
+ import { TrailmarkFindingMeta, TrailmarkRun } from "@trailmark/core"
2
+
3
+ export function withRunShape(run: TrailmarkRun): TrailmarkRun {
4
+ if (Array.isArray(run.scanErrors)) {
5
+ return run
6
+ }
7
+
8
+ return {
9
+ ...run,
10
+ scanErrors: [],
11
+ }
12
+ }
13
+
14
+ export function scopeKeyFromMetaLocal(meta: TrailmarkFindingMeta | undefined): string {
15
+ const normalized = meta?.projectName?.trim().toLowerCase()
16
+ return normalized || "default"
17
+ }
package/src/types.ts ADDED
@@ -0,0 +1,53 @@
1
+ import { Page } from "@playwright/test"
2
+ import { TrailmarkFindingMeta, TrailmarkRun, UrlNormalizationOptions } from "@trailmark/core"
3
+
4
+ export interface TrailmarkAxeOptions {
5
+ include?: string[]
6
+ exclude?: string[]
7
+ tags?: string[]
8
+ rules?: Record<string, { enabled: boolean }>
9
+ disableRules?: string[]
10
+ }
11
+
12
+ export type TrailmarkArtifactCaptureMode = "new-only" | "all"
13
+
14
+ export interface TrailmarkArtifactOptions {
15
+ enabled?: boolean
16
+ outputDir?: string
17
+ limitPerRun?: number
18
+ capture?: TrailmarkArtifactCaptureMode
19
+ includeViewport?: boolean
20
+ includeElement?: boolean
21
+ }
22
+
23
+ export interface ScanOptions {
24
+ enabled?: boolean
25
+ continueOnError?: boolean
26
+ axe?: TrailmarkAxeOptions
27
+ url?: UrlNormalizationOptions
28
+ artifacts?: TrailmarkArtifactOptions
29
+ meta?: Partial<TrailmarkFindingMeta>
30
+ scanLabel?: string
31
+ }
32
+
33
+ export interface TrailmarkFixture {
34
+ scan(page: Page, opts?: ScanOptions): Promise<void>
35
+ getRun(): TrailmarkRun
36
+ }
37
+
38
+ export type TrailmarkMode = "baseline" | "check"
39
+
40
+ export type TrailmarkModeOptions = {
41
+ mode?: TrailmarkMode
42
+ }
43
+
44
+ export type TrailmarkReporterOptions = {
45
+ baselinePath?: string
46
+ reportPath?: string
47
+ failOnImpact?: string[]
48
+ failOnScanError?: boolean
49
+ onConfigMismatch?: "warn" | "error" | "ignore"
50
+ verbose?: boolean
51
+ url?: UrlNormalizationOptions
52
+ artifacts?: TrailmarkArtifactOptions
53
+ } & TrailmarkModeOptions
@@ -0,0 +1,141 @@
1
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import path from "node:path"
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest"
5
+ import { TrailmarkFinding } from "@trailmark/core"
6
+ import {
7
+ ArtifactManager,
8
+ createArtifactRunId,
9
+ sanitizeArtifactSegment,
10
+ } from "../src/artifacts/artifactManager"
11
+
12
+ function makeFinding(overrides: Partial<TrailmarkFinding>): TrailmarkFinding {
13
+ return {
14
+ issueId: "issue-abc1234567890",
15
+ relaxedIssueId: "relaxed-abc1234567890",
16
+ ruleId: "color-contrast",
17
+ impact: "critical",
18
+ tags: ["wcag2aa"],
19
+ pageUrl: "https://example.com/checkout",
20
+ pageKey: "example.com/checkout",
21
+ target: ["button.checkout"],
22
+ targetHash: "target-a",
23
+ selectorHint: "button.checkout",
24
+ ...overrides,
25
+ }
26
+ }
27
+
28
+ describe("artifact manager", () => {
29
+ let originalCwd: string
30
+ let tempDir: string
31
+
32
+ beforeEach(() => {
33
+ originalCwd = process.cwd()
34
+ tempDir = mkdtempSync(path.join(tmpdir(), "trailmark-artifacts-"))
35
+ process.chdir(tempDir)
36
+ })
37
+
38
+ afterEach(() => {
39
+ process.chdir(originalCwd)
40
+ rmSync(tempDir, { recursive: true, force: true })
41
+ })
42
+
43
+ it("sanitizes path segments and builds deterministic run ids", () => {
44
+ expect(sanitizeArtifactSegment(" Checkout Page / Hero ")).toBe("checkout_page_hero")
45
+ expect(sanitizeArtifactSegment("***", 20)).toBe("unknown")
46
+ expect(createArtifactRunId(new Date("2026-01-02T03:04:05.678Z"))).toBe("20260102T030405678Z")
47
+ })
48
+
49
+ it("captures viewport and element artifacts with expected file names", async () => {
50
+ const manager = new ArtifactManager({
51
+ enabled: true,
52
+ outputDir: ".trailmark/artifacts",
53
+ limitPerRun: 10,
54
+ capture: "all",
55
+ includeViewport: true,
56
+ includeElement: true,
57
+ runId: "run-123",
58
+ })
59
+
60
+ const page = {
61
+ screenshot: async ({ path: outputPath }: { path: string }) => {
62
+ writeFileSync(outputPath, "viewport", "utf8")
63
+ },
64
+ locator: (selector: string) => ({
65
+ first: () => ({
66
+ screenshot: async ({ path: outputPath }: { path: string }) => {
67
+ writeFileSync(outputPath, `element:${selector}`, "utf8")
68
+ },
69
+ }),
70
+ }),
71
+ }
72
+
73
+ const result = await manager.captureForIssue({
74
+ page: page as never,
75
+ finding: makeFinding({
76
+ issueId: "issue-abc1234567890",
77
+ }),
78
+ })
79
+
80
+ expect(result.skipped).toBe(false)
81
+ expect(result.attachments).toHaveLength(2)
82
+ expect(result.attachments).toEqual([
83
+ {
84
+ kind: "viewport",
85
+ path: ".trailmark/artifacts/run-123/critical__color-contrast__example.com_checkout__issue-abc123__viewport.png",
86
+ },
87
+ {
88
+ kind: "element",
89
+ path: ".trailmark/artifacts/run-123/critical__color-contrast__example.com_checkout__issue-abc123__element.png",
90
+ },
91
+ ])
92
+
93
+ for (const attachment of result.attachments) {
94
+ expect(attachment.path && existsSync(path.resolve(tempDir, attachment.path))).toBe(true)
95
+ }
96
+
97
+ const elementAttachment = result.attachments.find((attachment) => attachment.kind === "element")
98
+ expect(readFileSync(path.resolve(tempDir, elementAttachment?.path ?? ""), "utf8")).toContain(
99
+ "button.checkout",
100
+ )
101
+ })
102
+
103
+ it("enforces artifact limit per run", async () => {
104
+ const manager = new ArtifactManager({
105
+ enabled: true,
106
+ outputDir: ".trailmark/artifacts",
107
+ limitPerRun: 1,
108
+ capture: "all",
109
+ includeViewport: true,
110
+ includeElement: false,
111
+ runId: "run-limited",
112
+ })
113
+
114
+ const page = {
115
+ screenshot: async ({ path: outputPath }: { path: string }) => {
116
+ writeFileSync(outputPath, "viewport", "utf8")
117
+ },
118
+ locator: () => ({
119
+ first: () => ({
120
+ screenshot: async () => undefined,
121
+ }),
122
+ }),
123
+ }
124
+
125
+ const first = await manager.captureForIssue({ page: page as never, finding: makeFinding({}) })
126
+ const second = await manager.captureForIssue({
127
+ page: page as never,
128
+ finding: makeFinding({
129
+ issueId: "issue-b",
130
+ relaxedIssueId: "relaxed-b",
131
+ targetHash: "target-b",
132
+ }),
133
+ })
134
+
135
+ expect(first.skipped).toBe(false)
136
+ expect(second.skipped).toBe(true)
137
+ expect(manager.getWarnings()).toEqual([
138
+ "trailmark: artifact capture limit reached (1); skipping remaining captures.",
139
+ ])
140
+ })
141
+ })
@@ -0,0 +1,153 @@
1
+ import { existsSync, readFileSync, rmSync } from "node:fs"
2
+ import path from "node:path"
3
+ import { spawnSync } from "node:child_process"
4
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
5
+ import { chromium } from "@playwright/test"
6
+
7
+ type Mode = "baseline" | "check"
8
+ type Target = "base" | "extra"
9
+
10
+ interface CommandResult {
11
+ status: number | null
12
+ stdout: string
13
+ stderr: string
14
+ }
15
+
16
+ const packageDir = path.resolve(__dirname, "..")
17
+ const repoRoot = path.resolve(packageDir, "..", "..")
18
+ const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm"
19
+ const baselinePath = path.resolve(packageDir, "integration", "tmp", "baseline.json")
20
+ const currentRunPath = path.resolve(packageDir, ".trailmark", "current-run.json")
21
+ const reportPath = path.resolve(packageDir, ".trailmark", "report.json")
22
+ let browserReady = false
23
+ let _browserError: string | undefined
24
+
25
+ function readJson(filePath: string): unknown {
26
+ return JSON.parse(readFileSync(filePath, "utf8"))
27
+ }
28
+
29
+ function run(command: string[], cwd: string, env: NodeJS.ProcessEnv = {}): CommandResult {
30
+ const result = spawnSync(pnpmBin, command, {
31
+ cwd,
32
+ env: {
33
+ ...process.env,
34
+ ...env,
35
+ },
36
+ encoding: "utf8",
37
+ })
38
+
39
+ return {
40
+ status: result.status,
41
+ stdout: result.stdout ?? "",
42
+ stderr: result.stderr ?? "",
43
+ }
44
+ }
45
+
46
+ function runPlaywright(mode: Mode, target: Target): CommandResult {
47
+ return run(["exec", "playwright", "test", "-c", "integration/playwright.config.ts"], packageDir, {
48
+ TRAILMARK_MODE: mode,
49
+ TRAILMARK_TARGET: target,
50
+ })
51
+ }
52
+
53
+ beforeAll(() => {
54
+ const coreBuild = run(["--filter", "@trailmark/core", "build"], repoRoot)
55
+ expect(coreBuild.status, `${coreBuild.stdout}\n${coreBuild.stderr}`).toBe(0)
56
+
57
+ const pwBuild = run(["--filter", "@trailmark/playwright", "build"], repoRoot)
58
+ expect(pwBuild.status, `${pwBuild.stdout}\n${pwBuild.stderr}`).toBe(0)
59
+ })
60
+
61
+ beforeAll(async () => {
62
+ try {
63
+ const browser = await chromium.launch({ headless: true })
64
+ await browser.close()
65
+ browserReady = true
66
+ } catch (error) {
67
+ browserReady = false
68
+ _browserError = error instanceof Error ? error.message : String(error)
69
+ }
70
+ })
71
+
72
+ beforeEach(() => {
73
+ rmSync(path.resolve(packageDir, "integration", "tmp"), { recursive: true, force: true })
74
+ rmSync(path.resolve(packageDir, ".trailmark"), { recursive: true, force: true })
75
+ })
76
+
77
+ afterAll(() => {
78
+ rmSync(path.resolve(packageDir, "integration", "tmp"), { recursive: true, force: true })
79
+ rmSync(path.resolve(packageDir, ".trailmark"), { recursive: true, force: true })
80
+ })
81
+
82
+ describe("trailmark reporter integration", () => {
83
+ it("creates baseline and then passes check when findings are unchanged", (context) => {
84
+ if (!browserReady) {
85
+ context.skip()
86
+ return
87
+ }
88
+
89
+ const baselineResult = runPlaywright("baseline", "base")
90
+ expect(baselineResult.status, `${baselineResult.stdout}\n${baselineResult.stderr}`).toBe(0)
91
+ expect(existsSync(baselinePath)).toBe(true)
92
+ expect(existsSync(currentRunPath)).toBe(true)
93
+
94
+ const baseline = readJson(baselinePath) as {
95
+ findings: Record<string, unknown>
96
+ }
97
+
98
+ console.log("Baseline findings:", baseline)
99
+ expect(Object.keys(baseline.findings).length).toBeGreaterThan(0)
100
+
101
+ const checkResult = runPlaywright("check", "base")
102
+ expect(checkResult.status, `${checkResult.stdout}\n${checkResult.stderr}`).toBe(0)
103
+ expect(existsSync(reportPath)).toBe(true)
104
+
105
+ const currentRun = readJson(currentRunPath) as {
106
+ diff?: { newFindings: unknown[]; blockingNew: unknown[] }
107
+ }
108
+ const report = readJson(reportPath) as {
109
+ diff?: { newIssueIds: string[] }
110
+ summary: { totalIssues: number }
111
+ }
112
+
113
+ expect(currentRun.diff?.newFindings.length ?? -1).toBe(0)
114
+ expect(currentRun.diff?.blockingNew.length ?? -1).toBe(0)
115
+ expect(report.diff?.newIssueIds).toEqual([])
116
+ expect(report.summary.totalIssues).toBeGreaterThan(0)
117
+ })
118
+
119
+ it("fails check when new issues are introduced", (context) => {
120
+ if (!browserReady) {
121
+ context.skip()
122
+ return
123
+ }
124
+
125
+ const baselineResult = runPlaywright("baseline", "base")
126
+ expect(baselineResult.status, `${baselineResult.stdout}\n${baselineResult.stderr}`).toBe(0)
127
+
128
+ const failResult = runPlaywright("check", "extra")
129
+ expect(failResult.status).not.toBe(0)
130
+ expect(existsSync(currentRunPath)).toBe(true)
131
+ expect(existsSync(reportPath)).toBe(true)
132
+
133
+ const currentRun = readJson(currentRunPath) as {
134
+ diff?: { newFindings: unknown[]; blockingNew: unknown[] }
135
+ }
136
+ const report = readJson(reportPath) as {
137
+ diff?: { newIssueIds: string[] }
138
+ issues: Array<{ occurrences: Array<{ attachments: Array<{ path?: string }> }> }>
139
+ }
140
+
141
+ expect((currentRun.diff?.newFindings.length ?? 0) > 0).toBe(true)
142
+ expect((currentRun.diff?.blockingNew.length ?? 0) > 0).toBe(true)
143
+ expect((report.diff?.newIssueIds.length ?? 0) > 0).toBe(true)
144
+
145
+ const attachmentPaths = report.issues.flatMap((issue) =>
146
+ issue.occurrences.flatMap((occurrence) =>
147
+ occurrence.attachments.map((attachment) => attachment.path).filter(Boolean),
148
+ ),
149
+ )
150
+ expect(attachmentPaths.length > 0).toBe(true)
151
+ expect(existsSync(path.resolve(packageDir, attachmentPaths[0] ?? ""))).toBe(true)
152
+ })
153
+ })