@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,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
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -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
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
|
+
}
|