@lullabot/playwright-testing 1.0.0 → 1.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/bin/github-a11y-summary +2 -2
- package/bin/github-failure-summary +4 -4
- package/lib/accessibility-baseline-file.d.ts +6 -6
- package/lib/accessibility-baseline-file.js +29 -23
- package/lib/accessibility-baseline.js +5 -3
- package/lib/accessible-screenshot.d.ts +4 -4
- package/lib/accessible-screenshot.js +110 -88
- package/lib/focus.js +4 -1
- package/lib/frames.js +4 -4
- package/lib/github/a11y-summary.js +74 -79
- package/lib/github/attachments.d.ts +1 -1
- package/lib/github/attachments.js +36 -34
- package/lib/github/failure-summary.d.ts +4 -4
- package/lib/github/failure-summary.js +140 -108
- package/lib/github/index.d.ts +6 -6
- package/lib/github/report-paths.js +17 -9
- package/lib/hover.js +17 -17
- package/lib/images.js +15 -12
- package/lib/index.d.ts +13 -13
- package/lib/interaction-states.d.ts +2 -2
- package/lib/interaction-states.js +7 -7
- package/lib/mock/index.d.ts +1 -1
- package/lib/mock/youtube.d.ts +1 -1
- package/lib/mock/youtube.js +1 -1
- package/lib/pseudo-state.d.ts +2 -2
- package/lib/pseudo-state.js +15 -7
- package/lib/videos.js +21 -10
- package/lib/visualdiff.d.ts +4 -4
- package/lib/visualdiff.js +35 -27
- package/package.json +3 -3
- package/src/accessibility-baseline-file.test.ts +190 -154
- package/src/accessibility-baseline-file.ts +107 -83
- package/src/accessibility-baseline.test.ts +869 -433
- package/src/accessibility-baseline.ts +41 -21
- package/src/accessible-screenshot.test.ts +551 -423
- package/src/accessible-screenshot.ts +358 -251
- package/src/focus.test.ts +23 -23
- package/src/focus.ts +7 -2
- package/src/fonts.test.ts +13 -13
- package/src/fonts.ts +1 -1
- package/src/frames.test.ts +10 -8
- package/src/frames.ts +5 -5
- package/src/github/a11y-summary.test.ts +436 -332
- package/src/github/a11y-summary.ts +189 -179
- package/src/github/attachments.test.ts +228 -167
- package/src/github/attachments.ts +144 -121
- package/src/github/failure-summary.test.ts +660 -445
- package/src/github/failure-summary.ts +376 -274
- package/src/github/index.test.ts +14 -14
- package/src/github/index.ts +6 -6
- package/src/github/report-paths.test.ts +211 -156
- package/src/github/report-paths.ts +75 -61
- package/src/hover.test.ts +50 -46
- package/src/hover.ts +18 -18
- package/src/images.test.ts +251 -206
- package/src/images.ts +52 -43
- package/src/index.ts +13 -13
- package/src/interaction-states.test.ts +72 -42
- package/src/interaction-states.ts +43 -29
- package/src/mock/index.ts +1 -1
- package/src/mock/youtube.test.ts +26 -26
- package/src/mock/youtube.ts +4 -6
- package/src/pseudo-state.test.ts +58 -51
- package/src/pseudo-state.ts +32 -27
- package/src/videos.test.ts +559 -399
- package/src/videos.ts +68 -44
- package/src/visualdiff.test.ts +416 -315
- package/src/visualdiff.ts +140 -99
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import * as fs from
|
|
2
|
-
import * as path from
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Translate the file paths a Playwright JSON report records into paths the
|
|
@@ -40,31 +40,31 @@ import * as path from 'path'
|
|
|
40
40
|
* context makes a coincidence unlikely, and longer tails are tried first
|
|
41
41
|
* regardless, so the most specific match always wins.
|
|
42
42
|
*/
|
|
43
|
-
const MIN_TAIL_COMPONENTS = 2
|
|
43
|
+
const MIN_TAIL_COMPONENTS = 2;
|
|
44
44
|
|
|
45
45
|
export interface PathPrefix {
|
|
46
46
|
/** Absolute path as recorded in the report — typically a container path. */
|
|
47
|
-
from: string
|
|
47
|
+
from: string;
|
|
48
48
|
/** The same directory as seen from here. */
|
|
49
|
-
to: string
|
|
49
|
+
to: string;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
export interface PathResolution {
|
|
53
53
|
/** The path to read. Unchanged when the recorded one was already fine. */
|
|
54
|
-
path: string
|
|
54
|
+
path: string;
|
|
55
55
|
/** Whether a readable file was found. */
|
|
56
|
-
found: boolean
|
|
56
|
+
found: boolean;
|
|
57
57
|
/** The mapping that made it readable, when one was needed. */
|
|
58
|
-
prefix?: PathPrefix
|
|
58
|
+
prefix?: PathPrefix;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
export interface PathResolverOptions {
|
|
62
62
|
/** Where the JSON report was read from, on this side of the boundary. */
|
|
63
|
-
reportPath: string
|
|
63
|
+
reportPath: string;
|
|
64
64
|
/** Explicit mappings. Tried before anything is worked out. */
|
|
65
|
-
prefixes?: PathPrefix[]
|
|
65
|
+
prefixes?: PathPrefix[];
|
|
66
66
|
/** Existence check, injectable for tests. */
|
|
67
|
-
exists?: (filePath: string) => boolean
|
|
67
|
+
exists?: (filePath: string) => boolean;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/**
|
|
@@ -74,14 +74,14 @@ export interface PathResolverOptions {
|
|
|
74
74
|
* contain one on Windows, whereas `FROM` comes from a container and will not.
|
|
75
75
|
*/
|
|
76
76
|
export function parsePathPrefix(value: string): PathPrefix | null {
|
|
77
|
-
const separator = value.indexOf(
|
|
78
|
-
if (separator <= 0) return null
|
|
77
|
+
const separator = value.indexOf(":");
|
|
78
|
+
if (separator <= 0) return null;
|
|
79
79
|
|
|
80
|
-
const from = value.slice(0, separator).trim()
|
|
81
|
-
const to = value.slice(separator + 1).trim()
|
|
82
|
-
if (!from || !to) return null
|
|
80
|
+
const from = value.slice(0, separator).trim();
|
|
81
|
+
const to = value.slice(separator + 1).trim();
|
|
82
|
+
if (!from || !to) return null;
|
|
83
83
|
|
|
84
|
-
return { from, to }
|
|
84
|
+
return { from, to };
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/**
|
|
@@ -95,64 +95,75 @@ export function parsePathPrefix(value: string): PathPrefix | null {
|
|
|
95
95
|
export function createPathResolver(
|
|
96
96
|
options: PathResolverOptions,
|
|
97
97
|
): (filePath: string) => PathResolution {
|
|
98
|
-
const exists =
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
const
|
|
98
|
+
const exists =
|
|
99
|
+
options.exists ?? ((filePath: string) => fs.existsSync(filePath));
|
|
100
|
+
const explicit = options.prefixes ?? [];
|
|
101
|
+
const searchRoots = ancestors(path.dirname(path.resolve(options.reportPath)));
|
|
102
|
+
const learned: PathPrefix[] = [];
|
|
102
103
|
|
|
103
104
|
/** Hang tails of the recorded path off each directory around the report. */
|
|
104
105
|
function search(filePath: string): PathResolution | null {
|
|
105
|
-
const target = splitPath(filePath)
|
|
106
|
+
const target = splitPath(filePath);
|
|
106
107
|
|
|
107
108
|
// Longest tail first, so the most specific match wins. Stop one short of
|
|
108
109
|
// the whole path: something has to be left to rewrite.
|
|
109
|
-
for (
|
|
110
|
-
|
|
110
|
+
for (
|
|
111
|
+
let length = target.components.length - 1;
|
|
112
|
+
length >= MIN_TAIL_COMPONENTS;
|
|
113
|
+
length--
|
|
114
|
+
) {
|
|
115
|
+
const tail = target.components
|
|
116
|
+
.slice(target.components.length - length)
|
|
117
|
+
.join("/");
|
|
111
118
|
|
|
112
119
|
for (const root of searchRoots) {
|
|
113
|
-
const candidate = `${trimTrailingSeparators(root)}/${tail}
|
|
114
|
-
if (!exists(candidate)) continue
|
|
120
|
+
const candidate = `${trimTrailingSeparators(root)}/${tail}`;
|
|
121
|
+
if (!exists(candidate)) continue;
|
|
115
122
|
|
|
116
123
|
return {
|
|
117
124
|
path: candidate,
|
|
118
125
|
found: true,
|
|
119
|
-
prefix: {
|
|
120
|
-
|
|
126
|
+
prefix: {
|
|
127
|
+
from: joinPath(target, target.components.length - length),
|
|
128
|
+
to: root,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
121
131
|
}
|
|
122
132
|
}
|
|
123
133
|
|
|
124
|
-
return null
|
|
134
|
+
return null;
|
|
125
135
|
}
|
|
126
136
|
|
|
127
137
|
return (filePath: string): PathResolution => {
|
|
128
|
-
if (exists(filePath)) return { path: filePath, found: true }
|
|
138
|
+
if (exists(filePath)) return { path: filePath, found: true };
|
|
129
139
|
|
|
130
140
|
for (const prefix of [...explicit, ...learned]) {
|
|
131
|
-
const rewritten = applyPrefix(filePath, prefix)
|
|
132
|
-
if (rewritten && exists(rewritten))
|
|
141
|
+
const rewritten = applyPrefix(filePath, prefix);
|
|
142
|
+
if (rewritten && exists(rewritten))
|
|
143
|
+
return { path: rewritten, found: true, prefix };
|
|
133
144
|
}
|
|
134
145
|
|
|
135
|
-
const discovered = search(filePath)
|
|
136
|
-
if (!discovered) return { path: filePath, found: false }
|
|
146
|
+
const discovered = search(filePath);
|
|
147
|
+
if (!discovered) return { path: filePath, found: false };
|
|
137
148
|
|
|
138
|
-
if (discovered.prefix) learned.push(discovered.prefix)
|
|
139
|
-
return discovered
|
|
140
|
-
}
|
|
149
|
+
if (discovered.prefix) learned.push(discovered.prefix);
|
|
150
|
+
return discovered;
|
|
151
|
+
};
|
|
141
152
|
}
|
|
142
153
|
|
|
143
154
|
/** Swap one prefix for another, or null when the path is not under it. */
|
|
144
155
|
function applyPrefix(filePath: string, prefix: PathPrefix): string | null {
|
|
145
|
-
const from = splitPath(prefix.from)
|
|
146
|
-
const target = splitPath(filePath)
|
|
156
|
+
const from = splitPath(prefix.from);
|
|
157
|
+
const target = splitPath(filePath);
|
|
147
158
|
|
|
148
|
-
if (target.components.length < from.components.length) return null
|
|
159
|
+
if (target.components.length < from.components.length) return null;
|
|
149
160
|
for (let index = 0; index < from.components.length; index++) {
|
|
150
|
-
if (target.components[index] !== from.components[index]) return null
|
|
161
|
+
if (target.components[index] !== from.components[index]) return null;
|
|
151
162
|
}
|
|
152
163
|
|
|
153
|
-
const rest = target.components.slice(from.components.length)
|
|
154
|
-
const to = trimTrailingSeparators(prefix.to)
|
|
155
|
-
return rest.length === 0 ? to : `${to}/${rest.join(
|
|
164
|
+
const rest = target.components.slice(from.components.length);
|
|
165
|
+
const to = trimTrailingSeparators(prefix.to);
|
|
166
|
+
return rest.length === 0 ? to : `${to}/${rest.join("/")}`;
|
|
156
167
|
}
|
|
157
168
|
|
|
158
169
|
/**
|
|
@@ -163,15 +174,15 @@ function applyPrefix(filePath: string, prefix: PathPrefix): string | null {
|
|
|
163
174
|
* out of a report this package did not write.
|
|
164
175
|
*/
|
|
165
176
|
function trimTrailingSeparators(value: string): string {
|
|
166
|
-
let end = value.length
|
|
167
|
-
while (end > 0 && (value[end - 1] ===
|
|
168
|
-
return value.slice(0, end)
|
|
177
|
+
let end = value.length;
|
|
178
|
+
while (end > 0 && (value[end - 1] === "/" || value[end - 1] === "\\")) end--;
|
|
179
|
+
return value.slice(0, end);
|
|
169
180
|
}
|
|
170
181
|
|
|
171
182
|
interface SplitPath {
|
|
172
183
|
/** `/`, `C:/`, or empty for a relative path. */
|
|
173
|
-
root: string
|
|
174
|
-
components: string[]
|
|
184
|
+
root: string;
|
|
185
|
+
components: string[];
|
|
175
186
|
}
|
|
176
187
|
|
|
177
188
|
/**
|
|
@@ -181,28 +192,31 @@ interface SplitPath {
|
|
|
181
192
|
* recorded paths come from a report rather than from this platform.
|
|
182
193
|
*/
|
|
183
194
|
function splitPath(value: string): SplitPath {
|
|
184
|
-
const match = /^([A-Za-z]:[\\/]|[\\/])?/.exec(value)
|
|
185
|
-
const prefix = match?.[1] ??
|
|
195
|
+
const match = /^([A-Za-z]:[\\/]|[\\/])?/.exec(value);
|
|
196
|
+
const prefix = match?.[1] ?? "";
|
|
186
197
|
return {
|
|
187
|
-
root: prefix.replace(/\\/g,
|
|
188
|
-
components: value
|
|
189
|
-
|
|
198
|
+
root: prefix.replace(/\\/g, "/"),
|
|
199
|
+
components: value
|
|
200
|
+
.slice(prefix.length)
|
|
201
|
+
.split(/[\\/]+/)
|
|
202
|
+
.filter(Boolean),
|
|
203
|
+
};
|
|
190
204
|
}
|
|
191
205
|
|
|
192
206
|
/** Rebuild a path from its first `count` components. */
|
|
193
207
|
function joinPath(split: SplitPath, count: number): string {
|
|
194
|
-
return split.root + split.components.slice(0, count).join(
|
|
208
|
+
return split.root + split.components.slice(0, count).join("/");
|
|
195
209
|
}
|
|
196
210
|
|
|
197
211
|
/** A directory and every directory above it. */
|
|
198
212
|
function ancestors(directory: string): string[] {
|
|
199
|
-
const out: string[] = []
|
|
200
|
-
let current = directory
|
|
213
|
+
const out: string[] = [];
|
|
214
|
+
let current = directory;
|
|
201
215
|
|
|
202
216
|
for (;;) {
|
|
203
|
-
out.push(current)
|
|
204
|
-
const parent = path.dirname(current)
|
|
205
|
-
if (parent === current) return out
|
|
206
|
-
current = parent
|
|
217
|
+
out.push(current);
|
|
218
|
+
const parent = path.dirname(current);
|
|
219
|
+
if (parent === current) return out;
|
|
220
|
+
current = parent;
|
|
207
221
|
}
|
|
208
222
|
}
|
package/src/hover.test.ts
CHANGED
|
@@ -1,76 +1,80 @@
|
|
|
1
|
-
import {afterEach, describe, expect, it, vi} from
|
|
2
|
-
import {clearHover} from
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { clearHover } from "./hover";
|
|
3
3
|
|
|
4
4
|
function fakePage() {
|
|
5
|
-
let shield: any
|
|
5
|
+
let shield: any;
|
|
6
6
|
const document = {
|
|
7
7
|
querySelector: vi.fn(() => shield),
|
|
8
8
|
createElement: vi.fn(() => {
|
|
9
9
|
shield = {
|
|
10
10
|
setAttribute: vi.fn(),
|
|
11
|
-
style: {cssText:
|
|
11
|
+
style: { cssText: "" },
|
|
12
12
|
remove: vi.fn(() => {
|
|
13
|
-
shield = undefined
|
|
13
|
+
shield = undefined;
|
|
14
14
|
}),
|
|
15
|
-
}
|
|
16
|
-
return shield
|
|
15
|
+
};
|
|
16
|
+
return shield;
|
|
17
17
|
}),
|
|
18
18
|
documentElement: {
|
|
19
19
|
appendChild: vi.fn(),
|
|
20
20
|
},
|
|
21
|
-
}
|
|
21
|
+
};
|
|
22
22
|
const page = {
|
|
23
|
-
evaluate: vi.fn(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
evaluate: vi.fn(
|
|
24
|
+
async (callback: (attribute: string) => void, attribute: string) => {
|
|
25
|
+
vi.stubGlobal("document", document);
|
|
26
|
+
callback(attribute);
|
|
27
|
+
},
|
|
28
|
+
),
|
|
27
29
|
mouse: {
|
|
28
30
|
move: vi.fn().mockResolvedValue(undefined),
|
|
29
31
|
},
|
|
30
|
-
}
|
|
32
|
+
};
|
|
31
33
|
|
|
32
|
-
return {document, page, getShield: () => shield}
|
|
34
|
+
return { document, page, getShield: () => shield };
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
describe(
|
|
37
|
+
describe("clearHover", () => {
|
|
36
38
|
afterEach(() => {
|
|
37
|
-
vi.unstubAllGlobals()
|
|
38
|
-
})
|
|
39
|
+
vi.unstubAllGlobals();
|
|
40
|
+
});
|
|
39
41
|
|
|
40
|
-
it(
|
|
41
|
-
const {document, page, getShield} = fakePage()
|
|
42
|
+
it("covers page content and moves the pointer onto the shield", async () => {
|
|
43
|
+
const { document, page, getShield } = fakePage();
|
|
42
44
|
|
|
43
|
-
const cleanup = await clearHover(page as any)
|
|
44
|
-
const shield = getShield()
|
|
45
|
+
const cleanup = await clearHover(page as any);
|
|
46
|
+
const shield = getShield();
|
|
45
47
|
|
|
46
|
-
expect(document.createElement).toHaveBeenCalledWith(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
expect(shield.
|
|
50
|
-
expect(shield.style.cssText).toContain(
|
|
51
|
-
expect(
|
|
52
|
-
expect(
|
|
48
|
+
expect(document.createElement).toHaveBeenCalledWith(
|
|
49
|
+
"playwright-testing-hover-shield",
|
|
50
|
+
);
|
|
51
|
+
expect(shield.setAttribute).toHaveBeenCalledWith("aria-hidden", "true");
|
|
52
|
+
expect(shield.style.cssText).toContain("position: fixed !important");
|
|
53
|
+
expect(shield.style.cssText).toContain("pointer-events: auto !important");
|
|
54
|
+
expect(shield.style.cssText).toContain("opacity: 0 !important");
|
|
55
|
+
expect(document.documentElement.appendChild).toHaveBeenCalledWith(shield);
|
|
56
|
+
expect(page.mouse.move).toHaveBeenCalledWith(0, 0);
|
|
53
57
|
|
|
54
|
-
await cleanup()
|
|
55
|
-
expect(shield.remove).toHaveBeenCalledOnce()
|
|
56
|
-
})
|
|
58
|
+
await cleanup();
|
|
59
|
+
expect(shield.remove).toHaveBeenCalledOnce();
|
|
60
|
+
});
|
|
57
61
|
|
|
58
|
-
it(
|
|
59
|
-
const {page, getShield} = fakePage()
|
|
60
|
-
const cleanup = await clearHover(page as any)
|
|
61
|
-
const shield = getShield()
|
|
62
|
+
it("makes cleanup idempotent", async () => {
|
|
63
|
+
const { page, getShield } = fakePage();
|
|
64
|
+
const cleanup = await clearHover(page as any);
|
|
65
|
+
const shield = getShield();
|
|
62
66
|
|
|
63
|
-
await cleanup()
|
|
64
|
-
await cleanup()
|
|
67
|
+
await cleanup();
|
|
68
|
+
await cleanup();
|
|
65
69
|
|
|
66
|
-
expect(shield.remove).toHaveBeenCalledOnce()
|
|
67
|
-
})
|
|
70
|
+
expect(shield.remove).toHaveBeenCalledOnce();
|
|
71
|
+
});
|
|
68
72
|
|
|
69
|
-
it(
|
|
70
|
-
const {page, getShield} = fakePage()
|
|
71
|
-
page.mouse.move.mockRejectedValueOnce(new Error(
|
|
73
|
+
it("removes the shield when moving the pointer fails", async () => {
|
|
74
|
+
const { page, getShield } = fakePage();
|
|
75
|
+
page.mouse.move.mockRejectedValueOnce(new Error("mouse failed"));
|
|
72
76
|
|
|
73
|
-
await expect(clearHover(page as any)).rejects.toThrow(
|
|
74
|
-
expect(getShield()).toBeUndefined()
|
|
75
|
-
})
|
|
76
|
-
})
|
|
77
|
+
await expect(clearHover(page as any)).rejects.toThrow("mouse failed");
|
|
78
|
+
expect(getShield()).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
});
|
package/src/hover.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {Page} from "@playwright/test";
|
|
1
|
+
import { Page } from "@playwright/test";
|
|
2
2
|
|
|
3
|
-
const HOVER_SHIELD_ATTRIBUTE =
|
|
3
|
+
const HOVER_SHIELD_ATTRIBUTE = "data-playwright-testing-hover-shield";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Move the pointer away from page content for a deterministic screenshot.
|
|
@@ -17,23 +17,23 @@ export async function clearHover(page: Page): Promise<() => Promise<void>> {
|
|
|
17
17
|
await page.evaluate((attribute) => {
|
|
18
18
|
document.querySelector(`[${attribute}]`)?.remove();
|
|
19
19
|
|
|
20
|
-
const shield = document.createElement(
|
|
21
|
-
shield.setAttribute(attribute,
|
|
22
|
-
shield.setAttribute(
|
|
20
|
+
const shield = document.createElement("playwright-testing-hover-shield");
|
|
21
|
+
shield.setAttribute(attribute, "");
|
|
22
|
+
shield.setAttribute("aria-hidden", "true");
|
|
23
23
|
shield.style.cssText = [
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
].join(
|
|
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
37
|
document.documentElement.appendChild(shield);
|
|
38
38
|
}, HOVER_SHIELD_ATTRIBUTE);
|
|
39
39
|
|