@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,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
|
+
})
|