@treelocator/runtime 0.4.7 → 0.6.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.
Files changed (166) hide show
  1. package/.eslintignore +1 -0
  2. package/dist/_generated_styles.d.ts +1 -1
  3. package/dist/_generated_styles.js +20 -0
  4. package/dist/_generated_tree_icon.d.ts +1 -1
  5. package/dist/adapters/HtmlElementTreeNode.d.ts +2 -2
  6. package/dist/adapters/HtmlElementTreeNode.js +4 -6
  7. package/dist/adapters/createTreeNode.js +17 -44
  8. package/dist/adapters/detectFramework.d.ts +8 -0
  9. package/dist/adapters/detectFramework.js +25 -0
  10. package/dist/adapters/detectFramework.test.d.ts +1 -0
  11. package/dist/adapters/detectFramework.test.js +60 -0
  12. package/dist/adapters/jsx/jsxAdapter.js +54 -89
  13. package/dist/adapters/jsx/jsxAdapter.test.d.ts +1 -0
  14. package/dist/adapters/jsx/jsxAdapter.test.js +273 -0
  15. package/dist/adapters/nextjs/parseNextjsDataAttributes.js +1 -1
  16. package/dist/adapters/nextjs/parseNextjsDataAttributes.test.d.ts +1 -0
  17. package/dist/adapters/nextjs/parseNextjsDataAttributes.test.js +158 -0
  18. package/dist/adapters/react/findFiberByHtmlElement.d.ts +1 -1
  19. package/dist/adapters/react/findFiberByHtmlElement.js +1 -1
  20. package/dist/adapters/react/getAllParentsElementsAndRootComponent.js +4 -0
  21. package/dist/adapters/resolveAdapter.d.ts +1 -1
  22. package/dist/adapters/resolveAdapter.js +4 -8
  23. package/dist/adapters/svelte/svelteAdapter.test.d.ts +1 -0
  24. package/dist/adapters/svelte/svelteAdapter.test.js +280 -0
  25. package/dist/adapters/vue/vueAdapter.test.d.ts +1 -0
  26. package/dist/adapters/vue/vueAdapter.test.js +222 -0
  27. package/dist/browserApi.d.ts +148 -0
  28. package/dist/browserApi.js +146 -5
  29. package/dist/browserApi.test.d.ts +1 -0
  30. package/dist/browserApi.test.js +287 -0
  31. package/dist/components/RecordingPillButton.d.ts +11 -0
  32. package/dist/components/RecordingPillButton.js +202 -0
  33. package/dist/components/RecordingResults.d.ts +2 -0
  34. package/dist/components/RecordingResults.js +213 -78
  35. package/dist/components/Runtime.js +161 -554
  36. package/dist/components/SettingsPanel.d.ts +5 -0
  37. package/dist/components/SettingsPanel.js +312 -0
  38. package/dist/consoleCapture.d.ts +9 -0
  39. package/dist/consoleCapture.js +95 -0
  40. package/dist/dejitter/recorder.d.ts +7 -1
  41. package/dist/dejitter/recorder.js +64 -1
  42. package/dist/functions/cssRuleInspector.d.ts +83 -0
  43. package/dist/functions/cssRuleInspector.js +608 -0
  44. package/dist/functions/cssRuleInspector.test.d.ts +1 -0
  45. package/dist/functions/cssRuleInspector.test.js +439 -0
  46. package/dist/functions/deduplicateLabels.test.d.ts +1 -0
  47. package/dist/functions/deduplicateLabels.test.js +178 -0
  48. package/dist/functions/enrichAncestrySourceMaps.js +0 -1
  49. package/dist/functions/extractComputedStyles.d.ts +51 -0
  50. package/dist/functions/extractComputedStyles.js +447 -0
  51. package/dist/functions/extractComputedStyles.test.d.ts +1 -0
  52. package/dist/functions/extractComputedStyles.test.js +549 -0
  53. package/dist/functions/formatAncestryChain.d.ts +8 -0
  54. package/dist/functions/formatAncestryChain.js +21 -1
  55. package/dist/functions/formatAncestryChain.test.js +18 -0
  56. package/dist/functions/getUsableName.test.d.ts +1 -0
  57. package/dist/functions/getUsableName.test.js +219 -0
  58. package/dist/functions/isCombinationModifiersPressed.test.d.ts +1 -0
  59. package/dist/functions/isCombinationModifiersPressed.test.js +192 -0
  60. package/dist/functions/mergeRects.test.js +210 -1
  61. package/dist/functions/namedSnapshots.d.ts +52 -0
  62. package/dist/functions/namedSnapshots.js +161 -0
  63. package/dist/functions/namedSnapshots.test.d.ts +1 -0
  64. package/dist/functions/namedSnapshots.test.js +85 -0
  65. package/dist/functions/normalizeFilePath.test.d.ts +1 -0
  66. package/dist/functions/normalizeFilePath.test.js +66 -0
  67. package/dist/functions/parseDataId.test.d.ts +1 -0
  68. package/dist/functions/parseDataId.test.js +101 -0
  69. package/dist/hooks/getStorage.d.ts +3 -0
  70. package/dist/hooks/getStorage.js +17 -0
  71. package/dist/hooks/useEventListeners.d.ts +15 -0
  72. package/dist/hooks/useEventListeners.js +56 -0
  73. package/dist/hooks/useLocatorStorage.d.ts +18 -0
  74. package/dist/hooks/useLocatorStorage.js +41 -0
  75. package/dist/hooks/useLocatorStorage.test.d.ts +1 -0
  76. package/dist/hooks/useLocatorStorage.test.js +124 -0
  77. package/dist/hooks/useRecordingState.d.ts +43 -0
  78. package/dist/hooks/useRecordingState.js +387 -0
  79. package/dist/hooks/useSettings.d.ts +13 -0
  80. package/dist/hooks/useSettings.js +66 -0
  81. package/dist/index.d.ts +5 -2
  82. package/dist/index.js +4 -2
  83. package/dist/initRuntime.d.ts +3 -1
  84. package/dist/initRuntime.js +4 -1
  85. package/dist/mcpBridge.d.ts +61 -0
  86. package/dist/mcpBridge.js +534 -0
  87. package/dist/mcpBridge.test.d.ts +1 -0
  88. package/dist/mcpBridge.test.js +248 -0
  89. package/dist/output.css +20 -0
  90. package/dist/visualDiff/diff.d.ts +9 -0
  91. package/dist/visualDiff/diff.js +209 -0
  92. package/dist/visualDiff/diff.test.d.ts +1 -0
  93. package/dist/visualDiff/diff.test.js +253 -0
  94. package/dist/visualDiff/settle.d.ts +3 -0
  95. package/dist/visualDiff/settle.js +50 -0
  96. package/dist/visualDiff/settle.test.d.ts +1 -0
  97. package/dist/visualDiff/settle.test.js +65 -0
  98. package/dist/visualDiff/snapshot.d.ts +4 -0
  99. package/dist/visualDiff/snapshot.js +84 -0
  100. package/dist/visualDiff/snapshot.test.d.ts +1 -0
  101. package/dist/visualDiff/snapshot.test.js +245 -0
  102. package/dist/visualDiff/types.d.ts +37 -0
  103. package/dist/visualDiff/types.js +1 -0
  104. package/package.json +2 -2
  105. package/scripts/wrapCSS.js +1 -1
  106. package/scripts/wrapImage.js +1 -1
  107. package/src/_generated_styles.ts +21 -1
  108. package/src/_generated_tree_icon.ts +1 -1
  109. package/src/adapters/HtmlElementTreeNode.ts +10 -7
  110. package/src/adapters/createTreeNode.ts +12 -51
  111. package/src/adapters/detectFramework.test.ts +73 -0
  112. package/src/adapters/detectFramework.ts +28 -0
  113. package/src/adapters/jsx/jsxAdapter.test.ts +240 -0
  114. package/src/adapters/jsx/jsxAdapter.ts +53 -106
  115. package/src/adapters/nextjs/parseNextjsDataAttributes.test.ts +212 -0
  116. package/src/adapters/nextjs/parseNextjsDataAttributes.ts +1 -1
  117. package/src/adapters/react/findDebugSource.ts +5 -6
  118. package/src/adapters/react/findFiberByHtmlElement.ts +3 -3
  119. package/src/adapters/react/getAllParentsElementsAndRootComponent.ts +3 -0
  120. package/src/adapters/react/reactAdapter.ts +1 -2
  121. package/src/adapters/resolveAdapter.ts +4 -14
  122. package/src/adapters/svelte/svelteAdapter.test.ts +334 -0
  123. package/src/adapters/vue/vueAdapter.test.ts +259 -0
  124. package/src/browserApi.test.ts +329 -0
  125. package/src/browserApi.ts +351 -4
  126. package/src/components/RecordingPillButton.tsx +301 -0
  127. package/src/components/RecordingResults.tsx +114 -13
  128. package/src/components/Runtime.tsx +176 -621
  129. package/src/components/SettingsPanel.tsx +339 -0
  130. package/src/consoleCapture.ts +113 -0
  131. package/src/dejitter/recorder.ts +67 -3
  132. package/src/functions/cssRuleInspector.test.ts +517 -0
  133. package/src/functions/cssRuleInspector.ts +708 -0
  134. package/src/functions/deduplicateLabels.test.ts +115 -0
  135. package/src/functions/enrichAncestrySourceMaps.ts +6 -3
  136. package/src/functions/extractComputedStyles.test.ts +681 -0
  137. package/src/functions/extractComputedStyles.ts +768 -0
  138. package/src/functions/formatAncestryChain.test.ts +23 -1
  139. package/src/functions/formatAncestryChain.ts +22 -1
  140. package/src/functions/getUsableName.test.ts +242 -0
  141. package/src/functions/isCombinationModifiersPressed.test.ts +156 -0
  142. package/src/functions/mergeRects.test.ts +111 -1
  143. package/src/functions/namedSnapshots.test.ts +106 -0
  144. package/src/functions/namedSnapshots.ts +232 -0
  145. package/src/functions/normalizeFilePath.test.ts +80 -0
  146. package/src/functions/parseDataId.test.ts +125 -0
  147. package/src/hooks/getStorage.ts +26 -0
  148. package/src/hooks/useEventListeners.ts +97 -0
  149. package/src/hooks/useLocatorStorage.test.ts +127 -0
  150. package/src/hooks/useLocatorStorage.ts +60 -0
  151. package/src/hooks/useRecordingState.ts +516 -0
  152. package/src/hooks/useSettings.ts +83 -0
  153. package/src/index.ts +10 -5
  154. package/src/initRuntime.ts +5 -0
  155. package/src/mcpBridge.test.ts +260 -0
  156. package/src/mcpBridge.ts +677 -0
  157. package/src/visualDiff/diff.test.ts +167 -0
  158. package/src/visualDiff/diff.ts +242 -0
  159. package/src/visualDiff/settle.test.ts +77 -0
  160. package/src/visualDiff/settle.ts +62 -0
  161. package/src/visualDiff/snapshot.test.ts +200 -0
  162. package/src/visualDiff/snapshot.ts +119 -0
  163. package/src/visualDiff/types.ts +40 -0
  164. package/tsconfig.json +3 -1
  165. package/vitest.config.ts +18 -0
  166. package/jest.config.ts +0 -195
