@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,439 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
5
+ import { calculateSpecificity, compareSpecificity, formatSpecificity, describeElement, formatCSSInspection, inspectCSSRules } from "./cssRuleInspector";
6
+ describe("calculateSpecificity", () => {
7
+ it("counts element selectors", () => {
8
+ expect(calculateSpecificity("div")).toEqual([0, 0, 1]);
9
+ expect(calculateSpecificity("div span")).toEqual([0, 0, 2]);
10
+ expect(calculateSpecificity("ul li a")).toEqual([0, 0, 3]);
11
+ });
12
+ it("counts class selectors", () => {
13
+ expect(calculateSpecificity(".button")).toEqual([0, 1, 0]);
14
+ expect(calculateSpecificity(".button.primary")).toEqual([0, 2, 0]);
15
+ });
16
+ it("counts ID selectors", () => {
17
+ expect(calculateSpecificity("#main")).toEqual([1, 0, 0]);
18
+ expect(calculateSpecificity("#main #sidebar")).toEqual([2, 0, 0]);
19
+ });
20
+ it("counts mixed selectors", () => {
21
+ expect(calculateSpecificity("div.button")).toEqual([0, 1, 1]);
22
+ expect(calculateSpecificity("#main .button")).toEqual([1, 1, 0]);
23
+ expect(calculateSpecificity("#main div.button")).toEqual([1, 1, 1]);
24
+ });
25
+ it("counts attribute selectors as class-level", () => {
26
+ expect(calculateSpecificity("[type=submit]")).toEqual([0, 1, 0]);
27
+ expect(calculateSpecificity("input[type=submit]")).toEqual([0, 1, 1]);
28
+ });
29
+ it("counts pseudo-classes as class-level", () => {
30
+ expect(calculateSpecificity(":hover")).toEqual([0, 1, 0]);
31
+ expect(calculateSpecificity("a:hover")).toEqual([0, 1, 1]);
32
+ expect(calculateSpecificity("a:first-child")).toEqual([0, 1, 1]);
33
+ });
34
+ it("counts pseudo-elements as element-level", () => {
35
+ expect(calculateSpecificity("::before")).toEqual([0, 0, 1]);
36
+ expect(calculateSpecificity("p::first-line")).toEqual([0, 0, 2]);
37
+ });
38
+ it("normalizes legacy single-colon pseudo-elements to element-level", () => {
39
+ // :before, :after, :first-line, :first-letter are legacy pseudo-elements
40
+ // that should be treated as element-level (0,0,1), not pseudo-class (0,1,0)
41
+ expect(calculateSpecificity(":before")).toEqual([0, 0, 1]);
42
+ expect(calculateSpecificity(":after")).toEqual([0, 0, 1]);
43
+ expect(calculateSpecificity("p:first-line")).toEqual([0, 0, 2]);
44
+ expect(calculateSpecificity("p:first-letter")).toEqual([0, 0, 2]);
45
+ });
46
+ it("handles child and sibling combinators", () => {
47
+ expect(calculateSpecificity("div > span")).toEqual([0, 0, 2]);
48
+ expect(calculateSpecificity("div + span")).toEqual([0, 0, 2]);
49
+ expect(calculateSpecificity("div ~ span")).toEqual([0, 0, 2]);
50
+ });
51
+ it("ignores universal selector", () => {
52
+ expect(calculateSpecificity("*")).toEqual([0, 0, 0]);
53
+ expect(calculateSpecificity("*.button")).toEqual([0, 1, 0]);
54
+ });
55
+ it("handles comma-separated selectors by returning max", () => {
56
+ // .a has specificity (0,1,0), #b has (1,0,0) — should return (1,0,0)
57
+ expect(calculateSpecificity(".a, #b")).toEqual([1, 0, 0]);
58
+ expect(calculateSpecificity("div, .button, #main")).toEqual([1, 0, 0]);
59
+ });
60
+ it("handles :not() by counting its argument", () => {
61
+ // :not(.disabled) = (0,1,0) + the :not itself is not counted
62
+ expect(calculateSpecificity(":not(.disabled)")).toEqual([0, 1, 0]);
63
+ expect(calculateSpecificity("div:not(.hidden)")).toEqual([0, 1, 1]);
64
+ });
65
+ it("handles :is() by counting its argument", () => {
66
+ expect(calculateSpecificity(":is(.a, .b)")).toEqual([0, 1, 0]);
67
+ });
68
+ it("handles :where() as zero specificity", () => {
69
+ expect(calculateSpecificity(":where(.a, .b)")).toEqual([0, 0, 0]);
70
+ expect(calculateSpecificity("div:where(.a)")).toEqual([0, 0, 1]);
71
+ });
72
+ it("handles complex real-world selectors", () => {
73
+ // Bootstrap-style: .btn-group > .btn:not(:first-child)
74
+ // .btn-group = (0,1,0), .btn = (0,1,0), :not(:first-child) -> :first-child = (0,1,0)
75
+ // Total: (0,3,0) ... but we also don't count > combinator
76
+ // Actually let me recalculate: .btn-group > .btn:not(:first-child)
77
+ // .btn-group = class (0,1,0)
78
+ // .btn = class (0,1,0)
79
+ // :not(:first-child) = argument :first-child = pseudo-class (0,1,0)
80
+ // Total = (0,3,0)
81
+ expect(calculateSpecificity(".btn-group > .btn:not(:first-child)")).toEqual([0, 3, 0]);
82
+ });
83
+ describe("nested functional pseudos", () => {
84
+ it("handles :not(:is(.a, .b))", () => {
85
+ // :is(.a, .b) = max(.a, .b) = (0,1,0)
86
+ // :not(:is(.a, .b)) = (0,1,0)
87
+ expect(calculateSpecificity(":not(:is(.a, .b))")).toEqual([0, 1, 0]);
88
+ });
89
+ it("handles :not(:not(.a))", () => {
90
+ // Inner :not(.a) = (0,1,0), outer :not(:not(.a)) = (0,1,0)
91
+ expect(calculateSpecificity(":not(:not(.a))")).toEqual([0, 1, 0]);
92
+ });
93
+ it("handles deeply nested :is and :where", () => {
94
+ // :is(:where(.a), .b) — :where(.a) = 0, .b = (0,1,0), max = (0,1,0)
95
+ expect(calculateSpecificity(":is(:where(.a), .b)")).toEqual([0, 1, 0]);
96
+ });
97
+ it("handles :has(:is(.a, [data-x=\"a,b\"]))", () => {
98
+ // .a = (0,1,0), [data-x="a,b"] = (0,1,0), max = (0,1,0)
99
+ // :has() takes the spec of its argument
100
+ expect(calculateSpecificity(':has(:is(.a, [data-x="a,b"]))')).toEqual([0, 1, 0]);
101
+ });
102
+ });
103
+ describe("attribute selectors with embedded specials", () => {
104
+ it("handles attribute value containing a comma", () => {
105
+ // [data-value="a,b"] = single attribute selector = (0,1,0)
106
+ expect(calculateSpecificity('[data-value="a,b"]')).toEqual([0, 1, 0]);
107
+ });
108
+ it("handles attribute value containing brackets and dots", () => {
109
+ // [data-x=".a#b"] should still be one attribute selector = (0,1,0)
110
+ // The dot and # inside the string must NOT be counted as class/id.
111
+ expect(calculateSpecificity('[data-x=".a#b"]')).toEqual([0, 1, 0]);
112
+ });
113
+ it("handles multiple attribute selectors", () => {
114
+ expect(calculateSpecificity('[type="submit"][disabled]')).toEqual([0, 2, 0]);
115
+ });
116
+ });
117
+ describe("comma splitting with nested specials", () => {
118
+ it("does not split inside parens", () => {
119
+ // :is(.a, .b) is a single branch, not two
120
+ expect(calculateSpecificity(":is(.a, .b), #c")).toEqual([1, 0, 0]);
121
+ });
122
+ it("does not split inside attribute brackets", () => {
123
+ // [data-x="a,b"] is a single branch
124
+ expect(calculateSpecificity('[data-x="a,b"], #c')).toEqual([1, 0, 0]);
125
+ });
126
+ });
127
+ });
128
+ describe("compareSpecificity", () => {
129
+ it("returns positive when first is higher", () => {
130
+ expect(compareSpecificity([1, 0, 0], [0, 10, 10])).toBeGreaterThan(0);
131
+ expect(compareSpecificity([0, 2, 0], [0, 1, 5])).toBeGreaterThan(0);
132
+ expect(compareSpecificity([0, 0, 3], [0, 0, 2])).toBeGreaterThan(0);
133
+ });
134
+ it("returns negative when first is lower", () => {
135
+ expect(compareSpecificity([0, 0, 1], [0, 1, 0])).toBeLessThan(0);
136
+ expect(compareSpecificity([0, 1, 0], [1, 0, 0])).toBeLessThan(0);
137
+ });
138
+ it("returns zero when equal", () => {
139
+ expect(compareSpecificity([0, 1, 1], [0, 1, 1])).toBe(0);
140
+ expect(compareSpecificity([1, 0, 0], [1, 0, 0])).toBe(0);
141
+ });
142
+ });
143
+ describe("formatSpecificity", () => {
144
+ it("formats tuple as readable string", () => {
145
+ expect(formatSpecificity([0, 1, 2])).toBe("(0,1,2)");
146
+ expect(formatSpecificity([1, 0, 0])).toBe("(1,0,0)");
147
+ expect(formatSpecificity([0, 0, 0])).toBe("(0,0,0)");
148
+ });
149
+ });
150
+ describe("describeElement", () => {
151
+ it("describes a plain element", () => {
152
+ const el = document.createElement("div");
153
+ expect(describeElement(el)).toBe("div");
154
+ });
155
+ it("includes id", () => {
156
+ const el = document.createElement("div");
157
+ el.id = "main";
158
+ expect(describeElement(el)).toBe("div#main");
159
+ });
160
+ it("includes classes", () => {
161
+ const el = document.createElement("button");
162
+ el.classList.add("btn", "primary");
163
+ expect(describeElement(el)).toBe("button.btn.primary");
164
+ });
165
+ it("includes both id and classes", () => {
166
+ const el = document.createElement("button");
167
+ el.id = "submit";
168
+ el.classList.add("btn", "primary");
169
+ expect(describeElement(el)).toBe("button#submit.btn.primary");
170
+ });
171
+ });
172
+ describe("formatCSSInspection", () => {
173
+ it("formats empty result", () => {
174
+ const result = {
175
+ element: "div",
176
+ properties: [],
177
+ unreachableSheets: []
178
+ };
179
+ const formatted = formatCSSInspection(result);
180
+ expect(formatted).toContain("CSS Rules for div");
181
+ expect(formatted).toContain("No matching CSS rules found.");
182
+ });
183
+ it("formats properties with winning/losing rules", () => {
184
+ const result = {
185
+ element: "button.primary",
186
+ properties: [{
187
+ property: "color",
188
+ value: "rgb(51, 51, 51)",
189
+ rules: [{
190
+ selector: ".button.primary",
191
+ value: "#333",
192
+ specificity: [0, 2, 0],
193
+ important: false,
194
+ source: "components.css",
195
+ winning: true
196
+ }, {
197
+ selector: ".button",
198
+ value: "#666",
199
+ specificity: [0, 1, 0],
200
+ important: false,
201
+ source: "base.css",
202
+ winning: false
203
+ }]
204
+ }],
205
+ unreachableSheets: []
206
+ };
207
+ const formatted = formatCSSInspection(result);
208
+ expect(formatted).toContain("CSS Rules for button.primary");
209
+ expect(formatted).toContain("color: rgb(51, 51, 51)");
210
+ expect(formatted).toContain("✓ .button.primary");
211
+ expect(formatted).toContain("(0,2,0)");
212
+ expect(formatted).toContain("✗ .button");
213
+ expect(formatted).toContain("(0,1,0)");
214
+ expect(formatted).toContain("[#666]"); // losing rule shows its value
215
+ });
216
+ it("shows !important flag", () => {
217
+ const result = {
218
+ element: "div",
219
+ properties: [{
220
+ property: "display",
221
+ value: "none",
222
+ rules: [{
223
+ selector: ".hidden",
224
+ value: "none",
225
+ specificity: [0, 1, 0],
226
+ important: true,
227
+ source: "utils.css",
228
+ winning: true
229
+ }]
230
+ }],
231
+ unreachableSheets: []
232
+ };
233
+ const formatted = formatCSSInspection(result);
234
+ expect(formatted).toContain("!important");
235
+ });
236
+ it("shows cross-origin warning", () => {
237
+ const result = {
238
+ element: "div",
239
+ properties: [],
240
+ unreachableSheets: ["cdn-styles.css", "external.css"]
241
+ };
242
+ const formatted = formatCSSInspection(result);
243
+ expect(formatted).toContain("Cross-origin");
244
+ expect(formatted).toContain("cdn-styles.css");
245
+ expect(formatted).toContain("external.css");
246
+ });
247
+ it("formats with source-order tie breaking", () => {
248
+ // Two rules with identical specificity — second should win because it
249
+ // appears later in the source order.
250
+ const result = {
251
+ element: "div",
252
+ properties: [{
253
+ property: "color",
254
+ value: "blue",
255
+ rules: [
256
+ // Already sorted by inspectCSSRules — winner first.
257
+ {
258
+ selector: ".later",
259
+ value: "blue",
260
+ specificity: [0, 1, 0],
261
+ important: false,
262
+ source: "later.css",
263
+ winning: true
264
+ }, {
265
+ selector: ".earlier",
266
+ value: "red",
267
+ specificity: [0, 1, 0],
268
+ important: false,
269
+ source: "earlier.css",
270
+ winning: false
271
+ }]
272
+ }],
273
+ unreachableSheets: []
274
+ };
275
+ const formatted = formatCSSInspection(result);
276
+ expect(formatted).toContain("✓ .later");
277
+ expect(formatted).toContain("✗ .earlier");
278
+ });
279
+ it("shows inline source for inline rules", () => {
280
+ const result = {
281
+ element: "div",
282
+ properties: [{
283
+ property: "color",
284
+ value: "red",
285
+ rules: [{
286
+ selector: "element.style",
287
+ value: "red",
288
+ specificity: [1, 0, 0],
289
+ important: false,
290
+ source: "inline",
291
+ winning: true
292
+ }]
293
+ }],
294
+ unreachableSheets: []
295
+ };
296
+ const formatted = formatCSSInspection(result);
297
+ expect(formatted).toContain("(inline)");
298
+ expect(formatted).toContain("— inline");
299
+ });
300
+ });
301
+ describe("inspectCSSRules (integration)", () => {
302
+ let styleEl = null;
303
+ let testEl = null;
304
+ beforeEach(() => {
305
+ styleEl = document.createElement("style");
306
+ document.head.appendChild(styleEl);
307
+ });
308
+ afterEach(() => {
309
+ if (styleEl?.parentNode) styleEl.parentNode.removeChild(styleEl);
310
+ if (testEl?.parentNode) testEl.parentNode.removeChild(testEl);
311
+ styleEl = null;
312
+ testEl = null;
313
+ });
314
+ function setStyles(css) {
315
+ styleEl.textContent = css;
316
+ }
317
+
318
+ /**
319
+ * Build a DOM element using createElement + setAttribute (no innerHTML).
320
+ * Avoids the XSS-pattern lint warning while keeping tests readable.
321
+ */
322
+ function make(tag, attrs = {}, ...children) {
323
+ const node = document.createElement(tag);
324
+ for (const [k, v] of Object.entries(attrs)) {
325
+ node.setAttribute(k, v);
326
+ }
327
+ for (const child of children) {
328
+ node.appendChild(child);
329
+ }
330
+ return node;
331
+ }
332
+ function mount(node) {
333
+ document.body.appendChild(node);
334
+ testEl = node;
335
+ return node;
336
+ }
337
+ it("source-order tie breaks: later rule wins on equal specificity", () => {
338
+ setStyles(`
339
+ .btn { color: red; }
340
+ .btn { color: blue; }
341
+ `);
342
+ const el = mount(make("button", {
343
+ class: "btn"
344
+ }));
345
+ const result = inspectCSSRules(el);
346
+ const colorProp = result.properties.find(p => p.property === "color");
347
+ expect(colorProp).toBeDefined();
348
+ // The winning rule should be the LATER one (blue), not the earlier one (red).
349
+ const winning = colorProp.rules.find(r => r.winning);
350
+ expect(winning.value).toBe("blue");
351
+ });
352
+ it("uses matched-branch specificity for selector lists", () => {
353
+ // Element matches `.btn` (specificity 0,1,0), but the rule's selector
354
+ // text is `.btn, #unrelated`. Specificity must be 0,1,0 (the matching
355
+ // branch), not 1,0,0 (the max of all branches). So a `#main .btn`
356
+ // selector (1,1,0) should still win.
357
+ setStyles(`
358
+ .btn, #unrelated { color: red; }
359
+ #main .btn { color: blue; }
360
+ `);
361
+ const btn = make("button", {
362
+ class: "btn"
363
+ });
364
+ mount(make("div", {
365
+ id: "main"
366
+ }, btn));
367
+ const result = inspectCSSRules(btn);
368
+ const colorProp = result.properties.find(p => p.property === "color");
369
+ const winning = colorProp.rules.find(r => r.winning);
370
+ expect(winning.value).toBe("blue");
371
+ expect(winning.selector).toContain("#main");
372
+ });
373
+ it("higher specificity beats lower regardless of source order", () => {
374
+ setStyles(`
375
+ #main { color: blue; }
376
+ .btn { color: red; }
377
+ `);
378
+ const el = mount(make("div", {
379
+ id: "main",
380
+ class: "btn"
381
+ }));
382
+ const result = inspectCSSRules(el);
383
+ const colorProp = result.properties.find(p => p.property === "color");
384
+ const winning = colorProp.rules.find(r => r.winning);
385
+ expect(winning.value).toBe("blue"); // #main has higher specificity
386
+ });
387
+ it("!important beats higher specificity without !important", () => {
388
+ setStyles(`
389
+ #main { color: blue; }
390
+ .btn { color: red !important; }
391
+ `);
392
+ const el = mount(make("div", {
393
+ id: "main",
394
+ class: "btn"
395
+ }));
396
+ const result = inspectCSSRules(el);
397
+ const colorProp = result.properties.find(p => p.property === "color");
398
+ const winning = colorProp.rules.find(r => r.winning);
399
+ expect(winning.value).toBe("red");
400
+ expect(winning.important).toBe(true);
401
+ });
402
+ it("inline styles beat stylesheet rules of same importance", () => {
403
+ setStyles(`
404
+ #main { color: blue; }
405
+ `);
406
+ const el = mount(make("div", {
407
+ id: "main",
408
+ style: "color: green;"
409
+ }));
410
+ const result = inspectCSSRules(el);
411
+ const colorProp = result.properties.find(p => p.property === "color");
412
+ const winning = colorProp.rules.find(r => r.winning);
413
+ expect(winning.source).toBe("inline");
414
+ });
415
+ it("returns empty properties when no rules match", () => {
416
+ setStyles(`.unrelated { color: red; }`);
417
+ const el = mount(make("div"));
418
+ const result = inspectCSSRules(el);
419
+ expect(result.properties).toEqual([]);
420
+ });
421
+ it("does not crash when getComputedStyle is unavailable", () => {
422
+ setStyles(`.btn { color: red; }`);
423
+ const el = mount(make("button", {
424
+ class: "btn"
425
+ }));
426
+
427
+ // Temporarily clobber getComputedStyle to simulate an environment without it
428
+ const original = window.getComputedStyle;
429
+ window.getComputedStyle = undefined;
430
+ try {
431
+ const result = inspectCSSRules(el);
432
+ const colorProp = result.properties.find(p => p.property === "color");
433
+ expect(colorProp).toBeDefined();
434
+ expect(colorProp.value).toBe("red"); // falls back to winning rule's value
435
+ } finally {
436
+ window.getComputedStyle = original;
437
+ }
438
+ });
439
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,178 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { deduplicateLabels } from "./deduplicateLabels";
3
+ describe("deduplicateLabels", () => {
4
+ test("returns empty array for empty input", () => {
5
+ const result = deduplicateLabels([]);
6
+ expect(result).toEqual([]);
7
+ });
8
+ test("returns same array when no duplicates", () => {
9
+ const labels = [{
10
+ label: "Button",
11
+ link: null
12
+ }, {
13
+ label: "Header",
14
+ link: null
15
+ }];
16
+ const result = deduplicateLabels(labels);
17
+ expect(result).toEqual(labels);
18
+ });
19
+ test("removes duplicate labels with same link", () => {
20
+ const label1 = {
21
+ label: "Button",
22
+ link: null
23
+ };
24
+ const label2 = {
25
+ label: "Button",
26
+ link: null
27
+ };
28
+ const labels = [label1, label2];
29
+ const result = deduplicateLabels(labels);
30
+ expect(result).toHaveLength(1);
31
+ expect(result[0]).toEqual(label1);
32
+ });
33
+ test("keeps labels with different link properties", () => {
34
+ const link1 = {
35
+ filePath: "Button.tsx",
36
+ projectPath: "/src",
37
+ line: 1,
38
+ column: 1
39
+ };
40
+ const link2 = {
41
+ filePath: "Header.tsx",
42
+ projectPath: "/src",
43
+ line: 1,
44
+ column: 1
45
+ };
46
+ const labels = [{
47
+ label: "Button",
48
+ link: link1
49
+ }, {
50
+ label: "Button",
51
+ link: link2
52
+ }];
53
+ const result = deduplicateLabels(labels);
54
+ expect(result).toHaveLength(2);
55
+ });
56
+ test("removes duplicate labels with same link object", () => {
57
+ const link = {
58
+ filePath: "Button.tsx",
59
+ projectPath: "/src",
60
+ line: 1,
61
+ column: 1
62
+ };
63
+ const labels = [{
64
+ label: "Button",
65
+ link
66
+ }, {
67
+ label: "Button",
68
+ link
69
+ }];
70
+ const result = deduplicateLabels(labels);
71
+ expect(result).toHaveLength(1);
72
+ });
73
+ test("handles multiple duplicates", () => {
74
+ const labels = [{
75
+ label: "Button",
76
+ link: null
77
+ }, {
78
+ label: "Button",
79
+ link: null
80
+ }, {
81
+ label: "Header",
82
+ link: null
83
+ }, {
84
+ label: "Header",
85
+ link: null
86
+ }, {
87
+ label: "Button",
88
+ link: null
89
+ }];
90
+ const result = deduplicateLabels(labels);
91
+ expect(result).toHaveLength(2);
92
+ expect(result.map(l => l.label)).toEqual(["Button", "Header"]);
93
+ });
94
+ test("preserves order of first occurrence", () => {
95
+ const labels = [{
96
+ label: "C",
97
+ link: null
98
+ }, {
99
+ label: "A",
100
+ link: null
101
+ }, {
102
+ label: "B",
103
+ link: null
104
+ }, {
105
+ label: "A",
106
+ link: null
107
+ }, {
108
+ label: "C",
109
+ link: null
110
+ }];
111
+ const result = deduplicateLabels(labels);
112
+ expect(result.map(l => l.label)).toEqual(["C", "A", "B"]);
113
+ });
114
+ test("handles complex link objects with same values", () => {
115
+ const labels = [{
116
+ label: "Component",
117
+ link: {
118
+ filePath: "App.tsx",
119
+ projectPath: "/src",
120
+ line: 10,
121
+ column: 5
122
+ }
123
+ }, {
124
+ label: "Component",
125
+ link: {
126
+ filePath: "App.tsx",
127
+ projectPath: "/src",
128
+ line: 10,
129
+ column: 5
130
+ }
131
+ }];
132
+ const result = deduplicateLabels(labels);
133
+ expect(result).toHaveLength(1);
134
+ });
135
+ test("distinguishes between link null and link with values", () => {
136
+ const labels = [{
137
+ label: "Button",
138
+ link: null
139
+ }, {
140
+ label: "Button",
141
+ link: {
142
+ filePath: "Button.tsx",
143
+ projectPath: "/src",
144
+ line: 1,
145
+ column: 1
146
+ }
147
+ }];
148
+ const result = deduplicateLabels(labels);
149
+ expect(result).toHaveLength(2);
150
+ });
151
+ test("handles single label", () => {
152
+ const labels = [{
153
+ label: "Button",
154
+ link: null
155
+ }];
156
+ const result = deduplicateLabels(labels);
157
+ expect(result).toEqual(labels);
158
+ });
159
+ test("returns correct instances", () => {
160
+ const label1 = {
161
+ label: "Button",
162
+ link: null
163
+ };
164
+ const label2 = {
165
+ label: "Header",
166
+ link: null
167
+ };
168
+ const label3 = {
169
+ label: "Button",
170
+ link: null
171
+ };
172
+ const labels = [label1, label2, label3];
173
+ const result = deduplicateLabels(labels);
174
+ expect(result).toHaveLength(2);
175
+ expect(result[0]).toBe(label1);
176
+ expect(result[1]).toBe(label2);
177
+ });
178
+ });
@@ -1,6 +1,5 @@
1
1
  import { resolveSourceLocation, parseDebugStack } from "../adapters/react/resolveSourceMap";
2
2
  import { normalizeFilePath } from "./normalizeFilePath";
3
-
4
3
  /**
5
4
  * Check if any DOM element has React 19 fibers (with _debugStack instead of _debugSource).
6
5
  * Must walk the _debugOwner chain because DOM element fibers (HostComponent) never have
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Extracts and formats computed styles from a DOM element.
3
+ * Output is optimized for AI consumption — minimal tokens, maximum signal.
4
+ */
5
+ export interface StyleSnapshot {
6
+ properties: Record<string, string>;
7
+ boundingRect: {
8
+ x: number;
9
+ y: number;
10
+ width: number;
11
+ height: number;
12
+ top: number;
13
+ right: number;
14
+ bottom: number;
15
+ left: number;
16
+ };
17
+ }
18
+ export interface ComputedStylesResult {
19
+ formatted: string;
20
+ snapshot: StyleSnapshot;
21
+ }
22
+ declare const LAYOUT_PROPERTIES: string[];
23
+ declare const VISUAL_PROPERTIES: string[];
24
+ declare const TYPOGRAPHY_PROPERTIES: string[];
25
+ declare const INTERACTION_PROPERTIES: string[];
26
+ declare const SVG_PROPERTIES: string[];
27
+ declare function collapseFourValues(top: string, right: string, bottom: string, left: string): string;
28
+ interface GroupedEntry {
29
+ name: string;
30
+ value: string;
31
+ }
32
+ declare function processGroupEntries(groupProps: string[], values: Record<string, string>, isNonDefault: (prop: string, value: string) => boolean): GroupedEntry[];
33
+ declare function formatDiff(prev: StyleSnapshot, curr: StyleSnapshot, elementLabel: string | undefined): string;
34
+ export interface ExtractOptions {
35
+ /**
36
+ * Skip diff-mode detection and always return the full formatted styles.
37
+ * Used by internal call sites that re-extract for the same element within
38
+ * the diff window (e.g. source-map enrichment re-runs in Runtime.tsx) where
39
+ * returning a "No changes detected" delta would be incorrect.
40
+ */
41
+ forceFull?: boolean;
42
+ /**
43
+ * Include curated properties even when they match browser defaults for the
44
+ * element's tag. Useful when you want a fuller dump closer to DevTools.
45
+ */
46
+ includeDefaults?: boolean;
47
+ }
48
+ export declare function readSnapshot(element: Element): StyleSnapshot;
49
+ export declare function extractComputedStyles(element: Element, elementLabel?: string, options?: ExtractOptions): ComputedStylesResult;
50
+ export { formatDiff as formatSnapshotDiff };
51
+ export { collapseFourValues as _collapseFourValues, processGroupEntries as _processGroupEntries, formatDiff as _formatDiff, LAYOUT_PROPERTIES, VISUAL_PROPERTIES, TYPOGRAPHY_PROPERTIES, INTERACTION_PROPERTIES, SVG_PROPERTIES, };