@lullabot/playwright-testing 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/README.md +61 -0
- package/bin/github-a11y-summary +5 -0
- package/bin/github-failure-summary +8 -0
- package/lib/accessibility-baseline-file.d.ts +47 -0
- package/lib/accessibility-baseline-file.js +205 -0
- package/lib/accessibility-baseline.d.ts +19 -0
- package/lib/accessibility-baseline.js +38 -0
- package/lib/accessible-screenshot.d.ts +181 -0
- package/lib/accessible-screenshot.js +519 -0
- package/lib/focus.d.ts +14 -0
- package/lib/focus.js +28 -0
- package/lib/fonts.d.ts +18 -0
- package/lib/fonts.js +24 -0
- package/lib/frames.d.ts +7 -0
- package/lib/frames.js +27 -0
- package/lib/github/a11y-summary.d.ts +55 -0
- package/lib/github/a11y-summary.js +383 -0
- package/lib/github/attachments.d.ts +98 -0
- package/lib/github/attachments.js +297 -0
- package/lib/github/failure-summary.d.ts +144 -0
- package/lib/github/failure-summary.js +567 -0
- package/lib/github/index.d.ts +6 -0
- package/lib/github/index.js +35 -0
- package/lib/github/report-paths.d.ts +38 -0
- package/lib/github/report-paths.js +200 -0
- package/lib/hover.d.ts +13 -0
- package/lib/hover.js +61 -0
- package/lib/images.d.ts +118 -0
- package/lib/images.js +260 -0
- package/lib/index.d.ts +13 -0
- package/lib/index.js +29 -0
- package/lib/interaction-states.d.ts +22 -0
- package/lib/interaction-states.js +75 -0
- package/lib/mock/index.d.ts +1 -0
- package/lib/mock/index.js +5 -0
- package/lib/mock/youtube.d.ts +5 -0
- package/lib/mock/youtube.js +38 -0
- package/lib/pseudo-state.d.ts +17 -0
- package/lib/pseudo-state.js +50 -0
- package/lib/videos.d.ts +134 -0
- package/lib/videos.js +349 -0
- package/lib/visualdiff.d.ts +154 -0
- package/lib/visualdiff.js +197 -0
- package/package.json +47 -0
- package/src/accessibility-baseline-file.test.ts +181 -0
- package/src/accessibility-baseline-file.ts +208 -0
- package/src/accessibility-baseline.test.ts +601 -0
- package/src/accessibility-baseline.ts +50 -0
- package/src/accessible-screenshot.test.ts +597 -0
- package/src/accessible-screenshot.ts +809 -0
- package/src/focus.test.ts +34 -0
- package/src/focus.ts +27 -0
- package/src/fonts.test.ts +17 -0
- package/src/fonts.ts +23 -0
- package/src/frames.test.ts +75 -0
- package/src/frames.ts +26 -0
- package/src/github/a11y-summary.test.ts +439 -0
- package/src/github/a11y-summary.ts +421 -0
- package/src/github/attachments.test.ts +248 -0
- package/src/github/attachments.ts +328 -0
- package/src/github/failure-summary.test.ts +636 -0
- package/src/github/failure-summary.ts +720 -0
- package/src/github/index.test.ts +24 -0
- package/src/github/index.ts +35 -0
- package/src/github/report-paths.test.ts +222 -0
- package/src/github/report-paths.ts +208 -0
- package/src/hover.test.ts +76 -0
- package/src/hover.ts +64 -0
- package/src/images.test.ts +355 -0
- package/src/images.ts +299 -0
- package/src/index.ts +13 -0
- package/src/interaction-states.test.ts +48 -0
- package/src/interaction-states.ts +94 -0
- package/src/mock/index.ts +1 -0
- package/src/mock/youtube.test.ts +38 -0
- package/src/mock/youtube.ts +39 -0
- package/src/pseudo-state.test.ts +83 -0
- package/src/pseudo-state.ts +69 -0
- package/src/videos.test.ts +637 -0
- package/src/videos.ts +389 -0
- package/src/visualdiff.test.ts +452 -0
- package/src/visualdiff.ts +381 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
AttachmentUploader,
|
|
4
|
+
createPathResolver,
|
|
5
|
+
generateA11yAnnotations,
|
|
6
|
+
generateA11ySummary,
|
|
7
|
+
generateFailureComment,
|
|
8
|
+
generateFailureSummary,
|
|
9
|
+
parseA11yResults,
|
|
10
|
+
parseFailures,
|
|
11
|
+
} from './index'
|
|
12
|
+
|
|
13
|
+
describe('GitHub package entry point', () => {
|
|
14
|
+
it('exports the reporting, attachment, and path-remapping APIs', () => {
|
|
15
|
+
expect(parseA11yResults).toBeTypeOf('function')
|
|
16
|
+
expect(generateA11ySummary).toBeTypeOf('function')
|
|
17
|
+
expect(generateA11yAnnotations).toBeTypeOf('function')
|
|
18
|
+
expect(parseFailures).toBeTypeOf('function')
|
|
19
|
+
expect(generateFailureSummary).toBeTypeOf('function')
|
|
20
|
+
expect(generateFailureComment).toBeTypeOf('function')
|
|
21
|
+
expect(AttachmentUploader).toBeTypeOf('function')
|
|
22
|
+
expect(createPathResolver).toBeTypeOf('function')
|
|
23
|
+
})
|
|
24
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export {
|
|
2
|
+
generateAnnotations as generateA11yAnnotations,
|
|
3
|
+
generateSummary as generateA11ySummary,
|
|
4
|
+
main as runA11ySummary,
|
|
5
|
+
parseA11yResults,
|
|
6
|
+
} from './a11y-summary'
|
|
7
|
+
export type { A11yReport, A11yTestResult } from './a11y-summary'
|
|
8
|
+
|
|
9
|
+
export * from './attachments'
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
FAILURE_MARKER_PREFIX,
|
|
13
|
+
FLAKE_MARKER_PREFIX,
|
|
14
|
+
defuseMaskTriggers,
|
|
15
|
+
generateComment as generateFailureComment,
|
|
16
|
+
generateSummary as generateFailureSummary,
|
|
17
|
+
main as runFailureSummary,
|
|
18
|
+
parseFailures,
|
|
19
|
+
resolveImagePaths,
|
|
20
|
+
uploaderFromEnvironment,
|
|
21
|
+
uploadImages,
|
|
22
|
+
} from './failure-summary'
|
|
23
|
+
export type {
|
|
24
|
+
FailedTest,
|
|
25
|
+
FailureImage,
|
|
26
|
+
FailureReport,
|
|
27
|
+
FailureCommentOptions,
|
|
28
|
+
FailureSummaryMainOptions,
|
|
29
|
+
ImageKind,
|
|
30
|
+
IncludeMode,
|
|
31
|
+
PathResolutionSummary,
|
|
32
|
+
SummaryOptions,
|
|
33
|
+
} from './failure-summary'
|
|
34
|
+
|
|
35
|
+
export * from './report-paths'
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import * as fs from 'fs'
|
|
3
|
+
import * as os from 'os'
|
|
4
|
+
import * as path from 'path'
|
|
5
|
+
import { createPathResolver, parsePathPrefix } from './report-paths'
|
|
6
|
+
|
|
7
|
+
let tmpDir: string
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'report-paths-test-'))
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
/** Create a file, and every directory above it, under the temporary directory. */
|
|
18
|
+
function touch(relativePath: string): string {
|
|
19
|
+
const filePath = path.join(tmpDir, relativePath)
|
|
20
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
21
|
+
fs.writeFileSync(filePath, 'png')
|
|
22
|
+
return filePath
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('parsePathPrefix', () => {
|
|
26
|
+
it('splits on the first colon', () => {
|
|
27
|
+
expect(parsePathPrefix('/var/www/html:/home/runner/work/site/site')).toEqual({
|
|
28
|
+
from: '/var/www/html',
|
|
29
|
+
to: '/home/runner/work/site/site',
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('keeps a colon in the local path, as Windows drives have', () => {
|
|
34
|
+
expect(parsePathPrefix('/var/www/html:C:/checkout')).toEqual({
|
|
35
|
+
from: '/var/www/html',
|
|
36
|
+
to: 'C:/checkout',
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('rejects a value that is not a pair', () => {
|
|
41
|
+
expect(parsePathPrefix('/var/www/html')).toBeNull()
|
|
42
|
+
expect(parsePathPrefix(':/home/runner')).toBeNull()
|
|
43
|
+
expect(parsePathPrefix('/var/www/html:')).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('createPathResolver', () => {
|
|
48
|
+
it('leaves a path that already resolves alone', () => {
|
|
49
|
+
const filePath = touch('test/playwright/test-results/diff.png')
|
|
50
|
+
const resolve = createPathResolver({ reportPath: path.join(tmpDir, 'results.json') })
|
|
51
|
+
|
|
52
|
+
expect(resolve(filePath)).toEqual({ path: filePath, found: true })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('re-roots a container path onto the checkout', () => {
|
|
56
|
+
touch('test/playwright/test-results/home-chromium/home-1-diff.png')
|
|
57
|
+
const resolve = createPathResolver({
|
|
58
|
+
reportPath: path.join(tmpDir, 'test/playwright/test-results/results.json'),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const resolution = resolve(
|
|
62
|
+
'/var/www/html/test/playwright/test-results/home-chromium/home-1-diff.png',
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
expect(resolution.found).toBe(true)
|
|
66
|
+
expect(resolution.path)
|
|
67
|
+
.toBe(path.join(tmpDir, 'test/playwright/test-results/home-chromium/home-1-diff.png'))
|
|
68
|
+
expect(resolution.prefix).toEqual({ from: '/var/www/html', to: tmpDir })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('finds the files when the report sits outside the output directory', () => {
|
|
72
|
+
// The arrangement the integration test produces: the report is written
|
|
73
|
+
// beside the Playwright config while the images go to a separate output
|
|
74
|
+
// directory, so the two share nothing but the mount point.
|
|
75
|
+
touch('test/playwright/test-results-visual/fixture-chromium/fixture-diff.png')
|
|
76
|
+
const resolve = createPathResolver({
|
|
77
|
+
reportPath: path.join(tmpDir, 'test/playwright/visual-diff-results.json'),
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const resolution = resolve(
|
|
81
|
+
'/var/www/html/test/playwright/test-results-visual/fixture-chromium/fixture-diff.png',
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
expect(resolution.found).toBe(true)
|
|
85
|
+
expect(resolution.path)
|
|
86
|
+
.toBe(path.join(tmpDir, 'test/playwright/test-results-visual/fixture-chromium/fixture-diff.png'))
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('does not assume a fixed offset between the report and the mount point', () => {
|
|
90
|
+
// This repository's own CI creates the project under a generated directory,
|
|
91
|
+
// so its depth below the workspace differs from the container's.
|
|
92
|
+
touch('tmp/pwtest-abc123/test/playwright/test-results/home-chromium/home-1-diff.png')
|
|
93
|
+
const resolve = createPathResolver({
|
|
94
|
+
reportPath: path.join(tmpDir, 'tmp/pwtest-abc123/test/playwright/test-results/results.json'),
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const resolution = resolve(
|
|
98
|
+
'/var/www/html/test/playwright/test-results/home-chromium/home-1-diff.png',
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
expect(resolution.found).toBe(true)
|
|
102
|
+
expect(resolution.prefix).toEqual({
|
|
103
|
+
from: '/var/www/html',
|
|
104
|
+
to: path.join(tmpDir, 'tmp/pwtest-abc123'),
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('prefers the longest tail, so the most specific match wins', () => {
|
|
109
|
+
// A shallow decoy sharing the last two components must not beat the real
|
|
110
|
+
// file, which shares many more.
|
|
111
|
+
touch('decoy/fixture-chromium/fixture-diff.png')
|
|
112
|
+
touch('test/playwright/test-results/fixture-chromium/fixture-diff.png')
|
|
113
|
+
|
|
114
|
+
const resolve = createPathResolver({
|
|
115
|
+
reportPath: path.join(tmpDir, 'test/playwright/test-results/results.json'),
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
expect(resolve('/var/www/html/test/playwright/test-results/fixture-chromium/fixture-diff.png').path)
|
|
119
|
+
.toBe(path.join(tmpDir, 'test/playwright/test-results/fixture-chromium/fixture-diff.png'))
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('will not identify a file by its name alone', () => {
|
|
123
|
+
// One component of tail is a coincidence waiting to happen: some unrelated
|
|
124
|
+
// fixture-diff.png must not be served up as the failure's diff image.
|
|
125
|
+
touch('fixture-diff.png')
|
|
126
|
+
const resolve = createPathResolver({ reportPath: path.join(tmpDir, 'results.json') })
|
|
127
|
+
|
|
128
|
+
expect(resolve('/var/www/html/somewhere/else/fixture-diff.png').found).toBe(false)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('reports a path it cannot find rather than inventing one', () => {
|
|
132
|
+
const resolve = createPathResolver({
|
|
133
|
+
reportPath: path.join(tmpDir, 'test/playwright/test-results/results.json'),
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const resolution = resolve('/var/www/html/test/playwright/test-results/missing-diff.png')
|
|
137
|
+
|
|
138
|
+
expect(resolution).toEqual({
|
|
139
|
+
path: '/var/www/html/test/playwright/test-results/missing-diff.png',
|
|
140
|
+
found: false,
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('reuses a mapping it has already worked out', () => {
|
|
145
|
+
touch('test-results/one/a-diff.png')
|
|
146
|
+
touch('test-results/two/b-diff.png')
|
|
147
|
+
|
|
148
|
+
const looked: string[] = []
|
|
149
|
+
const resolve = createPathResolver({
|
|
150
|
+
reportPath: path.join(tmpDir, 'results.json'),
|
|
151
|
+
exists: filePath => {
|
|
152
|
+
looked.push(filePath)
|
|
153
|
+
return fs.existsSync(filePath)
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
resolve('/var/www/html/test-results/one/a-diff.png')
|
|
158
|
+
const afterFirst = looked.length
|
|
159
|
+
looked.length = 0
|
|
160
|
+
|
|
161
|
+
expect(resolve('/var/www/html/test-results/two/b-diff.png').found).toBe(true)
|
|
162
|
+
// The learned mapping is tried straight after the original path, rather
|
|
163
|
+
// than searching the tree again.
|
|
164
|
+
expect(looked.length).toBeLessThan(afterFirst)
|
|
165
|
+
expect(looked).toEqual([
|
|
166
|
+
'/var/www/html/test-results/two/b-diff.png',
|
|
167
|
+
path.join(tmpDir, 'test-results/two/b-diff.png'),
|
|
168
|
+
])
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('prefers an explicit prefix over anything it would work out', () => {
|
|
172
|
+
touch('elsewhere/test-results/home-1-diff.png')
|
|
173
|
+
touch('test/playwright/test-results/home-1-diff.png')
|
|
174
|
+
|
|
175
|
+
const resolve = createPathResolver({
|
|
176
|
+
reportPath: path.join(tmpDir, 'test/playwright/test-results/results.json'),
|
|
177
|
+
prefixes: [{ from: '/var/www/html/test/playwright', to: path.join(tmpDir, 'elsewhere') }],
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
expect(resolve('/var/www/html/test/playwright/test-results/home-1-diff.png').path)
|
|
181
|
+
.toBe(path.join(tmpDir, 'elsewhere/test-results/home-1-diff.png'))
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('ignores a prefix the path is not under', () => {
|
|
185
|
+
touch('test-results/diff.png')
|
|
186
|
+
const resolve = createPathResolver({
|
|
187
|
+
reportPath: path.join(tmpDir, 'results.json'),
|
|
188
|
+
prefixes: [{ from: '/somewhere/else', to: tmpDir }],
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// The explicit prefix does not apply, and one component of tail is too
|
|
192
|
+
// little to search on.
|
|
193
|
+
expect(resolve('/var/www/html/diff.png').found).toBe(false)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('does not match a prefix part way through a directory name', () => {
|
|
197
|
+
touch('results/diff.png')
|
|
198
|
+
const resolve = createPathResolver({
|
|
199
|
+
reportPath: path.join(tmpDir, 'results.json'),
|
|
200
|
+
prefixes: [{ from: '/var/www/ht', to: tmpDir }],
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
// /var/www/html starts with the string /var/www/ht but is not under it,
|
|
204
|
+
// so the explicit mapping must not fire. The search still finds the file.
|
|
205
|
+
const resolution = resolve('/var/www/html/results/diff.png')
|
|
206
|
+
expect(resolution.path).toBe(path.join(tmpDir, 'results/diff.png'))
|
|
207
|
+
expect(resolution.prefix).toEqual({ from: '/var/www/html', to: tmpDir })
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('tolerates trailing slashes on the local side', () => {
|
|
211
|
+
touch('test-results/diff.png')
|
|
212
|
+
const resolve = createPathResolver({
|
|
213
|
+
reportPath: path.join(tmpDir, 'results.json'),
|
|
214
|
+
// A run of them, not just one: the trim is an index scan rather than a
|
|
215
|
+
// regex, because `/[\\/]+$/` backtracks quadratically over exactly this.
|
|
216
|
+
prefixes: [{ from: '/var/www/html', to: `${tmpDir}///` }],
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
expect(resolve('/var/www/html/test-results/diff.png').path)
|
|
220
|
+
.toBe(`${tmpDir}/test-results/diff.png`)
|
|
221
|
+
})
|
|
222
|
+
})
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import * as fs from 'fs'
|
|
2
|
+
import * as path from 'path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Translate the file paths a Playwright JSON report records into paths the
|
|
6
|
+
* process reading that report can actually open.
|
|
7
|
+
*
|
|
8
|
+
* The two are not always the same file system. Running Playwright inside a
|
|
9
|
+
* container — which is the normal arrangement for this package, where the suite
|
|
10
|
+
* runs in DDEV's web container — records every attachment under the path it had
|
|
11
|
+
* *there*, `/var/www/html/…`. A workflow step reading the report on the runner
|
|
12
|
+
* is on the other side of that boundary, and `/var/www/html` means nothing to
|
|
13
|
+
* it. The report is readable, the attachments are not, and nothing about the
|
|
14
|
+
* failure says why.
|
|
15
|
+
*
|
|
16
|
+
* The bind mount that creates the problem also solves it: the same files are
|
|
17
|
+
* visible from both sides under different prefixes, so the paths need
|
|
18
|
+
* re-rooting rather than fetching. What the two views share is the tail of the
|
|
19
|
+
* path — the part below the mount point — so the mount point is found by
|
|
20
|
+
* hanging progressively longer tails of a recorded path off the directories
|
|
21
|
+
* around the report until one of them names a file that exists.
|
|
22
|
+
*
|
|
23
|
+
* Every mapping is confirmed against the disk before it is used, so a wrong
|
|
24
|
+
* guess resolves to nothing rather than to the wrong image. The report's own
|
|
25
|
+
* `config` is deliberately not consulted: `rootDir` is the *test* directory
|
|
26
|
+
* rather than the project root, and `outputDir` need not contain the report,
|
|
27
|
+
* so neither reliably shares a tail with the path the report was read from.
|
|
28
|
+
*
|
|
29
|
+
* Symlinking the container path on the runner instead looks like it should
|
|
30
|
+
* work and does not: the Ubuntu runner image ships Apache, so /var/www/html
|
|
31
|
+
* already exists as a directory and `ln -sfn` quietly creates the link inside
|
|
32
|
+
* it. The command exits 0 and changes nothing.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Shortest tail worth trying, in path components.
|
|
37
|
+
*
|
|
38
|
+
* A bare file name is too little to identify a file by — some unrelated
|
|
39
|
+
* `fixture-diff.png` elsewhere in the tree would match. One directory of
|
|
40
|
+
* context makes a coincidence unlikely, and longer tails are tried first
|
|
41
|
+
* regardless, so the most specific match always wins.
|
|
42
|
+
*/
|
|
43
|
+
const MIN_TAIL_COMPONENTS = 2
|
|
44
|
+
|
|
45
|
+
export interface PathPrefix {
|
|
46
|
+
/** Absolute path as recorded in the report — typically a container path. */
|
|
47
|
+
from: string
|
|
48
|
+
/** The same directory as seen from here. */
|
|
49
|
+
to: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface PathResolution {
|
|
53
|
+
/** The path to read. Unchanged when the recorded one was already fine. */
|
|
54
|
+
path: string
|
|
55
|
+
/** Whether a readable file was found. */
|
|
56
|
+
found: boolean
|
|
57
|
+
/** The mapping that made it readable, when one was needed. */
|
|
58
|
+
prefix?: PathPrefix
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface PathResolverOptions {
|
|
62
|
+
/** Where the JSON report was read from, on this side of the boundary. */
|
|
63
|
+
reportPath: string
|
|
64
|
+
/** Explicit mappings. Tried before anything is worked out. */
|
|
65
|
+
prefixes?: PathPrefix[]
|
|
66
|
+
/** Existence check, injectable for tests. */
|
|
67
|
+
exists?: (filePath: string) => boolean
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Parse a `FROM:TO` pair, as `--path-prefix` takes it.
|
|
72
|
+
*
|
|
73
|
+
* Split on the first colon: `TO` is a local absolute path and may itself
|
|
74
|
+
* contain one on Windows, whereas `FROM` comes from a container and will not.
|
|
75
|
+
*/
|
|
76
|
+
export function parsePathPrefix(value: string): PathPrefix | null {
|
|
77
|
+
const separator = value.indexOf(':')
|
|
78
|
+
if (separator <= 0) return null
|
|
79
|
+
|
|
80
|
+
const from = value.slice(0, separator).trim()
|
|
81
|
+
const to = value.slice(separator + 1).trim()
|
|
82
|
+
if (!from || !to) return null
|
|
83
|
+
|
|
84
|
+
return { from, to }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build the function that turns a recorded path into a readable one.
|
|
89
|
+
*
|
|
90
|
+
* A path that already resolves is left alone, so this costs one `stat` per
|
|
91
|
+
* attachment when the report and the files are on the same side. A mapping
|
|
92
|
+
* worked out for one attachment is kept and tried first for the rest, so the
|
|
93
|
+
* search runs once per run rather than once per image.
|
|
94
|
+
*/
|
|
95
|
+
export function createPathResolver(
|
|
96
|
+
options: PathResolverOptions,
|
|
97
|
+
): (filePath: string) => PathResolution {
|
|
98
|
+
const exists = options.exists ?? ((filePath: string) => fs.existsSync(filePath))
|
|
99
|
+
const explicit = options.prefixes ?? []
|
|
100
|
+
const searchRoots = ancestors(path.dirname(path.resolve(options.reportPath)))
|
|
101
|
+
const learned: PathPrefix[] = []
|
|
102
|
+
|
|
103
|
+
/** Hang tails of the recorded path off each directory around the report. */
|
|
104
|
+
function search(filePath: string): PathResolution | null {
|
|
105
|
+
const target = splitPath(filePath)
|
|
106
|
+
|
|
107
|
+
// Longest tail first, so the most specific match wins. Stop one short of
|
|
108
|
+
// the whole path: something has to be left to rewrite.
|
|
109
|
+
for (let length = target.components.length - 1; length >= MIN_TAIL_COMPONENTS; length--) {
|
|
110
|
+
const tail = target.components.slice(target.components.length - length).join('/')
|
|
111
|
+
|
|
112
|
+
for (const root of searchRoots) {
|
|
113
|
+
const candidate = `${trimTrailingSeparators(root)}/${tail}`
|
|
114
|
+
if (!exists(candidate)) continue
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
path: candidate,
|
|
118
|
+
found: true,
|
|
119
|
+
prefix: { from: joinPath(target, target.components.length - length), to: root },
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (filePath: string): PathResolution => {
|
|
128
|
+
if (exists(filePath)) return { path: filePath, found: true }
|
|
129
|
+
|
|
130
|
+
for (const prefix of [...explicit, ...learned]) {
|
|
131
|
+
const rewritten = applyPrefix(filePath, prefix)
|
|
132
|
+
if (rewritten && exists(rewritten)) return { path: rewritten, found: true, prefix }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const discovered = search(filePath)
|
|
136
|
+
if (!discovered) return { path: filePath, found: false }
|
|
137
|
+
|
|
138
|
+
if (discovered.prefix) learned.push(discovered.prefix)
|
|
139
|
+
return discovered
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Swap one prefix for another, or null when the path is not under it. */
|
|
144
|
+
function applyPrefix(filePath: string, prefix: PathPrefix): string | null {
|
|
145
|
+
const from = splitPath(prefix.from)
|
|
146
|
+
const target = splitPath(filePath)
|
|
147
|
+
|
|
148
|
+
if (target.components.length < from.components.length) return null
|
|
149
|
+
for (let index = 0; index < from.components.length; index++) {
|
|
150
|
+
if (target.components[index] !== from.components[index]) return null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const rest = target.components.slice(from.components.length)
|
|
154
|
+
const to = trimTrailingSeparators(prefix.to)
|
|
155
|
+
return rest.length === 0 ? to : `${to}/${rest.join('/')}`
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Trim trailing separators.
|
|
160
|
+
*
|
|
161
|
+
* An index scan rather than `/[\\/]+$/`, because that pattern backtracks
|
|
162
|
+
* quadratically over a long run of separators — and these strings are paths
|
|
163
|
+
* out of a report this package did not write.
|
|
164
|
+
*/
|
|
165
|
+
function trimTrailingSeparators(value: string): string {
|
|
166
|
+
let end = value.length
|
|
167
|
+
while (end > 0 && (value[end - 1] === '/' || value[end - 1] === '\\')) end--
|
|
168
|
+
return value.slice(0, end)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
interface SplitPath {
|
|
172
|
+
/** `/`, `C:/`, or empty for a relative path. */
|
|
173
|
+
root: string
|
|
174
|
+
components: string[]
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Split a path into a root and its components, accepting either separator.
|
|
179
|
+
*
|
|
180
|
+
* The two sides of the boundary need not agree on the separator, and the
|
|
181
|
+
* recorded paths come from a report rather than from this platform.
|
|
182
|
+
*/
|
|
183
|
+
function splitPath(value: string): SplitPath {
|
|
184
|
+
const match = /^([A-Za-z]:[\\/]|[\\/])?/.exec(value)
|
|
185
|
+
const prefix = match?.[1] ?? ''
|
|
186
|
+
return {
|
|
187
|
+
root: prefix.replace(/\\/g, '/'),
|
|
188
|
+
components: value.slice(prefix.length).split(/[\\/]+/).filter(Boolean),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Rebuild a path from its first `count` components. */
|
|
193
|
+
function joinPath(split: SplitPath, count: number): string {
|
|
194
|
+
return split.root + split.components.slice(0, count).join('/')
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A directory and every directory above it. */
|
|
198
|
+
function ancestors(directory: string): string[] {
|
|
199
|
+
const out: string[] = []
|
|
200
|
+
let current = directory
|
|
201
|
+
|
|
202
|
+
for (;;) {
|
|
203
|
+
out.push(current)
|
|
204
|
+
const parent = path.dirname(current)
|
|
205
|
+
if (parent === current) return out
|
|
206
|
+
current = parent
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {afterEach, describe, expect, it, vi} from 'vitest'
|
|
2
|
+
import {clearHover} from './hover'
|
|
3
|
+
|
|
4
|
+
function fakePage() {
|
|
5
|
+
let shield: any
|
|
6
|
+
const document = {
|
|
7
|
+
querySelector: vi.fn(() => shield),
|
|
8
|
+
createElement: vi.fn(() => {
|
|
9
|
+
shield = {
|
|
10
|
+
setAttribute: vi.fn(),
|
|
11
|
+
style: {cssText: ''},
|
|
12
|
+
remove: vi.fn(() => {
|
|
13
|
+
shield = undefined
|
|
14
|
+
}),
|
|
15
|
+
}
|
|
16
|
+
return shield
|
|
17
|
+
}),
|
|
18
|
+
documentElement: {
|
|
19
|
+
appendChild: vi.fn(),
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
const page = {
|
|
23
|
+
evaluate: vi.fn(async (callback: (attribute: string) => void, attribute: string) => {
|
|
24
|
+
vi.stubGlobal('document', document)
|
|
25
|
+
callback(attribute)
|
|
26
|
+
}),
|
|
27
|
+
mouse: {
|
|
28
|
+
move: vi.fn().mockResolvedValue(undefined),
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {document, page, getShield: () => shield}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('clearHover', () => {
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
vi.unstubAllGlobals()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('covers page content and moves the pointer onto the shield', async () => {
|
|
41
|
+
const {document, page, getShield} = fakePage()
|
|
42
|
+
|
|
43
|
+
const cleanup = await clearHover(page as any)
|
|
44
|
+
const shield = getShield()
|
|
45
|
+
|
|
46
|
+
expect(document.createElement).toHaveBeenCalledWith('playwright-testing-hover-shield')
|
|
47
|
+
expect(shield.setAttribute).toHaveBeenCalledWith('aria-hidden', 'true')
|
|
48
|
+
expect(shield.style.cssText).toContain('position: fixed !important')
|
|
49
|
+
expect(shield.style.cssText).toContain('pointer-events: auto !important')
|
|
50
|
+
expect(shield.style.cssText).toContain('opacity: 0 !important')
|
|
51
|
+
expect(document.documentElement.appendChild).toHaveBeenCalledWith(shield)
|
|
52
|
+
expect(page.mouse.move).toHaveBeenCalledWith(0, 0)
|
|
53
|
+
|
|
54
|
+
await cleanup()
|
|
55
|
+
expect(shield.remove).toHaveBeenCalledOnce()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('makes cleanup idempotent', async () => {
|
|
59
|
+
const {page, getShield} = fakePage()
|
|
60
|
+
const cleanup = await clearHover(page as any)
|
|
61
|
+
const shield = getShield()
|
|
62
|
+
|
|
63
|
+
await cleanup()
|
|
64
|
+
await cleanup()
|
|
65
|
+
|
|
66
|
+
expect(shield.remove).toHaveBeenCalledOnce()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('removes the shield when moving the pointer fails', async () => {
|
|
70
|
+
const {page, getShield} = fakePage()
|
|
71
|
+
page.mouse.move.mockRejectedValueOnce(new Error('mouse failed'))
|
|
72
|
+
|
|
73
|
+
await expect(clearHover(page as any)).rejects.toThrow('mouse failed')
|
|
74
|
+
expect(getShield()).toBeUndefined()
|
|
75
|
+
})
|
|
76
|
+
})
|
package/src/hover.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import {Page} from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
const HOVER_SHIELD_ATTRIBUTE = 'data-playwright-testing-hover-shield';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Move the pointer away from page content for a deterministic screenshot.
|
|
7
|
+
*
|
|
8
|
+
* Pointer position survives clicks, navigation, and layout changes. That can
|
|
9
|
+
* leave an unrelated element under the pointer and activate its `:hover`
|
|
10
|
+
* styles. A transparent shield gives the pointer a neutral target regardless
|
|
11
|
+
* of what the page renders at the chosen coordinate.
|
|
12
|
+
*
|
|
13
|
+
* The returned cleanup function removes the shield. Call it after the
|
|
14
|
+
* screenshot so normal pointer hit testing is restored.
|
|
15
|
+
*/
|
|
16
|
+
export async function clearHover(page: Page): Promise<() => Promise<void>> {
|
|
17
|
+
await page.evaluate((attribute) => {
|
|
18
|
+
document.querySelector(`[${attribute}]`)?.remove();
|
|
19
|
+
|
|
20
|
+
const shield = document.createElement('playwright-testing-hover-shield');
|
|
21
|
+
shield.setAttribute(attribute, '');
|
|
22
|
+
shield.setAttribute('aria-hidden', 'true');
|
|
23
|
+
shield.style.cssText = [
|
|
24
|
+
'all: initial !important',
|
|
25
|
+
'position: fixed !important',
|
|
26
|
+
'inset: 0 !important',
|
|
27
|
+
'display: block !important',
|
|
28
|
+
'width: 100vw !important',
|
|
29
|
+
'height: 100vh !important',
|
|
30
|
+
'margin: 0 !important',
|
|
31
|
+
'padding: 0 !important',
|
|
32
|
+
'border: 0 !important',
|
|
33
|
+
'opacity: 0 !important',
|
|
34
|
+
'pointer-events: auto !important',
|
|
35
|
+
'z-index: 2147483647 !important',
|
|
36
|
+
].join(';');
|
|
37
|
+
document.documentElement.appendChild(shield);
|
|
38
|
+
}, HOVER_SHIELD_ATTRIBUTE);
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
// Playwright emits a mousemove even when the pointer was already at this
|
|
42
|
+
// coordinate, ensuring mouseleave/pointerleave handlers get a chance to
|
|
43
|
+
// respond to the new hit target.
|
|
44
|
+
await page.mouse.move(0, 0);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
await removeHoverShield(page);
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let removed = false;
|
|
51
|
+
return async () => {
|
|
52
|
+
if (removed) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
removed = true;
|
|
56
|
+
await removeHoverShield(page);
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function removeHoverShield(page: Page): Promise<void> {
|
|
61
|
+
await page.evaluate((attribute) => {
|
|
62
|
+
document.querySelector(`[${attribute}]`)?.remove();
|
|
63
|
+
}, HOVER_SHIELD_ATTRIBUTE);
|
|
64
|
+
}
|