@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 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
- export type { ButtonSnapshot, ColumnheaderSnapshot, ComboboxSnapshot, ContainerSnapshot, DialogSnapshot, ElementRole, ElementSnapshot, GroupSnapshot, HeadingSnapshot, InputSnapshot, LinkSnapshot, MenuitemSnapshot, NodeRole, NodeSnapshot, OptionSnapshot, ProgressbarSnapshot, TabSnapshot, TextSnapshot };
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/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,7 @@
125
125
  return booleanAttribute(pressed === "true");
126
126
  }
127
127
 
128
- // src/snapshots/description.ts
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/snapshots/container.ts
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/snapshots/selector.ts
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/snapshots/utils.ts
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/snapshots/role.ts
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/snapshots/input.ts
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/snapshots/combobox.ts
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/snapshots/dialog.ts
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/snapshots/group.ts
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/snapshots/heading.ts
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/snapshots/link.ts
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/snapshots/list.ts
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/snapshots/progressbar.ts
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/snapshots/tab.ts
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/snapshots/table.ts
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/snapshots/element.ts
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/index.ts
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
- 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
- }
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.8.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.11 <25",
19
+ "version": ">=24.13 <25",
20
20
  "onFail": "error"
21
21
  },
22
22
  "packageManager": {
23
23
  "name": "pnpm",
24
- "version": ">=10.21 <11",
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.global.js"
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.0",
44
- "eslint": "9.39.1",
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
- "prettier": "3.6.2",
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.47.0",
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