@@ -0,0 +1,681 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import {
4
+ _collapseFourValues,
5
+ _processGroupEntries,
6
+ _formatDiff,
7
+ extractComputedStyles,
8
+ LAYOUT_PROPERTIES,
9
+ VISUAL_PROPERTIES,
10
+ TYPOGRAPHY_PROPERTIES,
11
+ INTERACTION_PROPERTIES,
12
+ SVG_PROPERTIES,
13
+ StyleSnapshot,
14
+ } from "./extractComputedStyles";
15
+ import { getElementLabel } from "./formatAncestryChain";
16
+
17
+ describe("extractComputedStyles", () => {
18
+ describe("collapseFourValues", () => {
19
+ it("collapses when all four values are the same", () => {
20
+ expect(_collapseFourValues("8px", "8px", "8px", "8px")).toBe("8px");
21
+ });
22
+
23
+ it("collapses to two values when top=bottom and left=right", () => {
24
+ expect(_collapseFourValues("8px", "16px", "8px", "16px")).toBe(
25
+ "8px 16px"
26
+ );
27
+ });
28
+
29
+ it("collapses to three values when left=right", () => {
30
+ expect(_collapseFourValues("8px", "16px", "4px", "16px")).toBe(
31
+ "8px 16px 4px"
32
+ );
33
+ });
34
+
35
+ it("outputs all four when all different", () => {
36
+ expect(_collapseFourValues("1px", "2px", "3px", "4px")).toBe(
37
+ "1px 2px 3px 4px"
38
+ );
39
+ });
40
+
41
+ it("handles 0px values", () => {
42
+ expect(_collapseFourValues("0px", "0px", "0px", "0px")).toBe("0px");
43
+ });
44
+ });
45
+
46
+ describe("processGroupEntries", () => {
47
+ const alwaysNonDefault = (_prop: string, value: string) =>
48
+ value !== "" && value !== "default";
49
+
50
+ it("collapses margin into shorthand when 2+ sides are non-default", () => {
51
+ const values: Record<string, string> = {
52
+ "margin-top": "10px",
53
+ "margin-right": "20px",
54
+ "margin-bottom": "10px",
55
+ "margin-left": "20px",
56
+ };
57
+ const entries = _processGroupEntries(
58
+ LAYOUT_PROPERTIES,
59
+ values,
60
+ alwaysNonDefault
61
+ );
62
+ const marginEntry = entries.find((e) => e.name === "margin");
63
+ expect(marginEntry).toBeDefined();
64
+ expect(marginEntry!.value).toBe("10px 20px");
65
+ // No individual margin-* entries
66
+ expect(entries.find((e) => e.name === "margin-top")).toBeUndefined();
67
+ });
68
+
69
+ it("leaves single margin longhand when only one is non-default", () => {
70
+ const isNonDefault = (prop: string, value: string) => {
71
+ if (prop === "margin-top" && value === "10px") return true;
72
+ return false;
73
+ };
74
+ const values: Record<string, string> = {
75
+ "margin-top": "10px",
76
+ "margin-right": "0px",
77
+ "margin-bottom": "0px",
78
+ "margin-left": "0px",
79
+ };
80
+ const entries = _processGroupEntries(
81
+ LAYOUT_PROPERTIES,
82
+ values,
83
+ isNonDefault
84
+ );
85
+ expect(entries.find((e) => e.name === "margin")).toBeUndefined();
86
+ expect(entries.find((e) => e.name === "margin-top")).toBeDefined();
87
+ });
88
+
89
+ it("collapses padding into shorthand", () => {
90
+ const values: Record<string, string> = {
91
+ "padding-top": "8px",
92
+ "padding-right": "16px",
93
+ "padding-bottom": "8px",
94
+ "padding-left": "16px",
95
+ };
96
+ const entries = _processGroupEntries(
97
+ LAYOUT_PROPERTIES,
98
+ values,
99
+ alwaysNonDefault
100
+ );
101
+ const paddingEntry = entries.find((e) => e.name === "padding");
102
+ expect(paddingEntry).toBeDefined();
103
+ expect(paddingEntry!.value).toBe("8px 16px");
104
+ });
105
+
106
+ it("collapses overflow when both axes are the same", () => {
107
+ const values: Record<string, string> = {
108
+ "overflow-x": "hidden",
109
+ "overflow-y": "hidden",
110
+ };
111
+ const entries = _processGroupEntries(
112
+ LAYOUT_PROPERTIES,
113
+ values,
114
+ alwaysNonDefault
115
+ );
116
+ const overflowEntry = entries.find((e) => e.name === "overflow");
117
+ expect(overflowEntry).toBeDefined();
118
+ expect(overflowEntry!.value).toBe("hidden");
119
+ });
120
+
121
+ it("collapses overflow when axes differ", () => {
122
+ const values: Record<string, string> = {
123
+ "overflow-x": "hidden",
124
+ "overflow-y": "scroll",
125
+ };
126
+ const entries = _processGroupEntries(
127
+ LAYOUT_PROPERTIES,
128
+ values,
129
+ alwaysNonDefault
130
+ );
131
+ const overflowEntry = entries.find((e) => e.name === "overflow");
132
+ expect(overflowEntry).toBeDefined();
133
+ expect(overflowEntry!.value).toBe("hidden scroll");
134
+ });
135
+
136
+ it("collapses flex shorthand when 2+ are non-default", () => {
137
+ const values: Record<string, string> = {
138
+ "flex-grow": "1",
139
+ "flex-shrink": "0",
140
+ "flex-basis": "auto",
141
+ };
142
+ const entries = _processGroupEntries(
143
+ LAYOUT_PROPERTIES,
144
+ values,
145
+ alwaysNonDefault
146
+ );
147
+ const flexEntry = entries.find((e) => e.name === "flex");
148
+ expect(flexEntry).toBeDefined();
149
+ expect(flexEntry!.value).toBe("1 0 auto");
150
+ });
151
+
152
+ it("collapses uniform border into shorthand", () => {
153
+ const values: Record<string, string> = {
154
+ "border-top-width": "1px",
155
+ "border-right-width": "1px",
156
+ "border-bottom-width": "1px",
157
+ "border-left-width": "1px",
158
+ "border-top-style": "solid",
159
+ "border-right-style": "solid",
160
+ "border-bottom-style": "solid",
161
+ "border-left-style": "solid",
162
+ "border-top-color": "rgb(0, 0, 0)",
163
+ "border-right-color": "rgb(0, 0, 0)",
164
+ "border-bottom-color": "rgb(0, 0, 0)",
165
+ "border-left-color": "rgb(0, 0, 0)",
166
+ };
167
+ const entries = _processGroupEntries(
168
+ VISUAL_PROPERTIES,
169
+ values,
170
+ alwaysNonDefault
171
+ );
172
+ const borderEntry = entries.find((e) => e.name === "border");
173
+ expect(borderEntry).toBeDefined();
174
+ expect(borderEntry!.value).toBe("1px solid rgb(0, 0, 0)");
175
+ });
176
+
177
+ it("uses per-side border shorthands for non-uniform borders", () => {
178
+ const values: Record<string, string> = {
179
+ "border-top-width": "1px",
180
+ "border-right-width": "0px",
181
+ "border-bottom-width": "2px",
182
+ "border-left-width": "0px",
183
+ "border-top-style": "solid",
184
+ "border-right-style": "none",
185
+ "border-bottom-style": "solid",
186
+ "border-left-style": "none",
187
+ "border-top-color": "rgb(0, 0, 0)",
188
+ "border-right-color": "rgb(0, 0, 0)",
189
+ "border-bottom-color": "rgb(255, 0, 0)",
190
+ "border-left-color": "rgb(0, 0, 0)",
191
+ };
192
+ const entries = _processGroupEntries(
193
+ VISUAL_PROPERTIES,
194
+ values,
195
+ alwaysNonDefault
196
+ );
197
+ expect(entries.find((e) => e.name === "border")).toBeUndefined();
198
+ expect(entries.find((e) => e.name === "border-top")).toBeDefined();
199
+ expect(entries.find((e) => e.name === "border-bottom")).toBeDefined();
200
+ // Right and left have style: none, so they're skipped
201
+ expect(entries.find((e) => e.name === "border-right")).toBeUndefined();
202
+ expect(entries.find((e) => e.name === "border-left")).toBeUndefined();
203
+ });
204
+
205
+ it("collapses border-radius into shorthand", () => {
206
+ const values: Record<string, string> = {
207
+ "border-top-left-radius": "6px",
208
+ "border-top-right-radius": "6px",
209
+ "border-bottom-right-radius": "6px",
210
+ "border-bottom-left-radius": "6px",
211
+ };
212
+ const entries = _processGroupEntries(
213
+ VISUAL_PROPERTIES,
214
+ values,
215
+ alwaysNonDefault
216
+ );
217
+ const radiusEntry = entries.find((e) => e.name === "border-radius");
218
+ expect(radiusEntry).toBeDefined();
219
+ expect(radiusEntry!.value).toBe("6px");
220
+ });
221
+
222
+ it("collapses outline into shorthand", () => {
223
+ const values: Record<string, string> = {
224
+ "outline-width": "2px",
225
+ "outline-style": "solid",
226
+ "outline-color": "rgb(59, 130, 246)",
227
+ };
228
+ const entries = _processGroupEntries(
229
+ VISUAL_PROPERTIES,
230
+ values,
231
+ alwaysNonDefault
232
+ );
233
+ const outlineEntry = entries.find((e) => e.name === "outline");
234
+ expect(outlineEntry).toBeDefined();
235
+ expect(outlineEntry!.value).toBe("2px solid rgb(59, 130, 246)");
236
+ });
237
+
238
+ it("skips outline when style is none", () => {
239
+ const values: Record<string, string> = {
240
+ "outline-width": "0px",
241
+ "outline-style": "none",
242
+ "outline-color": "rgb(0, 0, 0)",
243
+ };
244
+ const entries = _processGroupEntries(
245
+ VISUAL_PROPERTIES,
246
+ values,
247
+ alwaysNonDefault
248
+ );
249
+ expect(entries.find((e) => e.name === "outline")).toBeUndefined();
250
+ // Individual longhands should also be consumed
251
+ expect(entries.find((e) => e.name === "outline-width")).toBeUndefined();
252
+ });
253
+
254
+ it("outputs individual properties that are not part of shorthands", () => {
255
+ const values: Record<string, string> = {
256
+ display: "flex",
257
+ position: "relative",
258
+ };
259
+ const entries = _processGroupEntries(
260
+ LAYOUT_PROPERTIES,
261
+ values,
262
+ alwaysNonDefault
263
+ );
264
+ expect(entries.find((e) => e.name === "display")).toBeDefined();
265
+ expect(entries.find((e) => e.name === "position")).toBeDefined();
266
+ });
267
+
268
+ it("skips properties where isNonDefault returns false", () => {
269
+ const neverNonDefault = () => false;
270
+ const values: Record<string, string> = {
271
+ display: "block",
272
+ position: "static",
273
+ };
274
+ const entries = _processGroupEntries(
275
+ LAYOUT_PROPERTIES,
276
+ values,
277
+ neverNonDefault
278
+ );
279
+ expect(entries).toEqual([]);
280
+ });
281
+
282
+ it("skips properties with empty values", () => {
283
+ const values: Record<string, string> = {
284
+ display: "",
285
+ position: "",
286
+ };
287
+ const entries = _processGroupEntries(
288
+ LAYOUT_PROPERTIES,
289
+ values,
290
+ alwaysNonDefault
291
+ );
292
+ expect(entries).toEqual([]);
293
+ });
294
+ });
295
+
296
+ describe("formatDiff", () => {
297
+ it("shows changed properties with arrow notation", () => {
298
+ const prev: StyleSnapshot = {
299
+ properties: { opacity: "1", "background-color": "transparent" },
300
+ boundingRect: {
301
+ x: 0,
302
+ y: 0,
303
+ width: 100,
304
+ height: 40,
305
+ top: 0,
306
+ right: 100,
307
+ bottom: 40,
308
+ left: 0,
309
+ },
310
+ };
311
+ const curr: StyleSnapshot = {
312
+ properties: { opacity: "0.8", "background-color": "rgb(59, 130, 246)" },
313
+ boundingRect: {
314
+ x: 0,
315
+ y: 0,
316
+ width: 100,
317
+ height: 40,
318
+ top: 0,
319
+ right: 100,
320
+ bottom: 40,
321
+ left: 0,
322
+ },
323
+ };
324
+
325
+ const result = _formatDiff(prev, curr, "Button");
326
+ expect(result).toContain("[ComputedStyles \u0394] Button");
327
+ expect(result).toContain("~ opacity: 1 \u2192 0.8");
328
+ expect(result).toContain(
329
+ "~ background-color: transparent \u2192 rgb(59, 130, 246)"
330
+ );
331
+ });
332
+
333
+ it("shows added properties with + prefix", () => {
334
+ const prev: StyleSnapshot = {
335
+ properties: { opacity: "1" },
336
+ boundingRect: {
337
+ x: 0,
338
+ y: 0,
339
+ width: 100,
340
+ height: 40,
341
+ top: 0,
342
+ right: 100,
343
+ bottom: 40,
344
+ left: 0,
345
+ },
346
+ };
347
+ const curr: StyleSnapshot = {
348
+ properties: {
349
+ opacity: "1",
350
+ "box-shadow": "0 2px 4px rgba(0,0,0,0.1)",
351
+ },
352
+ boundingRect: {
353
+ x: 0,
354
+ y: 0,
355
+ width: 100,
356
+ height: 40,
357
+ top: 0,
358
+ right: 100,
359
+ bottom: 40,
360
+ left: 0,
361
+ },
362
+ };
363
+
364
+ const result = _formatDiff(prev, curr, undefined);
365
+ expect(result).toContain("+ box-shadow: 0 2px 4px rgba(0,0,0,0.1)");
366
+ });
367
+
368
+ it("shows removed properties with - prefix", () => {
369
+ const prev: StyleSnapshot = {
370
+ properties: { transform: "scale(1.05)" },
371
+ boundingRect: {
372
+ x: 0,
373
+ y: 0,
374
+ width: 100,
375
+ height: 40,
376
+ top: 0,
377
+ right: 100,
378
+ bottom: 40,
379
+ left: 0,
380
+ },
381
+ };
382
+ const curr: StyleSnapshot = {
383
+ properties: {},
384
+ boundingRect: {
385
+ x: 0,
386
+ y: 0,
387
+ width: 100,
388
+ height: 40,
389
+ top: 0,
390
+ right: 100,
391
+ bottom: 40,
392
+ left: 0,
393
+ },
394
+ };
395
+
396
+ const result = _formatDiff(prev, curr, undefined);
397
+ expect(result).toContain("- transform: scale(1.05)");
398
+ });
399
+
400
+ it("shows bounding rect changes", () => {
401
+ const prev: StyleSnapshot = {
402
+ properties: {},
403
+ boundingRect: {
404
+ x: 100,
405
+ y: 200,
406
+ width: 120,
407
+ height: 40,
408
+ top: 200,
409
+ right: 220,
410
+ bottom: 240,
411
+ left: 100,
412
+ },
413
+ };
414
+ const curr: StyleSnapshot = {
415
+ properties: {},
416
+ boundingRect: {
417
+ x: 150,
418
+ y: 200,
419
+ width: 120,
420
+ height: 50,
421
+ top: 200,
422
+ right: 270,
423
+ bottom: 250,
424
+ left: 150,
425
+ },
426
+ };
427
+
428
+ const result = _formatDiff(prev, curr, undefined);
429
+ expect(result).toContain("BoundingRect");
430
+ expect(result).toContain("~ x: 100 \u2192 150");
431
+ expect(result).toContain("~ height: 40 \u2192 50");
432
+ });
433
+
434
+ it("shows 'No changes detected' when nothing changed", () => {
435
+ const snapshot: StyleSnapshot = {
436
+ properties: { opacity: "1" },
437
+ boundingRect: {
438
+ x: 0,
439
+ y: 0,
440
+ width: 100,
441
+ height: 40,
442
+ top: 0,
443
+ right: 100,
444
+ bottom: 40,
445
+ left: 0,
446
+ },
447
+ };
448
+ const result = _formatDiff(snapshot, snapshot, undefined);
449
+ expect(result).toContain("No changes detected");
450
+ });
451
+ });
452
+
453
+ describe("property group completeness", () => {
454
+ it("has no duplicates within property groups", () => {
455
+ const allProps = [
456
+ ...LAYOUT_PROPERTIES,
457
+ ...VISUAL_PROPERTIES,
458
+ ...TYPOGRAPHY_PROPERTIES,
459
+ ...INTERACTION_PROPERTIES,
460
+ ...SVG_PROPERTIES,
461
+ ];
462
+ const seen = new Set<string>();
463
+ const dupes: string[] = [];
464
+ for (const prop of allProps) {
465
+ if (seen.has(prop)) dupes.push(prop);
466
+ seen.add(prop);
467
+ }
468
+ expect(dupes).toEqual([]);
469
+ });
470
+
471
+ it("includes key layout properties", () => {
472
+ expect(LAYOUT_PROPERTIES).toContain("display");
473
+ expect(LAYOUT_PROPERTIES).toContain("position");
474
+ expect(LAYOUT_PROPERTIES).toContain("width");
475
+ expect(LAYOUT_PROPERTIES).toContain("height");
476
+ expect(LAYOUT_PROPERTIES).toContain("flex-direction");
477
+ expect(LAYOUT_PROPERTIES).toContain("grid-template-columns");
478
+ });
479
+
480
+ it("includes key visual properties", () => {
481
+ expect(VISUAL_PROPERTIES).toContain("background-color");
482
+ expect(VISUAL_PROPERTIES).toContain("color");
483
+ expect(VISUAL_PROPERTIES).toContain("opacity");
484
+ expect(VISUAL_PROPERTIES).toContain("box-shadow");
485
+ expect(VISUAL_PROPERTIES).toContain("transform");
486
+ });
487
+
488
+ it("includes key typography properties", () => {
489
+ expect(TYPOGRAPHY_PROPERTIES).toContain("font-family");
490
+ expect(TYPOGRAPHY_PROPERTIES).toContain("font-size");
491
+ expect(TYPOGRAPHY_PROPERTIES).toContain("font-weight");
492
+ expect(TYPOGRAPHY_PROPERTIES).toContain("line-height");
493
+ });
494
+
495
+ it("includes SVG-specific properties", () => {
496
+ expect(SVG_PROPERTIES).toContain("fill");
497
+ expect(SVG_PROPERTIES).toContain("stroke");
498
+ expect(SVG_PROPERTIES).toContain("stroke-width");
499
+ });
500
+ });
501
+
502
+ describe("forceFull option", () => {
503
+ it("returns full styles on repeat call within diff window when forceFull is true", () => {
504
+ const el = document.createElement("div");
505
+ document.body.appendChild(el);
506
+ try {
507
+ // First call primes the snapshot
508
+ const first = extractComputedStyles(el, "Button");
509
+ expect(first.formatted).toContain("[ComputedStyles]");
510
+ expect(first.formatted).not.toContain("\u0394"); // no diff marker
511
+
512
+ // Second call without forceFull enters diff mode
513
+ const second = extractComputedStyles(el, "Button");
514
+ expect(second.formatted).toContain("\u0394");
515
+
516
+ // Third call with forceFull bypasses diff mode
517
+ const third = extractComputedStyles(el, "Button", { forceFull: true });
518
+ expect(third.formatted).toContain("[ComputedStyles]");
519
+ expect(third.formatted).not.toContain("\u0394");
520
+ expect(third.formatted).toContain("BoundingRect");
521
+ } finally {
522
+ el.remove();
523
+ }
524
+ });
525
+
526
+ it("still updates lastSnapshot so subsequent calls can diff against the forced-full snapshot", () => {
527
+ const el = document.createElement("div");
528
+ document.body.appendChild(el);
529
+ try {
530
+ extractComputedStyles(el);
531
+ extractComputedStyles(el, undefined, { forceFull: true });
532
+ // Next call should be a diff against the forced-full snapshot,
533
+ // not the original one.
534
+ const after = extractComputedStyles(el);
535
+ expect(after.formatted).toContain("\u0394");
536
+ } finally {
537
+ el.remove();
538
+ }
539
+ });
540
+ });
541
+
542
+ describe("includeDefaults option", () => {
543
+ it("includes curated properties even when they match the tag defaults", () => {
544
+ const heading = document.createElement("h1");
545
+ document.body.appendChild(heading);
546
+
547
+ const makeStyle = (values: Record<string, string>) =>
548
+ ({
549
+ getPropertyValue: (prop: string) => values[prop] ?? "",
550
+ }) as CSSStyleDeclaration;
551
+
552
+ const headingStyles = {
553
+ display: "block",
554
+ "font-weight": "700",
555
+ "font-size": "51.2px",
556
+ "line-height": "56.32px",
557
+ };
558
+
559
+ const getComputedStyleSpy = vi
560
+ .spyOn(window, "getComputedStyle")
561
+ .mockImplementation((node: Element) => {
562
+ if (node === heading) {
563
+ return makeStyle(headingStyles);
564
+ }
565
+
566
+ if (node.tagName === "H1") {
567
+ return makeStyle(headingStyles);
568
+ }
569
+
570
+ return makeStyle({});
571
+ });
572
+
573
+ try {
574
+ const result = extractComputedStyles(heading, "App heading", {
575
+ forceFull: true,
576
+ includeDefaults: true,
577
+ });
578
+
579
+ expect(result.formatted).toContain("Layout");
580
+ expect(result.formatted).toContain("display: block");
581
+ expect(result.formatted).toContain("Typography");
582
+ expect(result.formatted).toContain("font-weight: 700");
583
+ expect(result.formatted).toContain("font-size: 51.2px");
584
+ } finally {
585
+ getComputedStyleSpy.mockRestore();
586
+ heading.remove();
587
+ }
588
+ });
589
+ });
590
+
591
+ describe("default filtering", () => {
592
+ it("keeps inherited typography when defaults are probed in an isolated shadow root", () => {
593
+ const link = document.createElement("a");
594
+ document.body.appendChild(link);
595
+
596
+ const makeStyle = (values: Record<string, string>) =>
597
+ ({
598
+ getPropertyValue: (prop: string) => values[prop] ?? "",
599
+ }) as CSSStyleDeclaration;
600
+
601
+ const inheritedTypography = {
602
+ "font-family": '"Fira Code", monospace',
603
+ "font-size": "21px",
604
+ "font-style": "italic",
605
+ "font-weight": "700",
606
+ };
607
+
608
+ const defaultTypography = {
609
+ "font-family": "Times",
610
+ "font-size": "16px",
611
+ "font-style": "normal",
612
+ "font-weight": "400",
613
+ };
614
+
615
+ const getComputedStyleSpy = vi
616
+ .spyOn(window, "getComputedStyle")
617
+ .mockImplementation((node: Element) => {
618
+ if (node === link) {
619
+ return makeStyle(inheritedTypography);
620
+ }
621
+
622
+ if (node.tagName === "A") {
623
+ return makeStyle(
624
+ node.getRootNode() instanceof ShadowRoot
625
+ ? defaultTypography
626
+ : inheritedTypography
627
+ );
628
+ }
629
+
630
+ return makeStyle({});
631
+ });
632
+
633
+ try {
634
+ const result = extractComputedStyles(link, "NavItem", {
635
+ forceFull: true,
636
+ });
637
+
638
+ expect(result.formatted).toContain("Typography");
639
+ expect(result.formatted).toMatch(/font-family: .*monospace/i);
640
+ expect(result.formatted).toContain("font-size: 21px");
641
+ expect(result.formatted).toContain("font-style: italic");
642
+ expect(result.formatted).toContain("font-weight: 700");
643
+ } finally {
644
+ getComputedStyleSpy.mockRestore();
645
+ link.remove();
646
+ }
647
+ });
648
+ });
649
+
650
+ describe("getElementLabel", () => {
651
+ it("returns empty string for empty ancestry", () => {
652
+ expect(getElementLabel([])).toBe("");
653
+ });
654
+
655
+ it("returns component name with file location", () => {
656
+ const label = getElementLabel([
657
+ {
658
+ elementName: "button",
659
+ componentName: "SubmitButton",
660
+ filePath: "src/components/SubmitButton.tsx",
661
+ line: 12,
662
+ },
663
+ ]);
664
+ expect(label).toBe("SubmitButton at src/components/SubmitButton.tsx:12");
665
+ });
666
+
667
+ it("falls back to element name when no component name is set", () => {
668
+ const label = getElementLabel([
669
+ { elementName: "div", filePath: "src/App.tsx", line: 5 },
670
+ ]);
671
+ expect(label).toBe("div at src/App.tsx:5");
672
+ });
673
+
674
+ it("omits location when filePath is missing", () => {
675
+ const label = getElementLabel([
676
+ { elementName: "span", componentName: "Label" },
677
+ ]);
678
+ expect(label).toBe("Label");
679
+ });
680
+ });
681
+ });