@cronn/element-snapshot 0.7.0 → 0.9.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 +109 -0
- package/dist/{types.d.ts → browser-lib.d.ts} +25 -12
- package/dist/{index.global.js → browser-lib.global.js} +57 -38
- package/dist/index.d.ts +47 -19
- package/dist/index.js +158 -0
- package/package.json +14 -10
- package/dist/types.js +0 -0
package/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Element Snapshot
|
|
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
|
+
includeComboboxOptions: true,
|
|
86
|
+
}),
|
|
87
|
+
).toMatchJsonFile({
|
|
88
|
+
name: "select options",
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Custom Snapshots
|
|
93
|
+
|
|
94
|
+
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.
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import { snapshotElementRaw } from "@cronn/element-snapshot";
|
|
98
|
+
|
|
99
|
+
test("matches custom snapshot", async ({ page }) => {
|
|
100
|
+
const tableSnapshot = await snapshotElementRaw(page.getByRole("table"));
|
|
101
|
+
const markdownTable = transformToMarkdownTable(tableSnapshot);
|
|
102
|
+
|
|
103
|
+
await expect(markdownTable).toMatchTextFile({ fileExtension: "md" });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
function transformToMarkdownTable(snapshot: Array<NodeSnapshot>): string {
|
|
107
|
+
// transform table snapshot to markdown table
|
|
108
|
+
}
|
|
109
|
+
```
|
|
@@ -18,6 +18,10 @@ interface ButtonAttributes extends DisableableAttributes {
|
|
|
18
18
|
}
|
|
19
19
|
type PressedValue = true | "mixed";
|
|
20
20
|
|
|
21
|
+
interface DiscribableAttributes {
|
|
22
|
+
description?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
21
25
|
interface InputSnapshot extends GenericElementSnapshot<CommonInputRole, InputAttributes> {
|
|
22
26
|
}
|
|
23
27
|
type InputRole = "button" | "combobox" | CommonInputRole;
|
|
@@ -26,8 +30,7 @@ interface InputAttributes extends CommonInputAttributes {
|
|
|
26
30
|
value?: string;
|
|
27
31
|
checked?: boolean;
|
|
28
32
|
}
|
|
29
|
-
interface CommonInputAttributes extends InputStateAttributes {
|
|
30
|
-
description?: string;
|
|
33
|
+
interface CommonInputAttributes extends InputStateAttributes, DiscribableAttributes {
|
|
31
34
|
placeholder?: string;
|
|
32
35
|
}
|
|
33
36
|
|
|
@@ -43,21 +46,19 @@ interface OptionAttributes {
|
|
|
43
46
|
selected?: boolean;
|
|
44
47
|
}
|
|
45
48
|
|
|
46
|
-
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">;
|
|
47
|
-
interface ContainerSnapshot extends GenericElementSnapshot<ContainerRole> {
|
|
48
|
-
}
|
|
49
|
-
type ContainerRole = SetValues<typeof CONTAINER_ROLES>;
|
|
50
|
-
|
|
51
49
|
interface DialogSnapshot extends GenericElementSnapshot<DialogRole, DialogAttributes> {
|
|
52
50
|
}
|
|
53
51
|
type DialogRole = "dialog" | "alertdialog";
|
|
54
|
-
interface DialogAttributes {
|
|
55
|
-
description?: string;
|
|
52
|
+
interface DialogAttributes extends DiscribableAttributes {
|
|
56
53
|
modal?: boolean;
|
|
57
54
|
}
|
|
58
55
|
|
|
59
56
|
interface GroupSnapshot extends GenericElementSnapshot<"group"> {
|
|
60
57
|
}
|
|
58
|
+
interface RadiogroupSnapshot extends GenericElementSnapshot<"radiogroup", RadiogroupAttributes> {
|
|
59
|
+
}
|
|
60
|
+
interface RadiogroupAttributes extends DiscribableAttributes {
|
|
61
|
+
}
|
|
61
62
|
|
|
62
63
|
interface HeadingSnapshot extends GenericElementSnapshot<"heading", HeadingAttributes> {
|
|
63
64
|
}
|
|
@@ -100,10 +101,11 @@ interface TextSnapshot {
|
|
|
100
101
|
name: string;
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
type SnapshotTargetElement = HTMLElement | SVGElement;
|
|
103
105
|
type NodeRole = ElementRole | "text";
|
|
104
|
-
type ElementRole = "heading" | "link" | "button" | "option" | "tab" | "menuitem" | "columnheader" | "group" | "progressbar" | ContainerRole | InputRole | DialogRole;
|
|
106
|
+
type ElementRole = "heading" | "link" | "button" | "option" | "tab" | "menuitem" | "columnheader" | "group" | "radiogroup" | "progressbar" | ContainerRole | InputRole | DialogRole;
|
|
105
107
|
type NodeSnapshot = ElementSnapshot | TextSnapshot;
|
|
106
|
-
type ElementSnapshot = ContainerSnapshot | HeadingSnapshot | LinkSnapshot | ButtonSnapshot | InputSnapshot | ComboboxSnapshot | OptionSnapshot | DialogSnapshot | TabSnapshot | MenuitemSnapshot | ColumnheaderSnapshot | GroupSnapshot | ProgressbarSnapshot;
|
|
108
|
+
type ElementSnapshot = ContainerSnapshot | HeadingSnapshot | LinkSnapshot | ButtonSnapshot | InputSnapshot | ComboboxSnapshot | OptionSnapshot | DialogSnapshot | TabSnapshot | MenuitemSnapshot | ColumnheaderSnapshot | GroupSnapshot | RadiogroupSnapshot | ProgressbarSnapshot;
|
|
107
109
|
interface GenericElementSnapshot<TRole extends NodeRole = NodeRole, TAttributes = unknown> {
|
|
108
110
|
role: TRole;
|
|
109
111
|
name?: string;
|
|
@@ -112,4 +114,15 @@ interface GenericElementSnapshot<TRole extends NodeRole = NodeRole, TAttributes
|
|
|
112
114
|
}
|
|
113
115
|
type SetValues<TSet> = TSet extends Set<infer TValue> ? TValue : never;
|
|
114
116
|
|
|
115
|
-
|
|
117
|
+
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">;
|
|
118
|
+
interface ContainerSnapshot extends GenericElementSnapshot<ContainerRole> {
|
|
119
|
+
}
|
|
120
|
+
type ContainerRole = SetValues<typeof CONTAINER_ROLES>;
|
|
121
|
+
|
|
122
|
+
declare function snapshotElement(element: SnapshotTargetElement): Array<NodeSnapshot>;
|
|
123
|
+
|
|
124
|
+
declare global {
|
|
125
|
+
interface Window {
|
|
126
|
+
snapshotElement: typeof snapshotElement;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -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,12 @@
|
|
|
125
125
|
return booleanAttribute(pressed === "true");
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
// src/
|
|
128
|
+
// src/browser/description.ts
|
|
129
|
+
function discribableAttributes(element) {
|
|
130
|
+
return {
|
|
131
|
+
description: resolveDescription(element)
|
|
132
|
+
};
|
|
133
|
+
}
|
|
129
134
|
function resolveDescription(element) {
|
|
130
135
|
const describedByElement = resolveElementReference(
|
|
131
136
|
element,
|
|
@@ -140,7 +145,7 @@
|
|
|
140
145
|
return void 0;
|
|
141
146
|
}
|
|
142
147
|
|
|
143
|
-
// src/
|
|
148
|
+
// src/browser/container.ts
|
|
144
149
|
var CONTAINER_ROLES = /* @__PURE__ */ new Set([
|
|
145
150
|
"paragraph",
|
|
146
151
|
"list",
|
|
@@ -181,7 +186,7 @@
|
|
|
181
186
|
return CONTAINER_ROLES.has(role);
|
|
182
187
|
}
|
|
183
188
|
|
|
184
|
-
// src/
|
|
189
|
+
// src/browser/selector.ts
|
|
185
190
|
function selectorList(...selectors) {
|
|
186
191
|
return selectors.join(",");
|
|
187
192
|
}
|
|
@@ -192,7 +197,7 @@
|
|
|
192
197
|
return `[${name}='${value}']`;
|
|
193
198
|
}
|
|
194
199
|
|
|
195
|
-
// src/
|
|
200
|
+
// src/browser/utils.ts
|
|
196
201
|
function getElementTagName(element) {
|
|
197
202
|
return element.tagName.toLowerCase();
|
|
198
203
|
}
|
|
@@ -200,7 +205,7 @@
|
|
|
200
205
|
return element.closest(containerSelector) !== null;
|
|
201
206
|
}
|
|
202
207
|
|
|
203
|
-
// src/
|
|
208
|
+
// src/browser/role.ts
|
|
204
209
|
var ELEMENT_ROLES = {
|
|
205
210
|
main: "main",
|
|
206
211
|
nav: "navigation",
|
|
@@ -389,7 +394,7 @@
|
|
|
389
394
|
return isWithinElement(element, roleSelector("tablist"));
|
|
390
395
|
}
|
|
391
396
|
|
|
392
|
-
// src/
|
|
397
|
+
// src/browser/input.ts
|
|
393
398
|
function snapshotInput(element) {
|
|
394
399
|
if (element instanceof HTMLInputElement) {
|
|
395
400
|
return snapshotInputElement(element);
|
|
@@ -428,13 +433,14 @@
|
|
|
428
433
|
}
|
|
429
434
|
function snapshotCommonInputAttributes(element, isEmpty) {
|
|
430
435
|
return {
|
|
431
|
-
|
|
436
|
+
...discribableAttributes(element),
|
|
432
437
|
placeholder: isEmpty ? resolvePlaceholder(element) : void 0,
|
|
433
438
|
...inputStateAttributes(element)
|
|
434
439
|
};
|
|
435
440
|
}
|
|
441
|
+
var CHECKED_INPUT_TYPES = /* @__PURE__ */ new Set(["checkbox", "radio"]);
|
|
436
442
|
function resolveInputValue(element) {
|
|
437
|
-
if (element instanceof HTMLInputElement && element.type
|
|
443
|
+
if (element instanceof HTMLInputElement && CHECKED_INPUT_TYPES.has(element.type)) {
|
|
438
444
|
return void 0;
|
|
439
445
|
}
|
|
440
446
|
return stringAttribute(element.value);
|
|
@@ -471,13 +477,13 @@
|
|
|
471
477
|
return stringAttribute(element.placeholder);
|
|
472
478
|
}
|
|
473
479
|
|
|
474
|
-
// src/
|
|
480
|
+
// src/browser/combobox.ts
|
|
475
481
|
function snapshotCombobox(element) {
|
|
476
482
|
if (!isCombobox(element)) {
|
|
477
483
|
return null;
|
|
478
484
|
}
|
|
479
485
|
const options = snapshotOptions(element);
|
|
480
|
-
const value = resolveValue(element
|
|
486
|
+
const value = resolveValue(element);
|
|
481
487
|
return {
|
|
482
488
|
role: "combobox",
|
|
483
489
|
name: resolveInputLabel(element),
|
|
@@ -497,15 +503,23 @@
|
|
|
497
503
|
}
|
|
498
504
|
return false;
|
|
499
505
|
}
|
|
500
|
-
function resolveValue(element
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
506
|
+
function resolveValue(element) {
|
|
507
|
+
if (element instanceof HTMLButtonElement) {
|
|
508
|
+
return snapshotTextContent(element);
|
|
509
|
+
}
|
|
510
|
+
if (element instanceof HTMLInputElement) {
|
|
511
|
+
return resolveInputValue(element);
|
|
512
|
+
}
|
|
513
|
+
const selectedLabels = Array.from(element.selectedOptions).map(
|
|
514
|
+
(option) => option.label
|
|
515
|
+
);
|
|
516
|
+
if (selectedLabels.length === 0) {
|
|
517
|
+
return void 0;
|
|
504
518
|
}
|
|
505
|
-
if (
|
|
506
|
-
return
|
|
519
|
+
if (selectedLabels.length === 1) {
|
|
520
|
+
return selectedLabels.at(0);
|
|
507
521
|
}
|
|
508
|
-
return
|
|
522
|
+
return selectedLabels;
|
|
509
523
|
}
|
|
510
524
|
function snapshotOptions(element) {
|
|
511
525
|
return resolveOptions(element).map(snapshotOption).filter((childSnapshot) => childSnapshot !== null);
|
|
@@ -514,10 +528,6 @@
|
|
|
514
528
|
if (element instanceof HTMLSelectElement) {
|
|
515
529
|
return Array.from(element.options);
|
|
516
530
|
}
|
|
517
|
-
const ariaExpanded = element.ariaExpanded === "true";
|
|
518
|
-
if (!ariaExpanded) {
|
|
519
|
-
return [];
|
|
520
|
-
}
|
|
521
531
|
const controlledElement = resolveElementReference(element, "aria-controls");
|
|
522
532
|
if (controlledElement === null) {
|
|
523
533
|
return [];
|
|
@@ -536,7 +546,7 @@
|
|
|
536
546
|
};
|
|
537
547
|
}
|
|
538
548
|
|
|
539
|
-
// src/
|
|
549
|
+
// src/browser/dialog.ts
|
|
540
550
|
function snapshotDialogWithRole(role) {
|
|
541
551
|
return (element) => snapshotDialog(role, element);
|
|
542
552
|
}
|
|
@@ -545,14 +555,14 @@
|
|
|
545
555
|
role,
|
|
546
556
|
name: resolveAccessibleName(element, false),
|
|
547
557
|
attributes: {
|
|
548
|
-
|
|
558
|
+
...discribableAttributes(element),
|
|
549
559
|
modal: booleanAttribute(element.ariaModal)
|
|
550
560
|
},
|
|
551
561
|
children: snapshotChildren(element)
|
|
552
562
|
};
|
|
553
563
|
}
|
|
554
564
|
|
|
555
|
-
// src/
|
|
565
|
+
// src/browser/group.ts
|
|
556
566
|
function snapshotGroup(element) {
|
|
557
567
|
return {
|
|
558
568
|
role: "group",
|
|
@@ -567,8 +577,16 @@
|
|
|
567
577
|
}
|
|
568
578
|
return resolveAccessibleName(element, false);
|
|
569
579
|
}
|
|
580
|
+
function snapshotRadiogroup(element) {
|
|
581
|
+
return {
|
|
582
|
+
role: "radiogroup",
|
|
583
|
+
name: resolveAccessibleName(element, false),
|
|
584
|
+
attributes: discribableAttributes(element),
|
|
585
|
+
children: snapshotChildren(element)
|
|
586
|
+
};
|
|
587
|
+
}
|
|
570
588
|
|
|
571
|
-
// src/
|
|
589
|
+
// src/browser/heading.ts
|
|
572
590
|
var LEVEL_REGEXP = /^H([1-6])$/;
|
|
573
591
|
function snapshotHeading(element) {
|
|
574
592
|
if (!(element instanceof HTMLHeadingElement)) {
|
|
@@ -588,7 +606,7 @@
|
|
|
588
606
|
return numericAttribute(levelAsString);
|
|
589
607
|
}
|
|
590
608
|
|
|
591
|
-
// src/
|
|
609
|
+
// src/browser/link.ts
|
|
592
610
|
function snapshotLink(element) {
|
|
593
611
|
return {
|
|
594
612
|
role: "link",
|
|
@@ -601,7 +619,7 @@
|
|
|
601
619
|
return { url: stringAttribute(element.getAttribute("href")) };
|
|
602
620
|
}
|
|
603
621
|
|
|
604
|
-
// src/
|
|
622
|
+
// src/browser/list.ts
|
|
605
623
|
function snapshotMenuitem(element) {
|
|
606
624
|
return {
|
|
607
625
|
role: "menuitem",
|
|
@@ -614,7 +632,7 @@
|
|
|
614
632
|
};
|
|
615
633
|
}
|
|
616
634
|
|
|
617
|
-
// src/
|
|
635
|
+
// src/browser/progressbar.ts
|
|
618
636
|
function snapshotProgressbar(element) {
|
|
619
637
|
return {
|
|
620
638
|
role: "progressbar",
|
|
@@ -632,7 +650,7 @@
|
|
|
632
650
|
return numericAttribute(element.ariaValueNow);
|
|
633
651
|
}
|
|
634
652
|
|
|
635
|
-
// src/
|
|
653
|
+
// src/browser/tab.ts
|
|
636
654
|
function snapshotTab(element) {
|
|
637
655
|
return {
|
|
638
656
|
role: "tab",
|
|
@@ -645,7 +663,7 @@
|
|
|
645
663
|
};
|
|
646
664
|
}
|
|
647
665
|
|
|
648
|
-
// src/
|
|
666
|
+
// src/browser/table.ts
|
|
649
667
|
var SORT_ATTRIBUTES = /* @__PURE__ */ new Set(["ascending", "descending", "other"]);
|
|
650
668
|
function snapshotColumnheader(element) {
|
|
651
669
|
return {
|
|
@@ -658,7 +676,7 @@
|
|
|
658
676
|
};
|
|
659
677
|
}
|
|
660
678
|
|
|
661
|
-
// src/
|
|
679
|
+
// src/browser/element.ts
|
|
662
680
|
var ROLE_SNAPSHOTS = {
|
|
663
681
|
button: snapshotButton,
|
|
664
682
|
checkbox: snapshotInput,
|
|
@@ -677,6 +695,7 @@
|
|
|
677
695
|
menuitem: snapshotMenuitem,
|
|
678
696
|
columnheader: snapshotColumnheader,
|
|
679
697
|
group: snapshotGroup,
|
|
698
|
+
radiogroup: snapshotRadiogroup,
|
|
680
699
|
progressbar: snapshotProgressbar
|
|
681
700
|
};
|
|
682
701
|
function snapshotElement(element) {
|
|
@@ -742,6 +761,6 @@
|
|
|
742
761
|
return cssStyles.display === "none" || cssStyles.visibility === "hidden" || cssStyles.visibility === "collapse";
|
|
743
762
|
}
|
|
744
763
|
|
|
745
|
-
// src/
|
|
764
|
+
// src/browser-lib.ts
|
|
746
765
|
window.snapshotElement = snapshotElement;
|
|
747
766
|
})();
|
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
|
}
|
|
@@ -18,6 +20,10 @@ interface ButtonAttributes extends DisableableAttributes {
|
|
|
18
20
|
}
|
|
19
21
|
type PressedValue = true | "mixed";
|
|
20
22
|
|
|
23
|
+
interface DiscribableAttributes {
|
|
24
|
+
description?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
interface InputSnapshot extends GenericElementSnapshot<CommonInputRole, InputAttributes> {
|
|
22
28
|
}
|
|
23
29
|
type InputRole = "button" | "combobox" | CommonInputRole;
|
|
@@ -26,8 +32,7 @@ interface InputAttributes extends CommonInputAttributes {
|
|
|
26
32
|
value?: string;
|
|
27
33
|
checked?: boolean;
|
|
28
34
|
}
|
|
29
|
-
interface CommonInputAttributes extends InputStateAttributes {
|
|
30
|
-
description?: string;
|
|
35
|
+
interface CommonInputAttributes extends InputStateAttributes, DiscribableAttributes {
|
|
31
36
|
placeholder?: string;
|
|
32
37
|
}
|
|
33
38
|
|
|
@@ -43,16 +48,24 @@ interface OptionAttributes {
|
|
|
43
48
|
selected?: boolean;
|
|
44
49
|
}
|
|
45
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">;
|
|
52
|
+
interface ContainerSnapshot extends GenericElementSnapshot<ContainerRole> {
|
|
53
|
+
}
|
|
54
|
+
type ContainerRole = SetValues<typeof CONTAINER_ROLES>;
|
|
55
|
+
|
|
46
56
|
interface DialogSnapshot extends GenericElementSnapshot<DialogRole, DialogAttributes> {
|
|
47
57
|
}
|
|
48
58
|
type DialogRole = "dialog" | "alertdialog";
|
|
49
|
-
interface DialogAttributes {
|
|
50
|
-
description?: string;
|
|
59
|
+
interface DialogAttributes extends DiscribableAttributes {
|
|
51
60
|
modal?: boolean;
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
interface GroupSnapshot extends GenericElementSnapshot<"group"> {
|
|
55
64
|
}
|
|
65
|
+
interface RadiogroupSnapshot extends GenericElementSnapshot<"radiogroup", RadiogroupAttributes> {
|
|
66
|
+
}
|
|
67
|
+
interface RadiogroupAttributes extends DiscribableAttributes {
|
|
68
|
+
}
|
|
56
69
|
|
|
57
70
|
interface HeadingSnapshot extends GenericElementSnapshot<"heading", HeadingAttributes> {
|
|
58
71
|
}
|
|
@@ -95,11 +108,10 @@ interface TextSnapshot {
|
|
|
95
108
|
name: string;
|
|
96
109
|
}
|
|
97
110
|
|
|
98
|
-
type SnapshotTargetElement = HTMLElement | SVGElement;
|
|
99
111
|
type NodeRole = ElementRole | "text";
|
|
100
|
-
type ElementRole = "heading" | "link" | "button" | "option" | "tab" | "menuitem" | "columnheader" | "group" | "progressbar" | ContainerRole | InputRole | DialogRole;
|
|
112
|
+
type ElementRole = "heading" | "link" | "button" | "option" | "tab" | "menuitem" | "columnheader" | "group" | "radiogroup" | "progressbar" | ContainerRole | InputRole | DialogRole;
|
|
101
113
|
type NodeSnapshot = ElementSnapshot | TextSnapshot;
|
|
102
|
-
type ElementSnapshot = ContainerSnapshot | HeadingSnapshot | LinkSnapshot | ButtonSnapshot | InputSnapshot | ComboboxSnapshot | OptionSnapshot | DialogSnapshot | TabSnapshot | MenuitemSnapshot | ColumnheaderSnapshot | GroupSnapshot | ProgressbarSnapshot;
|
|
114
|
+
type ElementSnapshot = ContainerSnapshot | HeadingSnapshot | LinkSnapshot | ButtonSnapshot | InputSnapshot | ComboboxSnapshot | OptionSnapshot | DialogSnapshot | TabSnapshot | MenuitemSnapshot | ColumnheaderSnapshot | GroupSnapshot | RadiogroupSnapshot | ProgressbarSnapshot;
|
|
103
115
|
interface GenericElementSnapshot<TRole extends NodeRole = NodeRole, TAttributes = unknown> {
|
|
104
116
|
role: TRole;
|
|
105
117
|
name?: string;
|
|
@@ -108,15 +120,31 @@ interface GenericElementSnapshot<TRole extends NodeRole = NodeRole, TAttributes
|
|
|
108
120
|
}
|
|
109
121
|
type SetValues<TSet> = TSet extends Set<infer TValue> ? TValue : never;
|
|
110
122
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
+
interface ElementSnapshotOptions {
|
|
124
|
+
/**
|
|
125
|
+
* Include combobox options in the snapshot
|
|
126
|
+
*
|
|
127
|
+
* @default false
|
|
128
|
+
*/
|
|
129
|
+
includeComboboxOptions?: boolean;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Creates an Element Snapshot of the specified element
|
|
133
|
+
*
|
|
134
|
+
* @example
|
|
135
|
+
* ```ts
|
|
136
|
+
* await expect(snapshotElement(page.getByRole("main"))).toMatchJsonSnapshot();
|
|
137
|
+
* ```
|
|
138
|
+
* @param locator Locator for the element to snapshot
|
|
139
|
+
* @experimental
|
|
140
|
+
*/
|
|
141
|
+
declare function snapshotElement(locator: Locator, options?: ElementSnapshotOptions): Promise<unknown>;
|
|
142
|
+
/**
|
|
143
|
+
* Creates an Element Snapshot of the specified element, returning the raw snapshot data for custom transformations
|
|
144
|
+
*
|
|
145
|
+
* @param locator Locator for the element to snapshot
|
|
146
|
+
* @experimental
|
|
147
|
+
*/
|
|
148
|
+
declare function snapshotElementRaw(locator: Locator): Promise<Array<NodeSnapshot>>;
|
|
149
|
+
|
|
150
|
+
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,158 @@
|
|
|
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
|
+
includeComboboxOptions;
|
|
47
|
+
constructor(options = {}) {
|
|
48
|
+
this.includeComboboxOptions = options.includeComboboxOptions ?? false;
|
|
49
|
+
}
|
|
50
|
+
transform(snapshots) {
|
|
51
|
+
const transformedSnapshots = snapshots.map(
|
|
52
|
+
(snapshot) => this.transformSnapshotRecursive(snapshot)
|
|
53
|
+
);
|
|
54
|
+
if (transformedSnapshots.length === 1) {
|
|
55
|
+
return transformedSnapshots.at(0);
|
|
56
|
+
}
|
|
57
|
+
return transformedSnapshots;
|
|
58
|
+
}
|
|
59
|
+
transformSnapshotRecursive(snapshot) {
|
|
60
|
+
if (snapshot.role === "text") {
|
|
61
|
+
return this.simplifyTextSnapshot(snapshot);
|
|
62
|
+
}
|
|
63
|
+
return this.simplifyElementSnapshot(snapshot);
|
|
64
|
+
}
|
|
65
|
+
simplifyTextSnapshot(snapshot) {
|
|
66
|
+
return snapshot.name;
|
|
67
|
+
}
|
|
68
|
+
simplifyElementSnapshot(snapshot) {
|
|
69
|
+
const normalizedSnapshot = this.normalizeElementSnapshot(snapshot);
|
|
70
|
+
if (this.isEmpty(normalizedSnapshot)) {
|
|
71
|
+
const { role: role2 } = normalizedSnapshot;
|
|
72
|
+
return this.transformedSnapshot(role2, "");
|
|
73
|
+
}
|
|
74
|
+
if (this.hasOnlyName(normalizedSnapshot)) {
|
|
75
|
+
const { role: role2, name: name2 } = normalizedSnapshot;
|
|
76
|
+
return this.transformedSnapshot(role2, name2);
|
|
77
|
+
}
|
|
78
|
+
if (this.hasOnlyChildren(normalizedSnapshot)) {
|
|
79
|
+
const { role: role2, children: children2 } = normalizedSnapshot;
|
|
80
|
+
return this.transformedSnapshot(
|
|
81
|
+
role2,
|
|
82
|
+
children2.length === 1 ? children2.at(0) : children2
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (snapshot.role === "combobox") {
|
|
86
|
+
return this.simplifyComboboxSnapshot(snapshot);
|
|
87
|
+
}
|
|
88
|
+
const { role, name, attributes, children } = normalizedSnapshot;
|
|
89
|
+
return this.transformedSnapshot(role, {
|
|
90
|
+
name,
|
|
91
|
+
...attributes,
|
|
92
|
+
children
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
normalizeElementSnapshot(snapshot) {
|
|
96
|
+
const sparseAttributes = this.removeUndefinedProperties(
|
|
97
|
+
snapshot.attributes ?? {}
|
|
98
|
+
);
|
|
99
|
+
const transformedChildren = snapshot.children?.map(
|
|
100
|
+
(childSnaphot) => this.transformSnapshotRecursive(childSnaphot)
|
|
101
|
+
);
|
|
102
|
+
const nameEqualsChildren = transformedChildren?.length === 1 && snapshot.name === transformedChildren.at(0);
|
|
103
|
+
return {
|
|
104
|
+
role: snapshot.role,
|
|
105
|
+
name: snapshot.name,
|
|
106
|
+
attributes: sparseAttributes,
|
|
107
|
+
children: nameEqualsChildren ? void 0 : transformedChildren
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
simplifyComboboxSnapshot(snapshot) {
|
|
111
|
+
const { role, name, attributes } = this.normalizeElementSnapshot(snapshot);
|
|
112
|
+
const options = snapshot.options ?? [];
|
|
113
|
+
const transformedOptions = options.map(
|
|
114
|
+
(optionSnapshot) => this.simplifyElementSnapshot(optionSnapshot)
|
|
115
|
+
);
|
|
116
|
+
return this.transformedSnapshot(role, {
|
|
117
|
+
name,
|
|
118
|
+
...attributes,
|
|
119
|
+
options: this.includeComboboxOptions && transformedOptions.length > 0 ? transformedOptions : void 0
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
isEmpty(snapshot) {
|
|
123
|
+
return snapshot.name === void 0 && (snapshot.attributes === void 0 || Object.keys(snapshot.attributes).length === 0) && (snapshot.children === void 0 || snapshot.children.length === 0);
|
|
124
|
+
}
|
|
125
|
+
hasOnlyName(snapshot) {
|
|
126
|
+
return snapshot.name !== void 0 && snapshot.attributes === void 0 && snapshot.children === void 0;
|
|
127
|
+
}
|
|
128
|
+
hasOnlyChildren(snapshot) {
|
|
129
|
+
return snapshot.name === void 0 && snapshot.attributes === void 0 && snapshot.children !== void 0;
|
|
130
|
+
}
|
|
131
|
+
removeUndefinedProperties(attributes) {
|
|
132
|
+
const sparseAttributes = {};
|
|
133
|
+
Object.entries(attributes).forEach(([key, value]) => {
|
|
134
|
+
if (value !== void 0) {
|
|
135
|
+
sparseAttributes[key] = value;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
return Object.keys(sparseAttributes).length > 0 ? sparseAttributes : void 0;
|
|
139
|
+
}
|
|
140
|
+
transformedSnapshot(role, content) {
|
|
141
|
+
return { [role]: content };
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// src/playwright/snapshot.ts
|
|
146
|
+
async function snapshotElement(locator, options) {
|
|
147
|
+
const snapshot = await snapshotElementRaw(locator);
|
|
148
|
+
return new ElementSnapshotTransformer(options).transform(snapshot);
|
|
149
|
+
}
|
|
150
|
+
async function snapshotElementRaw(locator) {
|
|
151
|
+
return await new ElementSnapshotProxy(locator.page()).snapshotElement(
|
|
152
|
+
locator
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
export {
|
|
156
|
+
snapshotElement,
|
|
157
|
+
snapshotElementRaw
|
|
158
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cronn/element-snapshot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Element snapshots for Playwright",
|
|
5
5
|
"author": "cronn",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
"packageManager": {
|
|
23
23
|
"name": "pnpm",
|
|
24
|
-
"version": ">=10.
|
|
24
|
+
"version": ">=10.27 <11",
|
|
25
25
|
"onFail": "error"
|
|
26
26
|
}
|
|
27
27
|
},
|
|
@@ -29,27 +29,31 @@
|
|
|
29
29
|
"exports": {
|
|
30
30
|
".": {
|
|
31
31
|
"types": "./dist/index.d.ts",
|
|
32
|
-
"default": "./dist/index.
|
|
33
|
-
},
|
|
34
|
-
"./types": {
|
|
35
|
-
"types": "./dist/types.d.ts",
|
|
36
|
-
"default": "./dist/types.js"
|
|
32
|
+
"default": "./dist/index.js"
|
|
37
33
|
}
|
|
38
34
|
},
|
|
39
35
|
"files": [
|
|
40
36
|
"dist"
|
|
41
37
|
],
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"package-directory": "8.1.0"
|
|
40
|
+
},
|
|
42
41
|
"devDependencies": {
|
|
43
42
|
"@trivago/prettier-plugin-sort-imports": "6.0.0",
|
|
44
|
-
"
|
|
43
|
+
"@types/node": "24.10.4",
|
|
44
|
+
"eslint": "9.39.2",
|
|
45
45
|
"eslint-config-prettier": "10.1.8",
|
|
46
46
|
"eslint-plugin-unused-imports": "4.3.0",
|
|
47
|
-
"
|
|
47
|
+
"@playwright/test": "1.56.1",
|
|
48
|
+
"prettier": "3.7.4",
|
|
48
49
|
"tsup": "8.5.1",
|
|
49
50
|
"typescript": "5.9.3",
|
|
50
|
-
"typescript-eslint": "8.
|
|
51
|
+
"typescript-eslint": "8.50.1",
|
|
51
52
|
"@cronn/shared-configs": "0.0.0"
|
|
52
53
|
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"@playwright/test": "^1.55"
|
|
56
|
+
},
|
|
53
57
|
"scripts": {
|
|
54
58
|
"lint:check": "eslint src/ --max-warnings=0",
|
|
55
59
|
"lint:fix": "eslint src/ --max-warnings=0 --fix",
|
package/dist/types.js
DELETED
|
File without changes
|