@cronn/element-snapshot 0.8.0 → 0.10.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 +114 -0
- package/dist/{types.d.ts → browser-lib.d.ts} +18 -8
- package/dist/{index.global.js → browser-lib.global.js} +46 -28
- package/dist/index.d.ts +48 -15
- package/dist/index.js +169 -0
- package/package.json +17 -12
- package/dist/types.js +0 -0
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Element Snapshots
|
|
2
|
+
|
|
3
|
+
Element Snapshots are a custom snapshot implementation inspired by [Playwright's ARIA Snapshots](https://playwright.dev/docs/aria-snapshots#aria-snapshots) and the [Accessibility Object Model](https://wicg.github.io/aom/explainer.html). They cover additional properties, e.g. validation attributes like `required` or `invalid` on form inputs, and are optimized to be serialized as JSON.
|
|
4
|
+
|
|
5
|
+
Element Snapshots are designed to be used in combination with [playwright-file-snapshots](../playwright-file-snapshots/README.md) for writing Playwright tests.
|
|
6
|
+
|
|
7
|
+
> [!NOTE]
|
|
8
|
+
> Element Snapshots are currently experimental. Not all HTML elements, ARIA roles and attributes are covered. Breaking changes to the snapshot format are possible.
|
|
9
|
+
|
|
10
|
+
## Getting Started
|
|
11
|
+
|
|
12
|
+
### Adding the library to your project
|
|
13
|
+
|
|
14
|
+
```shell
|
|
15
|
+
npm install -D @cronn/element-snapshot
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```shell
|
|
19
|
+
yarn add -D @cronn/element-snapshot
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```shell
|
|
23
|
+
pnpm add -D @cronn/element-snapshot
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Writing Tests
|
|
27
|
+
|
|
28
|
+
### General-Purpose Snapshots
|
|
29
|
+
|
|
30
|
+
The function `snapshotElement` provides a general-purpose snapshot including all supported roles and attributes. It can be used to achieve a high test coverage, but can become hard to read for complex HTML structures.
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { snapshotElement } from "@cronn/element-snapshot";
|
|
34
|
+
import { defineValidationFileExpect } from "@cronn/playwright-file-snapshots";
|
|
35
|
+
|
|
36
|
+
const expect = defineValidationFileExpect();
|
|
37
|
+
|
|
38
|
+
test("matches element snapshot", async ({ page }) => {
|
|
39
|
+
await expect(snapshotElement(page.getByRole("main"))).toMatchJsonFile();
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Element Snapshot Example:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"main": [
|
|
48
|
+
{
|
|
49
|
+
"heading": {
|
|
50
|
+
"name": "List",
|
|
51
|
+
"level": 1
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"list": [
|
|
56
|
+
{
|
|
57
|
+
"listitem": "Apple"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"listitem": "Peach"
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
To improve the specificity of certain tests, `snapshotElement` can be called on certain areas of the page only:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
test("matches navigation snapshot", async ({ page }) => {
|
|
72
|
+
await expect(snapshotElement(page.getByRole("navigation"))).toMatchJsonFile({
|
|
73
|
+
name: "navigation",
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
#### Snapshot Options
|
|
79
|
+
|
|
80
|
+
Snapshot options can be passed when calling the snapshot function:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
await expect(
|
|
84
|
+
snapshotElement(page.getByLabel("My select"), {
|
|
85
|
+
filter: (element) => element.role === "heading",
|
|
86
|
+
}),
|
|
87
|
+
).toMatchJsonFile({
|
|
88
|
+
name: "headings",
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
| Option | Default Value | Description |
|
|
93
|
+
| ------------------------ | ------------- | ------------------------------------------------------------------------------------ |
|
|
94
|
+
| `filter` | `() => true` | Include only elements in the snapshot for which the specified filter returns `true`. |
|
|
95
|
+
| `includeComboboxOptions` | `false` | Include combobox options in the snapshot. |
|
|
96
|
+
|
|
97
|
+
### Custom Snapshots
|
|
98
|
+
|
|
99
|
+
The function `snapshotElementRaw` provides the raw element snapshot in a strictly typed structure. This format is not well suited to be read by humans, but can be utilized to derive custom snapshot formats, e.g. for HTML tables.
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { snapshotElementRaw } from "@cronn/element-snapshot";
|
|
103
|
+
|
|
104
|
+
test("matches custom snapshot", async ({ page }) => {
|
|
105
|
+
const tableSnapshot = await snapshotElementRaw(page.getByRole("table"));
|
|
106
|
+
const markdownTable = transformToMarkdownTable(tableSnapshot);
|
|
107
|
+
|
|
108
|
+
await expect(markdownTable).toMatchTextFile({ fileExtension: "md" });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
function transformToMarkdownTable(snapshot: Array<NodeSnapshot>): string {
|
|
112
|
+
// transform table snapshot to markdown table
|
|
113
|
+
}
|
|
114
|
+
```
|
|
@@ -46,11 +46,6 @@ interface OptionAttributes {
|
|
|
46
46
|
selected?: boolean;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
declare const CONTAINER_ROLES: Set<"paragraph" | "list" | "listitem" | "listbox" | "article" | "banner" | "complementary" | "contentinfo" | "form" | "main" | "navigation" | "region" | "search" | "term" | "definition" | "table" | "grid" | "rowgroup" | "row" | "rowheader" | "cell" | "gridcell" | "alert" | "menu" | "tablist" | "tabpanel">;
|
|
50
|
-
interface ContainerSnapshot extends GenericElementSnapshot<ContainerRole> {
|
|
51
|
-
}
|
|
52
|
-
type ContainerRole = SetValues<typeof CONTAINER_ROLES>;
|
|
53
|
-
|
|
54
49
|
interface DialogSnapshot extends GenericElementSnapshot<DialogRole, DialogAttributes> {
|
|
55
50
|
}
|
|
56
51
|
type DialogRole = "dialog" | "alertdialog";
|
|
@@ -71,6 +66,9 @@ interface HeadingAttributes {
|
|
|
71
66
|
level?: number;
|
|
72
67
|
}
|
|
73
68
|
|
|
69
|
+
interface ImageSnapshot extends GenericElementSnapshot<"img"> {
|
|
70
|
+
}
|
|
71
|
+
|
|
74
72
|
interface LinkSnapshot extends GenericElementSnapshot<"link", LinkAttributes> {
|
|
75
73
|
}
|
|
76
74
|
interface LinkAttributes {
|
|
@@ -106,10 +104,11 @@ interface TextSnapshot {
|
|
|
106
104
|
name: string;
|
|
107
105
|
}
|
|
108
106
|
|
|
107
|
+
type SnapshotTargetElement = HTMLElement | SVGElement;
|
|
109
108
|
type NodeRole = ElementRole | "text";
|
|
110
|
-
type ElementRole = "heading" | "link" | "button" | "option" | "tab" | "menuitem" | "columnheader" | "group" | "radiogroup" | "progressbar" | ContainerRole | InputRole | DialogRole;
|
|
109
|
+
type ElementRole = "heading" | "link" | "button" | "option" | "tab" | "menuitem" | "columnheader" | "group" | "radiogroup" | "progressbar" | "img" | ContainerRole | InputRole | DialogRole;
|
|
111
110
|
type NodeSnapshot = ElementSnapshot | TextSnapshot;
|
|
112
|
-
type ElementSnapshot = ContainerSnapshot | HeadingSnapshot | LinkSnapshot | ButtonSnapshot | InputSnapshot | ComboboxSnapshot | OptionSnapshot | DialogSnapshot | TabSnapshot | MenuitemSnapshot | ColumnheaderSnapshot | GroupSnapshot | RadiogroupSnapshot | ProgressbarSnapshot;
|
|
111
|
+
type ElementSnapshot = ContainerSnapshot | HeadingSnapshot | LinkSnapshot | ButtonSnapshot | InputSnapshot | ComboboxSnapshot | OptionSnapshot | DialogSnapshot | TabSnapshot | MenuitemSnapshot | ColumnheaderSnapshot | GroupSnapshot | RadiogroupSnapshot | ProgressbarSnapshot | ImageSnapshot;
|
|
113
112
|
interface GenericElementSnapshot<TRole extends NodeRole = NodeRole, TAttributes = unknown> {
|
|
114
113
|
role: TRole;
|
|
115
114
|
name?: string;
|
|
@@ -118,4 +117,15 @@ interface GenericElementSnapshot<TRole extends NodeRole = NodeRole, TAttributes
|
|
|
118
117
|
}
|
|
119
118
|
type SetValues<TSet> = TSet extends Set<infer TValue> ? TValue : never;
|
|
120
119
|
|
|
121
|
-
|
|
120
|
+
declare const CONTAINER_ROLES: Set<"paragraph" | "list" | "listitem" | "listbox" | "article" | "banner" | "complementary" | "contentinfo" | "form" | "main" | "navigation" | "region" | "search" | "term" | "definition" | "table" | "grid" | "rowgroup" | "row" | "rowheader" | "cell" | "gridcell" | "alert" | "menu" | "tablist" | "tabpanel" | "separator">;
|
|
121
|
+
interface ContainerSnapshot extends GenericElementSnapshot<ContainerRole> {
|
|
122
|
+
}
|
|
123
|
+
type ContainerRole = SetValues<typeof CONTAINER_ROLES>;
|
|
124
|
+
|
|
125
|
+
declare function snapshotElement(element: SnapshotTargetElement): Array<NodeSnapshot>;
|
|
126
|
+
|
|
127
|
+
declare global {
|
|
128
|
+
interface Window {
|
|
129
|
+
snapshotElement: typeof snapshotElement;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
(() => {
|
|
3
|
-
// src/
|
|
3
|
+
// src/browser/attribute.ts
|
|
4
4
|
function stringAttribute(value) {
|
|
5
5
|
return value !== null && value !== "" ? value : void 0;
|
|
6
6
|
}
|
|
@@ -31,13 +31,13 @@
|
|
|
31
31
|
return element.ownerDocument.getElementById(referenceId);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
// src/
|
|
34
|
+
// src/browser/children.ts
|
|
35
35
|
function snapshotChildren(element) {
|
|
36
36
|
const children = Array.from(element.childNodes).map(snapshotNodeRecursive).filter((childSnapshot) => childSnapshot !== null).flat();
|
|
37
37
|
return children.length > 0 ? children : void 0;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
// src/
|
|
40
|
+
// src/browser/text.ts
|
|
41
41
|
function snapshotTextNode(textNode) {
|
|
42
42
|
const textContent = snapshotTextContent(textNode);
|
|
43
43
|
if (textContent === void 0) {
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
return normalizedText !== "" ? normalizedText : void 0;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
// src/
|
|
59
|
+
// src/browser/name.ts
|
|
60
60
|
function resolveAccessibleName(element, includeTextContent = true) {
|
|
61
61
|
const labelledByElement = resolveElementReference(element, "aria-labelledby");
|
|
62
62
|
if (labelledByElement !== null) {
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
return void 0;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
// src/
|
|
74
|
+
// src/browser/state.ts
|
|
75
75
|
function disableableAttributes(element) {
|
|
76
76
|
const supportsHtmlAttribute = isNativeInputElement(element) || element instanceof HTMLButtonElement;
|
|
77
77
|
const disabled = booleanAttribute(
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
return element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
// src/
|
|
106
|
+
// src/browser/button.ts
|
|
107
107
|
var PRESSED_VALUES = /* @__PURE__ */ new Set(["true", "mixed", "false"]);
|
|
108
108
|
function snapshotButton(element) {
|
|
109
109
|
return {
|
|
@@ -125,7 +125,7 @@
|
|
|
125
125
|
return booleanAttribute(pressed === "true");
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
// src/
|
|
128
|
+
// src/browser/description.ts
|
|
129
129
|
function discribableAttributes(element) {
|
|
130
130
|
return {
|
|
131
131
|
description: resolveDescription(element)
|
|
@@ -145,7 +145,7 @@
|
|
|
145
145
|
return void 0;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
// src/
|
|
148
|
+
// src/browser/container.ts
|
|
149
149
|
var CONTAINER_ROLES = /* @__PURE__ */ new Set([
|
|
150
150
|
"paragraph",
|
|
151
151
|
"list",
|
|
@@ -172,7 +172,8 @@
|
|
|
172
172
|
"alert",
|
|
173
173
|
"menu",
|
|
174
174
|
"tablist",
|
|
175
|
-
"tabpanel"
|
|
175
|
+
"tabpanel",
|
|
176
|
+
"separator"
|
|
176
177
|
]);
|
|
177
178
|
function snapshotContainer(role, element) {
|
|
178
179
|
const children = snapshotChildren(element);
|
|
@@ -186,7 +187,7 @@
|
|
|
186
187
|
return CONTAINER_ROLES.has(role);
|
|
187
188
|
}
|
|
188
189
|
|
|
189
|
-
// src/
|
|
190
|
+
// src/browser/selector.ts
|
|
190
191
|
function selectorList(...selectors) {
|
|
191
192
|
return selectors.join(",");
|
|
192
193
|
}
|
|
@@ -197,7 +198,7 @@
|
|
|
197
198
|
return `[${name}='${value}']`;
|
|
198
199
|
}
|
|
199
200
|
|
|
200
|
-
// src/
|
|
201
|
+
// src/browser/utils.ts
|
|
201
202
|
function getElementTagName(element) {
|
|
202
203
|
return element.tagName.toLowerCase();
|
|
203
204
|
}
|
|
@@ -205,7 +206,7 @@
|
|
|
205
206
|
return element.closest(containerSelector) !== null;
|
|
206
207
|
}
|
|
207
208
|
|
|
208
|
-
// src/
|
|
209
|
+
// src/browser/role.ts
|
|
209
210
|
var ELEMENT_ROLES = {
|
|
210
211
|
main: "main",
|
|
211
212
|
nav: "navigation",
|
|
@@ -242,7 +243,10 @@
|
|
|
242
243
|
th: resolveTableHeaderCellRole,
|
|
243
244
|
td: "cell",
|
|
244
245
|
fieldset: "group",
|
|
245
|
-
progress: "progressbar"
|
|
246
|
+
progress: "progressbar",
|
|
247
|
+
hr: "separator",
|
|
248
|
+
img: "img",
|
|
249
|
+
svg: "img"
|
|
246
250
|
};
|
|
247
251
|
var CONTEXT_DEPENDENT_ROLES = {
|
|
248
252
|
banner: isTopLevelBanner,
|
|
@@ -394,7 +398,7 @@
|
|
|
394
398
|
return isWithinElement(element, roleSelector("tablist"));
|
|
395
399
|
}
|
|
396
400
|
|
|
397
|
-
// src/
|
|
401
|
+
// src/browser/input.ts
|
|
398
402
|
function snapshotInput(element) {
|
|
399
403
|
if (element instanceof HTMLInputElement) {
|
|
400
404
|
return snapshotInputElement(element);
|
|
@@ -477,7 +481,7 @@
|
|
|
477
481
|
return stringAttribute(element.placeholder);
|
|
478
482
|
}
|
|
479
483
|
|
|
480
|
-
// src/
|
|
484
|
+
// src/browser/combobox.ts
|
|
481
485
|
function snapshotCombobox(element) {
|
|
482
486
|
if (!isCombobox(element)) {
|
|
483
487
|
return null;
|
|
@@ -546,7 +550,7 @@
|
|
|
546
550
|
};
|
|
547
551
|
}
|
|
548
552
|
|
|
549
|
-
// src/
|
|
553
|
+
// src/browser/dialog.ts
|
|
550
554
|
function snapshotDialogWithRole(role) {
|
|
551
555
|
return (element) => snapshotDialog(role, element);
|
|
552
556
|
}
|
|
@@ -562,7 +566,7 @@
|
|
|
562
566
|
};
|
|
563
567
|
}
|
|
564
568
|
|
|
565
|
-
// src/
|
|
569
|
+
// src/browser/group.ts
|
|
566
570
|
function snapshotGroup(element) {
|
|
567
571
|
return {
|
|
568
572
|
role: "group",
|
|
@@ -586,7 +590,7 @@
|
|
|
586
590
|
};
|
|
587
591
|
}
|
|
588
592
|
|
|
589
|
-
// src/
|
|
593
|
+
// src/browser/heading.ts
|
|
590
594
|
var LEVEL_REGEXP = /^H([1-6])$/;
|
|
591
595
|
function snapshotHeading(element) {
|
|
592
596
|
if (!(element instanceof HTMLHeadingElement)) {
|
|
@@ -606,7 +610,21 @@
|
|
|
606
610
|
return numericAttribute(levelAsString);
|
|
607
611
|
}
|
|
608
612
|
|
|
609
|
-
// src/
|
|
613
|
+
// src/browser/image.ts
|
|
614
|
+
function snapshotImage(element) {
|
|
615
|
+
return {
|
|
616
|
+
role: "img",
|
|
617
|
+
name: resolveImageName(element)
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
function resolveImageName(element) {
|
|
621
|
+
if (element instanceof HTMLImageElement) {
|
|
622
|
+
return stringAttribute(element.alt);
|
|
623
|
+
}
|
|
624
|
+
return resolveAccessibleName(element, true);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// src/browser/link.ts
|
|
610
628
|
function snapshotLink(element) {
|
|
611
629
|
return {
|
|
612
630
|
role: "link",
|
|
@@ -619,7 +637,7 @@
|
|
|
619
637
|
return { url: stringAttribute(element.getAttribute("href")) };
|
|
620
638
|
}
|
|
621
639
|
|
|
622
|
-
// src/
|
|
640
|
+
// src/browser/list.ts
|
|
623
641
|
function snapshotMenuitem(element) {
|
|
624
642
|
return {
|
|
625
643
|
role: "menuitem",
|
|
@@ -632,7 +650,7 @@
|
|
|
632
650
|
};
|
|
633
651
|
}
|
|
634
652
|
|
|
635
|
-
// src/
|
|
653
|
+
// src/browser/progressbar.ts
|
|
636
654
|
function snapshotProgressbar(element) {
|
|
637
655
|
return {
|
|
638
656
|
role: "progressbar",
|
|
@@ -650,7 +668,7 @@
|
|
|
650
668
|
return numericAttribute(element.ariaValueNow);
|
|
651
669
|
}
|
|
652
670
|
|
|
653
|
-
// src/
|
|
671
|
+
// src/browser/tab.ts
|
|
654
672
|
function snapshotTab(element) {
|
|
655
673
|
return {
|
|
656
674
|
role: "tab",
|
|
@@ -663,7 +681,7 @@
|
|
|
663
681
|
};
|
|
664
682
|
}
|
|
665
683
|
|
|
666
|
-
// src/
|
|
684
|
+
// src/browser/table.ts
|
|
667
685
|
var SORT_ATTRIBUTES = /* @__PURE__ */ new Set(["ascending", "descending", "other"]);
|
|
668
686
|
function snapshotColumnheader(element) {
|
|
669
687
|
return {
|
|
@@ -676,7 +694,7 @@
|
|
|
676
694
|
};
|
|
677
695
|
}
|
|
678
696
|
|
|
679
|
-
// src/
|
|
697
|
+
// src/browser/element.ts
|
|
680
698
|
var ROLE_SNAPSHOTS = {
|
|
681
699
|
button: snapshotButton,
|
|
682
700
|
checkbox: snapshotInput,
|
|
@@ -696,7 +714,8 @@
|
|
|
696
714
|
columnheader: snapshotColumnheader,
|
|
697
715
|
group: snapshotGroup,
|
|
698
716
|
radiogroup: snapshotRadiogroup,
|
|
699
|
-
progressbar: snapshotProgressbar
|
|
717
|
+
progressbar: snapshotProgressbar,
|
|
718
|
+
img: snapshotImage
|
|
700
719
|
};
|
|
701
720
|
function snapshotElement(element) {
|
|
702
721
|
return snapshotNodeRecursive(element);
|
|
@@ -732,7 +751,6 @@
|
|
|
732
751
|
"style",
|
|
733
752
|
"script",
|
|
734
753
|
"noscript",
|
|
735
|
-
"img",
|
|
736
754
|
"picture",
|
|
737
755
|
"audio",
|
|
738
756
|
"video",
|
|
@@ -740,7 +758,7 @@
|
|
|
740
758
|
"template"
|
|
741
759
|
]);
|
|
742
760
|
function isSupportedElement(node) {
|
|
743
|
-
if (!(node instanceof HTMLElement)) {
|
|
761
|
+
if (!(node instanceof HTMLElement || node instanceof SVGElement)) {
|
|
744
762
|
return false;
|
|
745
763
|
}
|
|
746
764
|
const tagName = getElementTagName(node);
|
|
@@ -761,6 +779,6 @@
|
|
|
761
779
|
return cssStyles.display === "none" || cssStyles.visibility === "hidden" || cssStyles.visibility === "collapse";
|
|
762
780
|
}
|
|
763
781
|
|
|
764
|
-
// src/
|
|
782
|
+
// src/browser-lib.ts
|
|
765
783
|
window.snapshotElement = snapshotElement;
|
|
766
784
|
})();
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { Locator } from '@playwright/test';
|
|
2
|
+
|
|
1
3
|
interface DisableableAttributes {
|
|
2
4
|
disabled?: boolean;
|
|
3
5
|
}
|
|
@@ -46,6 +48,11 @@ interface OptionAttributes {
|
|
|
46
48
|
selected?: boolean;
|
|
47
49
|
}
|
|
48
50
|
|
|
51
|
+
declare const CONTAINER_ROLES: Set<"paragraph" | "list" | "listitem" | "listbox" | "article" | "banner" | "complementary" | "contentinfo" | "form" | "main" | "navigation" | "region" | "search" | "term" | "definition" | "table" | "grid" | "rowgroup" | "row" | "rowheader" | "cell" | "gridcell" | "alert" | "menu" | "tablist" | "tabpanel" | "separator">;
|
|
52
|
+
interface ContainerSnapshot extends GenericElementSnapshot<ContainerRole> {
|
|
53
|
+
}
|
|
54
|
+
type ContainerRole = SetValues<typeof CONTAINER_ROLES>;
|
|
55
|
+
|
|
49
56
|
interface DialogSnapshot extends GenericElementSnapshot<DialogRole, DialogAttributes> {
|
|
50
57
|
}
|
|
51
58
|
type DialogRole = "dialog" | "alertdialog";
|
|
@@ -66,6 +73,9 @@ interface HeadingAttributes {
|
|
|
66
73
|
level?: number;
|
|
67
74
|
}
|
|
68
75
|
|
|
76
|
+
interface ImageSnapshot extends GenericElementSnapshot<"img"> {
|
|
77
|
+
}
|
|
78
|
+
|
|
69
79
|
interface LinkSnapshot extends GenericElementSnapshot<"link", LinkAttributes> {
|
|
70
80
|
}
|
|
71
81
|
interface LinkAttributes {
|
|
@@ -101,11 +111,10 @@ interface TextSnapshot {
|
|
|
101
111
|
name: string;
|
|
102
112
|
}
|
|
103
113
|
|
|
104
|
-
type SnapshotTargetElement = HTMLElement | SVGElement;
|
|
105
114
|
type NodeRole = ElementRole | "text";
|
|
106
|
-
type ElementRole = "heading" | "link" | "button" | "option" | "tab" | "menuitem" | "columnheader" | "group" | "radiogroup" | "progressbar" | ContainerRole | InputRole | DialogRole;
|
|
115
|
+
type ElementRole = "heading" | "link" | "button" | "option" | "tab" | "menuitem" | "columnheader" | "group" | "radiogroup" | "progressbar" | "img" | ContainerRole | InputRole | DialogRole;
|
|
107
116
|
type NodeSnapshot = ElementSnapshot | TextSnapshot;
|
|
108
|
-
type ElementSnapshot = ContainerSnapshot | HeadingSnapshot | LinkSnapshot | ButtonSnapshot | InputSnapshot | ComboboxSnapshot | OptionSnapshot | DialogSnapshot | TabSnapshot | MenuitemSnapshot | ColumnheaderSnapshot | GroupSnapshot | RadiogroupSnapshot | ProgressbarSnapshot;
|
|
117
|
+
type ElementSnapshot = ContainerSnapshot | HeadingSnapshot | LinkSnapshot | ButtonSnapshot | InputSnapshot | ComboboxSnapshot | OptionSnapshot | DialogSnapshot | TabSnapshot | MenuitemSnapshot | ColumnheaderSnapshot | GroupSnapshot | RadiogroupSnapshot | ProgressbarSnapshot | ImageSnapshot;
|
|
109
118
|
interface GenericElementSnapshot<TRole extends NodeRole = NodeRole, TAttributes = unknown> {
|
|
110
119
|
role: TRole;
|
|
111
120
|
name?: string;
|
|
@@ -114,15 +123,39 @@ interface GenericElementSnapshot<TRole extends NodeRole = NodeRole, TAttributes
|
|
|
114
123
|
}
|
|
115
124
|
type SetValues<TSet> = TSet extends Set<infer TValue> ? TValue : never;
|
|
116
125
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
126
|
+
type SnapshotFilter = (node: NodeSnapshot) => boolean;
|
|
127
|
+
|
|
128
|
+
interface ElementSnapshotOptions {
|
|
129
|
+
/**
|
|
130
|
+
* Include only elements in the snapshot for which the specified filter returns `true`
|
|
131
|
+
*
|
|
132
|
+
* @default () => true
|
|
133
|
+
*/
|
|
134
|
+
filter?: SnapshotFilter;
|
|
135
|
+
/**
|
|
136
|
+
* Include combobox options in the snapshot
|
|
137
|
+
*
|
|
138
|
+
* @default false
|
|
139
|
+
*/
|
|
140
|
+
includeComboboxOptions?: boolean;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Creates an Element Snapshot of the specified element
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* ```ts
|
|
147
|
+
* await expect(snapshotElement(page.getByRole("main"))).toMatchJsonSnapshot();
|
|
148
|
+
* ```
|
|
149
|
+
* @param locator Locator for the element to snapshot
|
|
150
|
+
* @experimental
|
|
151
|
+
*/
|
|
152
|
+
declare function snapshotElement(locator: Locator, options?: ElementSnapshotOptions): Promise<unknown>;
|
|
153
|
+
/**
|
|
154
|
+
* Creates an Element Snapshot of the specified element, returning the raw snapshot data for custom transformations
|
|
155
|
+
*
|
|
156
|
+
* @param locator Locator for the element to snapshot
|
|
157
|
+
* @experimental
|
|
158
|
+
*/
|
|
159
|
+
declare function snapshotElementRaw(locator: Locator): Promise<Array<NodeSnapshot>>;
|
|
160
|
+
|
|
161
|
+
export { type ButtonSnapshot, type ColumnheaderSnapshot, type ComboboxSnapshot, type ContainerSnapshot, type DialogSnapshot, type ElementRole, type ElementSnapshot, type GroupSnapshot, type HeadingSnapshot, type InputSnapshot, type LinkSnapshot, type MenuitemSnapshot, type NodeRole, type NodeSnapshot, type OptionSnapshot, type ProgressbarSnapshot, type RadiogroupSnapshot, type TabSnapshot, type TextSnapshot, snapshotElement, snapshotElementRaw };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// src/playwright/proxy.ts
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { packageDirectory } from "package-directory";
|
|
4
|
+
var ElementSnapshotProxy = class {
|
|
5
|
+
page;
|
|
6
|
+
constructor(page) {
|
|
7
|
+
this.page = page;
|
|
8
|
+
}
|
|
9
|
+
async snapshotElement(locator) {
|
|
10
|
+
if (!await this.isLibraryLoaded()) {
|
|
11
|
+
await this.loadLibrary();
|
|
12
|
+
}
|
|
13
|
+
return await locator.evaluate((element) => {
|
|
14
|
+
return window.snapshotElement(element);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
async isLibraryLoaded() {
|
|
18
|
+
return await this.page.evaluate(
|
|
19
|
+
() => typeof window.snapshotElement === "function"
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
async loadLibrary() {
|
|
23
|
+
const packageRootDir = await this.resolvePackageRootDir();
|
|
24
|
+
const libPath = path.resolve(
|
|
25
|
+
packageRootDir,
|
|
26
|
+
"dist",
|
|
27
|
+
"browser-lib.global.js"
|
|
28
|
+
);
|
|
29
|
+
await this.page.addScriptTag({
|
|
30
|
+
path: libPath
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
async resolvePackageRootDir() {
|
|
34
|
+
const packageDir = await packageDirectory({
|
|
35
|
+
cwd: import.meta.dirname
|
|
36
|
+
});
|
|
37
|
+
if (packageDir === void 0) {
|
|
38
|
+
throw new Error("Unable to resolve root directory of package");
|
|
39
|
+
}
|
|
40
|
+
return packageDir;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// src/playwright/transformer.ts
|
|
45
|
+
var ElementSnapshotTransformer = class {
|
|
46
|
+
filter;
|
|
47
|
+
includeComboboxOptions;
|
|
48
|
+
constructor(options = {}) {
|
|
49
|
+
this.filter = options.filter ?? (() => true);
|
|
50
|
+
this.includeComboboxOptions = options.includeComboboxOptions ?? false;
|
|
51
|
+
}
|
|
52
|
+
transform(snapshots) {
|
|
53
|
+
const transformedSnapshots = this.filterAndTransformSnapshots(snapshots);
|
|
54
|
+
if (transformedSnapshots.length === 1) {
|
|
55
|
+
return transformedSnapshots.at(0);
|
|
56
|
+
}
|
|
57
|
+
return transformedSnapshots;
|
|
58
|
+
}
|
|
59
|
+
filterAndTransformSnapshots(snapshots) {
|
|
60
|
+
return snapshots.flatMap((snapshot) => {
|
|
61
|
+
if (this.filter(snapshot)) {
|
|
62
|
+
return this.transformSnapshotRecursive(snapshot);
|
|
63
|
+
}
|
|
64
|
+
if ("children" in snapshot) {
|
|
65
|
+
return this.filterAndTransformSnapshots(snapshot.children ?? []);
|
|
66
|
+
}
|
|
67
|
+
return [];
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
transformSnapshotRecursive(snapshot) {
|
|
71
|
+
if (snapshot.role === "text") {
|
|
72
|
+
return this.simplifyTextSnapshot(snapshot);
|
|
73
|
+
}
|
|
74
|
+
return this.simplifyElementSnapshot(snapshot);
|
|
75
|
+
}
|
|
76
|
+
simplifyTextSnapshot(snapshot) {
|
|
77
|
+
return snapshot.name;
|
|
78
|
+
}
|
|
79
|
+
simplifyElementSnapshot(snapshot) {
|
|
80
|
+
const normalizedSnapshot = this.normalizeElementSnapshot(snapshot);
|
|
81
|
+
if (this.isEmpty(normalizedSnapshot)) {
|
|
82
|
+
const { role: role2 } = normalizedSnapshot;
|
|
83
|
+
return this.transformedSnapshot(role2, "");
|
|
84
|
+
}
|
|
85
|
+
if (this.hasOnlyName(normalizedSnapshot)) {
|
|
86
|
+
const { role: role2, name: name2 } = normalizedSnapshot;
|
|
87
|
+
return this.transformedSnapshot(role2, name2);
|
|
88
|
+
}
|
|
89
|
+
if (this.hasOnlyChildren(normalizedSnapshot)) {
|
|
90
|
+
const { role: role2, children: children2 } = normalizedSnapshot;
|
|
91
|
+
return this.transformedSnapshot(
|
|
92
|
+
role2,
|
|
93
|
+
children2.length === 1 ? children2.at(0) : children2
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (snapshot.role === "combobox") {
|
|
97
|
+
return this.simplifyComboboxSnapshot(snapshot);
|
|
98
|
+
}
|
|
99
|
+
const { role, name, attributes, children } = normalizedSnapshot;
|
|
100
|
+
return this.transformedSnapshot(role, {
|
|
101
|
+
name,
|
|
102
|
+
...attributes,
|
|
103
|
+
children
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
normalizeElementSnapshot(snapshot) {
|
|
107
|
+
const sparseAttributes = this.removeUndefinedProperties(
|
|
108
|
+
snapshot.attributes ?? {}
|
|
109
|
+
);
|
|
110
|
+
const transformedChildren = this.filterAndTransformSnapshots(
|
|
111
|
+
snapshot?.children ?? []
|
|
112
|
+
);
|
|
113
|
+
const nameEqualsChildren = transformedChildren.length === 1 && snapshot.name === transformedChildren.at(0);
|
|
114
|
+
return {
|
|
115
|
+
role: snapshot.role,
|
|
116
|
+
name: snapshot.name,
|
|
117
|
+
attributes: sparseAttributes,
|
|
118
|
+
children: nameEqualsChildren ? void 0 : transformedChildren.length === 0 ? void 0 : transformedChildren
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
simplifyComboboxSnapshot(snapshot) {
|
|
122
|
+
const { role, name, attributes } = this.normalizeElementSnapshot(snapshot);
|
|
123
|
+
const options = snapshot.options ?? [];
|
|
124
|
+
const transformedOptions = options.map(
|
|
125
|
+
(optionSnapshot) => this.simplifyElementSnapshot(optionSnapshot)
|
|
126
|
+
);
|
|
127
|
+
return this.transformedSnapshot(role, {
|
|
128
|
+
name,
|
|
129
|
+
...attributes,
|
|
130
|
+
options: this.includeComboboxOptions && transformedOptions.length > 0 ? transformedOptions : void 0
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
isEmpty(snapshot) {
|
|
134
|
+
return snapshot.name === void 0 && (snapshot.attributes === void 0 || Object.keys(snapshot.attributes).length === 0) && (snapshot.children === void 0 || snapshot.children.length === 0);
|
|
135
|
+
}
|
|
136
|
+
hasOnlyName(snapshot) {
|
|
137
|
+
return snapshot.name !== void 0 && snapshot.attributes === void 0 && snapshot.children === void 0;
|
|
138
|
+
}
|
|
139
|
+
hasOnlyChildren(snapshot) {
|
|
140
|
+
return snapshot.name === void 0 && snapshot.attributes === void 0 && snapshot.children !== void 0;
|
|
141
|
+
}
|
|
142
|
+
removeUndefinedProperties(attributes) {
|
|
143
|
+
const sparseAttributes = {};
|
|
144
|
+
Object.entries(attributes).forEach(([key, value]) => {
|
|
145
|
+
if (value !== void 0) {
|
|
146
|
+
sparseAttributes[key] = value;
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
return Object.keys(sparseAttributes).length > 0 ? sparseAttributes : void 0;
|
|
150
|
+
}
|
|
151
|
+
transformedSnapshot(role, content) {
|
|
152
|
+
return { [role]: content };
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// src/playwright/snapshot.ts
|
|
157
|
+
async function snapshotElement(locator, options) {
|
|
158
|
+
const snapshot = await snapshotElementRaw(locator);
|
|
159
|
+
return new ElementSnapshotTransformer(options).transform(snapshot);
|
|
160
|
+
}
|
|
161
|
+
async function snapshotElementRaw(locator) {
|
|
162
|
+
return await new ElementSnapshotProxy(locator.page()).snapshotElement(
|
|
163
|
+
locator
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
export {
|
|
167
|
+
snapshotElement,
|
|
168
|
+
snapshotElementRaw
|
|
169
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cronn/element-snapshot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Element snapshots for Playwright",
|
|
5
5
|
"author": "cronn",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -16,40 +16,45 @@
|
|
|
16
16
|
"devEngines": {
|
|
17
17
|
"runtime": {
|
|
18
18
|
"name": "node",
|
|
19
|
-
"version": ">=24.
|
|
19
|
+
"version": ">=24.13 <25",
|
|
20
20
|
"onFail": "error"
|
|
21
21
|
},
|
|
22
22
|
"packageManager": {
|
|
23
23
|
"name": "pnpm",
|
|
24
|
-
"version": ">=10.
|
|
24
|
+
"version": ">=10.28.2 <11",
|
|
25
25
|
"onFail": "error"
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
28
|
"type": "module",
|
|
29
29
|
"exports": {
|
|
30
|
+
"./package.json": "./package.json",
|
|
30
31
|
".": {
|
|
31
32
|
"types": "./dist/index.d.ts",
|
|
32
|
-
"default": "./dist/index.
|
|
33
|
-
},
|
|
34
|
-
"./types": {
|
|
35
|
-
"types": "./dist/types.d.ts",
|
|
36
|
-
"default": "./dist/types.js"
|
|
33
|
+
"default": "./dist/index.js"
|
|
37
34
|
}
|
|
38
35
|
},
|
|
39
36
|
"files": [
|
|
40
37
|
"dist"
|
|
41
38
|
],
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"package-directory": "8.1.0"
|
|
41
|
+
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
-
"@trivago/prettier-plugin-sort-imports": "6.0.
|
|
44
|
-
"
|
|
43
|
+
"@trivago/prettier-plugin-sort-imports": "6.0.2",
|
|
44
|
+
"@types/node": "24.10.9",
|
|
45
|
+
"eslint": "9.39.2",
|
|
45
46
|
"eslint-config-prettier": "10.1.8",
|
|
46
47
|
"eslint-plugin-unused-imports": "4.3.0",
|
|
47
|
-
"
|
|
48
|
+
"@playwright/test": "1.58.0",
|
|
49
|
+
"prettier": "3.8.1",
|
|
48
50
|
"tsup": "8.5.1",
|
|
49
51
|
"typescript": "5.9.3",
|
|
50
|
-
"typescript-eslint": "8.
|
|
52
|
+
"typescript-eslint": "8.54.0",
|
|
51
53
|
"@cronn/shared-configs": "0.0.0"
|
|
52
54
|
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@playwright/test": "^1.55"
|
|
57
|
+
},
|
|
53
58
|
"scripts": {
|
|
54
59
|
"lint:check": "eslint src/ --max-warnings=0",
|
|
55
60
|
"lint:fix": "eslint src/ --max-warnings=0 --fix",
|
package/dist/types.js
DELETED
|
File without changes
|