@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 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
- export type { ButtonSnapshot, ColumnheaderSnapshot, ComboboxSnapshot, ContainerSnapshot, DialogSnapshot, ElementRole, ElementSnapshot, GroupSnapshot, HeadingSnapshot, InputSnapshot, LinkSnapshot, MenuitemSnapshot, NodeRole, NodeSnapshot, OptionSnapshot, ProgressbarSnapshot, TabSnapshot, TextSnapshot };
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/snapshots/attribute.ts
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/snapshots/children.ts
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/snapshots/text.ts
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/snapshots/name.ts
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/snapshots/state.ts
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/snapshots/button.ts
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/snapshots/description.ts
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/snapshots/container.ts
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/snapshots/selector.ts
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/snapshots/utils.ts
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/snapshots/role.ts
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/snapshots/input.ts
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
- description: resolveDescription(element),
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 === "checkbox") {
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/snapshots/combobox.ts
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, options);
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, options) {
501
- const selectedOptions = options.filter((option) => option.attributes?.selected === true).map((option) => option.name).filter((optionName) => optionName !== void 0);
502
- if (selectedOptions.length === 0) {
503
- return element instanceof HTMLInputElement ? resolveInputValue(element) : void 0;
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 (selectedOptions.length === 1) {
506
- return selectedOptions.at(0);
519
+ if (selectedLabels.length === 1) {
520
+ return selectedLabels.at(0);
507
521
  }
508
- return selectedOptions;
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/snapshots/dialog.ts
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
- description: resolveDescription(element),
558
+ ...discribableAttributes(element),
549
559
  modal: booleanAttribute(element.ariaModal)
550
560
  },
551
561
  children: snapshotChildren(element)
552
562
  };
553
563
  }
554
564
 
555
- // src/snapshots/group.ts
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/snapshots/heading.ts
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/snapshots/link.ts
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/snapshots/list.ts
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/snapshots/progressbar.ts
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/snapshots/tab.ts
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/snapshots/table.ts
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/snapshots/element.ts
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/index.ts
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
- 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">;
112
- interface ContainerSnapshot extends GenericElementSnapshot<ContainerRole> {
113
- }
114
- type ContainerRole = SetValues<typeof CONTAINER_ROLES>;
115
-
116
- declare function snapshotElement(element: SnapshotTargetElement): Array<NodeSnapshot>;
117
-
118
- declare global {
119
- interface Window {
120
- snapshotElement: typeof snapshotElement;
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.7.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.21 <11",
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.global.js"
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
- "eslint": "9.39.1",
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
- "prettier": "3.6.2",
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.47.0",
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