@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.
- package/.eslintignore +1 -0
- package/dist/_generated_styles.d.ts +1 -1
- package/dist/_generated_styles.js +20 -0
- package/dist/_generated_tree_icon.d.ts +1 -1
- package/dist/adapters/HtmlElementTreeNode.d.ts +2 -2
- package/dist/adapters/HtmlElementTreeNode.js +4 -6
- package/dist/adapters/createTreeNode.js +17 -44
- package/dist/adapters/detectFramework.d.ts +8 -0
- package/dist/adapters/detectFramework.js +25 -0
- package/dist/adapters/detectFramework.test.d.ts +1 -0
- package/dist/adapters/detectFramework.test.js +60 -0
- package/dist/adapters/jsx/jsxAdapter.js +54 -89
- package/dist/adapters/jsx/jsxAdapter.test.d.ts +1 -0
- package/dist/adapters/jsx/jsxAdapter.test.js +273 -0
- package/dist/adapters/nextjs/parseNextjsDataAttributes.js +1 -1
- package/dist/adapters/nextjs/parseNextjsDataAttributes.test.d.ts +1 -0
- package/dist/adapters/nextjs/parseNextjsDataAttributes.test.js +158 -0
- package/dist/adapters/react/findFiberByHtmlElement.d.ts +1 -1
- package/dist/adapters/react/findFiberByHtmlElement.js +1 -1
- package/dist/adapters/react/getAllParentsElementsAndRootComponent.js +4 -0
- package/dist/adapters/resolveAdapter.d.ts +1 -1
- package/dist/adapters/resolveAdapter.js +4 -8
- package/dist/adapters/svelte/svelteAdapter.test.d.ts +1 -0
- package/dist/adapters/svelte/svelteAdapter.test.js +280 -0
- package/dist/adapters/vue/vueAdapter.test.d.ts +1 -0
- package/dist/adapters/vue/vueAdapter.test.js +222 -0
- package/dist/browserApi.d.ts +148 -0
- package/dist/browserApi.js +146 -5
- package/dist/browserApi.test.d.ts +1 -0
- package/dist/browserApi.test.js +287 -0
- package/dist/components/RecordingPillButton.d.ts +11 -0
- package/dist/components/RecordingPillButton.js +202 -0
- package/dist/components/RecordingResults.d.ts +2 -0
- package/dist/components/RecordingResults.js +213 -78
- package/dist/components/Runtime.js +161 -554
- package/dist/components/SettingsPanel.d.ts +5 -0
- package/dist/components/SettingsPanel.js +312 -0
- package/dist/consoleCapture.d.ts +9 -0
- package/dist/consoleCapture.js +95 -0
- package/dist/dejitter/recorder.d.ts +7 -1
- package/dist/dejitter/recorder.js +64 -1
- package/dist/functions/cssRuleInspector.d.ts +83 -0
- package/dist/functions/cssRuleInspector.js +608 -0
- package/dist/functions/cssRuleInspector.test.d.ts +1 -0
- package/dist/functions/cssRuleInspector.test.js +439 -0
- package/dist/functions/deduplicateLabels.test.d.ts +1 -0
- package/dist/functions/deduplicateLabels.test.js +178 -0
- package/dist/functions/enrichAncestrySourceMaps.js +0 -1
- package/dist/functions/extractComputedStyles.d.ts +51 -0
- package/dist/functions/extractComputedStyles.js +447 -0
- package/dist/functions/extractComputedStyles.test.d.ts +1 -0
- package/dist/functions/extractComputedStyles.test.js +549 -0
- package/dist/functions/formatAncestryChain.d.ts +8 -0
- package/dist/functions/formatAncestryChain.js +21 -1
- package/dist/functions/formatAncestryChain.test.js +18 -0
- package/dist/functions/getUsableName.test.d.ts +1 -0
- package/dist/functions/getUsableName.test.js +219 -0
- package/dist/functions/isCombinationModifiersPressed.test.d.ts +1 -0
- package/dist/functions/isCombinationModifiersPressed.test.js +192 -0
- package/dist/functions/mergeRects.test.js +210 -1
- package/dist/functions/namedSnapshots.d.ts +52 -0
- package/dist/functions/namedSnapshots.js +161 -0
- package/dist/functions/namedSnapshots.test.d.ts +1 -0
- package/dist/functions/namedSnapshots.test.js +85 -0
- package/dist/functions/normalizeFilePath.test.d.ts +1 -0
- package/dist/functions/normalizeFilePath.test.js +66 -0
- package/dist/functions/parseDataId.test.d.ts +1 -0
- package/dist/functions/parseDataId.test.js +101 -0
- package/dist/hooks/getStorage.d.ts +3 -0
- package/dist/hooks/getStorage.js +17 -0
- package/dist/hooks/useEventListeners.d.ts +15 -0
- package/dist/hooks/useEventListeners.js +56 -0
- package/dist/hooks/useLocatorStorage.d.ts +18 -0
- package/dist/hooks/useLocatorStorage.js +41 -0
- package/dist/hooks/useLocatorStorage.test.d.ts +1 -0
- package/dist/hooks/useLocatorStorage.test.js +124 -0
- package/dist/hooks/useRecordingState.d.ts +43 -0
- package/dist/hooks/useRecordingState.js +387 -0
- package/dist/hooks/useSettings.d.ts +13 -0
- package/dist/hooks/useSettings.js +66 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -2
- package/dist/initRuntime.d.ts +3 -1
- package/dist/initRuntime.js +4 -1
- package/dist/mcpBridge.d.ts +61 -0
- package/dist/mcpBridge.js +534 -0
- package/dist/mcpBridge.test.d.ts +1 -0
- package/dist/mcpBridge.test.js +248 -0
- package/dist/output.css +20 -0
- package/dist/visualDiff/diff.d.ts +9 -0
- package/dist/visualDiff/diff.js +209 -0
- package/dist/visualDiff/diff.test.d.ts +1 -0
- package/dist/visualDiff/diff.test.js +253 -0
- package/dist/visualDiff/settle.d.ts +3 -0
- package/dist/visualDiff/settle.js +50 -0
- package/dist/visualDiff/settle.test.d.ts +1 -0
- package/dist/visualDiff/settle.test.js +65 -0
- package/dist/visualDiff/snapshot.d.ts +4 -0
- package/dist/visualDiff/snapshot.js +84 -0
- package/dist/visualDiff/snapshot.test.d.ts +1 -0
- package/dist/visualDiff/snapshot.test.js +245 -0
- package/dist/visualDiff/types.d.ts +37 -0
- package/dist/visualDiff/types.js +1 -0
- package/package.json +2 -2
- package/scripts/wrapCSS.js +1 -1
- package/scripts/wrapImage.js +1 -1
- package/src/_generated_styles.ts +21 -1
- package/src/_generated_tree_icon.ts +1 -1
- package/src/adapters/HtmlElementTreeNode.ts +10 -7
- package/src/adapters/createTreeNode.ts +12 -51
- package/src/adapters/detectFramework.test.ts +73 -0
- package/src/adapters/detectFramework.ts +28 -0
- package/src/adapters/jsx/jsxAdapter.test.ts +240 -0
- package/src/adapters/jsx/jsxAdapter.ts +53 -106
- package/src/adapters/nextjs/parseNextjsDataAttributes.test.ts +212 -0
- package/src/adapters/nextjs/parseNextjsDataAttributes.ts +1 -1
- package/src/adapters/react/findDebugSource.ts +5 -6
- package/src/adapters/react/findFiberByHtmlElement.ts +3 -3
- package/src/adapters/react/getAllParentsElementsAndRootComponent.ts +3 -0
- package/src/adapters/react/reactAdapter.ts +1 -2
- package/src/adapters/resolveAdapter.ts +4 -14
- package/src/adapters/svelte/svelteAdapter.test.ts +334 -0
- package/src/adapters/vue/vueAdapter.test.ts +259 -0
- package/src/browserApi.test.ts +329 -0
- package/src/browserApi.ts +351 -4
- package/src/components/RecordingPillButton.tsx +301 -0
- package/src/components/RecordingResults.tsx +114 -13
- package/src/components/Runtime.tsx +176 -621
- package/src/components/SettingsPanel.tsx +339 -0
- package/src/consoleCapture.ts +113 -0
- package/src/dejitter/recorder.ts +67 -3
- package/src/functions/cssRuleInspector.test.ts +517 -0
- package/src/functions/cssRuleInspector.ts +708 -0
- package/src/functions/deduplicateLabels.test.ts +115 -0
- package/src/functions/enrichAncestrySourceMaps.ts +6 -3
- package/src/functions/extractComputedStyles.test.ts +681 -0
- package/src/functions/extractComputedStyles.ts +768 -0
- package/src/functions/formatAncestryChain.test.ts +23 -1
- package/src/functions/formatAncestryChain.ts +22 -1
- package/src/functions/getUsableName.test.ts +242 -0
- package/src/functions/isCombinationModifiersPressed.test.ts +156 -0
- package/src/functions/mergeRects.test.ts +111 -1
- package/src/functions/namedSnapshots.test.ts +106 -0
- package/src/functions/namedSnapshots.ts +232 -0
- package/src/functions/normalizeFilePath.test.ts +80 -0
- package/src/functions/parseDataId.test.ts +125 -0
- package/src/hooks/getStorage.ts +26 -0
- package/src/hooks/useEventListeners.ts +97 -0
- package/src/hooks/useLocatorStorage.test.ts +127 -0
- package/src/hooks/useLocatorStorage.ts +60 -0
- package/src/hooks/useRecordingState.ts +516 -0
- package/src/hooks/useSettings.ts +83 -0
- package/src/index.ts +10 -5
- package/src/initRuntime.ts +5 -0
- package/src/mcpBridge.test.ts +260 -0
- package/src/mcpBridge.ts +677 -0
- package/src/visualDiff/diff.test.ts +167 -0
- package/src/visualDiff/diff.ts +242 -0
- package/src/visualDiff/settle.test.ts +77 -0
- package/src/visualDiff/settle.ts +62 -0
- package/src/visualDiff/snapshot.test.ts +200 -0
- package/src/visualDiff/snapshot.ts +119 -0
- package/src/visualDiff/types.ts +40 -0
- package/tsconfig.json +3 -1
- package/vitest.config.ts +18 -0
- package/jest.config.ts +0 -195
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
calculateSpecificity,
|
|
7
|
+
compareSpecificity,
|
|
8
|
+
formatSpecificity,
|
|
9
|
+
describeElement,
|
|
10
|
+
formatCSSInspection,
|
|
11
|
+
inspectCSSRules,
|
|
12
|
+
SpecificityTuple,
|
|
13
|
+
CSSInspectionResult,
|
|
14
|
+
} from "./cssRuleInspector";
|
|
15
|
+
|
|
16
|
+
describe("calculateSpecificity", () => {
|
|
17
|
+
it("counts element selectors", () => {
|
|
18
|
+
expect(calculateSpecificity("div")).toEqual([0, 0, 1]);
|
|
19
|
+
expect(calculateSpecificity("div span")).toEqual([0, 0, 2]);
|
|
20
|
+
expect(calculateSpecificity("ul li a")).toEqual([0, 0, 3]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("counts class selectors", () => {
|
|
24
|
+
expect(calculateSpecificity(".button")).toEqual([0, 1, 0]);
|
|
25
|
+
expect(calculateSpecificity(".button.primary")).toEqual([0, 2, 0]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("counts ID selectors", () => {
|
|
29
|
+
expect(calculateSpecificity("#main")).toEqual([1, 0, 0]);
|
|
30
|
+
expect(calculateSpecificity("#main #sidebar")).toEqual([2, 0, 0]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("counts mixed selectors", () => {
|
|
34
|
+
expect(calculateSpecificity("div.button")).toEqual([0, 1, 1]);
|
|
35
|
+
expect(calculateSpecificity("#main .button")).toEqual([1, 1, 0]);
|
|
36
|
+
expect(calculateSpecificity("#main div.button")).toEqual([1, 1, 1]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("counts attribute selectors as class-level", () => {
|
|
40
|
+
expect(calculateSpecificity("[type=submit]")).toEqual([0, 1, 0]);
|
|
41
|
+
expect(calculateSpecificity("input[type=submit]")).toEqual([0, 1, 1]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("counts pseudo-classes as class-level", () => {
|
|
45
|
+
expect(calculateSpecificity(":hover")).toEqual([0, 1, 0]);
|
|
46
|
+
expect(calculateSpecificity("a:hover")).toEqual([0, 1, 1]);
|
|
47
|
+
expect(calculateSpecificity("a:first-child")).toEqual([0, 1, 1]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("counts pseudo-elements as element-level", () => {
|
|
51
|
+
expect(calculateSpecificity("::before")).toEqual([0, 0, 1]);
|
|
52
|
+
expect(calculateSpecificity("p::first-line")).toEqual([0, 0, 2]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("normalizes legacy single-colon pseudo-elements to element-level", () => {
|
|
56
|
+
// :before, :after, :first-line, :first-letter are legacy pseudo-elements
|
|
57
|
+
// that should be treated as element-level (0,0,1), not pseudo-class (0,1,0)
|
|
58
|
+
expect(calculateSpecificity(":before")).toEqual([0, 0, 1]);
|
|
59
|
+
expect(calculateSpecificity(":after")).toEqual([0, 0, 1]);
|
|
60
|
+
expect(calculateSpecificity("p:first-line")).toEqual([0, 0, 2]);
|
|
61
|
+
expect(calculateSpecificity("p:first-letter")).toEqual([0, 0, 2]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("handles child and sibling combinators", () => {
|
|
65
|
+
expect(calculateSpecificity("div > span")).toEqual([0, 0, 2]);
|
|
66
|
+
expect(calculateSpecificity("div + span")).toEqual([0, 0, 2]);
|
|
67
|
+
expect(calculateSpecificity("div ~ span")).toEqual([0, 0, 2]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("ignores universal selector", () => {
|
|
71
|
+
expect(calculateSpecificity("*")).toEqual([0, 0, 0]);
|
|
72
|
+
expect(calculateSpecificity("*.button")).toEqual([0, 1, 0]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("handles comma-separated selectors by returning max", () => {
|
|
76
|
+
// .a has specificity (0,1,0), #b has (1,0,0) — should return (1,0,0)
|
|
77
|
+
expect(calculateSpecificity(".a, #b")).toEqual([1, 0, 0]);
|
|
78
|
+
expect(calculateSpecificity("div, .button, #main")).toEqual([1, 0, 0]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("handles :not() by counting its argument", () => {
|
|
82
|
+
// :not(.disabled) = (0,1,0) + the :not itself is not counted
|
|
83
|
+
expect(calculateSpecificity(":not(.disabled)")).toEqual([0, 1, 0]);
|
|
84
|
+
expect(calculateSpecificity("div:not(.hidden)")).toEqual([0, 1, 1]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("handles :is() by counting its argument", () => {
|
|
88
|
+
expect(calculateSpecificity(":is(.a, .b)")).toEqual([0, 1, 0]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("handles :where() as zero specificity", () => {
|
|
92
|
+
expect(calculateSpecificity(":where(.a, .b)")).toEqual([0, 0, 0]);
|
|
93
|
+
expect(calculateSpecificity("div:where(.a)")).toEqual([0, 0, 1]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("handles complex real-world selectors", () => {
|
|
97
|
+
// Bootstrap-style: .btn-group > .btn:not(:first-child)
|
|
98
|
+
// .btn-group = (0,1,0), .btn = (0,1,0), :not(:first-child) -> :first-child = (0,1,0)
|
|
99
|
+
// Total: (0,3,0) ... but we also don't count > combinator
|
|
100
|
+
// Actually let me recalculate: .btn-group > .btn:not(:first-child)
|
|
101
|
+
// .btn-group = class (0,1,0)
|
|
102
|
+
// .btn = class (0,1,0)
|
|
103
|
+
// :not(:first-child) = argument :first-child = pseudo-class (0,1,0)
|
|
104
|
+
// Total = (0,3,0)
|
|
105
|
+
expect(calculateSpecificity(".btn-group > .btn:not(:first-child)")).toEqual([0, 3, 0]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("nested functional pseudos", () => {
|
|
109
|
+
it("handles :not(:is(.a, .b))", () => {
|
|
110
|
+
// :is(.a, .b) = max(.a, .b) = (0,1,0)
|
|
111
|
+
// :not(:is(.a, .b)) = (0,1,0)
|
|
112
|
+
expect(calculateSpecificity(":not(:is(.a, .b))")).toEqual([0, 1, 0]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("handles :not(:not(.a))", () => {
|
|
116
|
+
// Inner :not(.a) = (0,1,0), outer :not(:not(.a)) = (0,1,0)
|
|
117
|
+
expect(calculateSpecificity(":not(:not(.a))")).toEqual([0, 1, 0]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("handles deeply nested :is and :where", () => {
|
|
121
|
+
// :is(:where(.a), .b) — :where(.a) = 0, .b = (0,1,0), max = (0,1,0)
|
|
122
|
+
expect(calculateSpecificity(":is(:where(.a), .b)")).toEqual([0, 1, 0]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("handles :has(:is(.a, [data-x=\"a,b\"]))", () => {
|
|
126
|
+
// .a = (0,1,0), [data-x="a,b"] = (0,1,0), max = (0,1,0)
|
|
127
|
+
// :has() takes the spec of its argument
|
|
128
|
+
expect(calculateSpecificity(':has(:is(.a, [data-x="a,b"]))')).toEqual([
|
|
129
|
+
0, 1, 0,
|
|
130
|
+
]);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("attribute selectors with embedded specials", () => {
|
|
135
|
+
it("handles attribute value containing a comma", () => {
|
|
136
|
+
// [data-value="a,b"] = single attribute selector = (0,1,0)
|
|
137
|
+
expect(calculateSpecificity('[data-value="a,b"]')).toEqual([0, 1, 0]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("handles attribute value containing brackets and dots", () => {
|
|
141
|
+
// [data-x=".a#b"] should still be one attribute selector = (0,1,0)
|
|
142
|
+
// The dot and # inside the string must NOT be counted as class/id.
|
|
143
|
+
expect(calculateSpecificity('[data-x=".a#b"]')).toEqual([0, 1, 0]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("handles multiple attribute selectors", () => {
|
|
147
|
+
expect(calculateSpecificity('[type="submit"][disabled]')).toEqual([
|
|
148
|
+
0, 2, 0,
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("comma splitting with nested specials", () => {
|
|
154
|
+
it("does not split inside parens", () => {
|
|
155
|
+
// :is(.a, .b) is a single branch, not two
|
|
156
|
+
expect(calculateSpecificity(":is(.a, .b), #c")).toEqual([1, 0, 0]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("does not split inside attribute brackets", () => {
|
|
160
|
+
// [data-x="a,b"] is a single branch
|
|
161
|
+
expect(calculateSpecificity('[data-x="a,b"], #c')).toEqual([1, 0, 0]);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("compareSpecificity", () => {
|
|
167
|
+
it("returns positive when first is higher", () => {
|
|
168
|
+
expect(compareSpecificity([1, 0, 0], [0, 10, 10])).toBeGreaterThan(0);
|
|
169
|
+
expect(compareSpecificity([0, 2, 0], [0, 1, 5])).toBeGreaterThan(0);
|
|
170
|
+
expect(compareSpecificity([0, 0, 3], [0, 0, 2])).toBeGreaterThan(0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("returns negative when first is lower", () => {
|
|
174
|
+
expect(compareSpecificity([0, 0, 1], [0, 1, 0])).toBeLessThan(0);
|
|
175
|
+
expect(compareSpecificity([0, 1, 0], [1, 0, 0])).toBeLessThan(0);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("returns zero when equal", () => {
|
|
179
|
+
expect(compareSpecificity([0, 1, 1], [0, 1, 1])).toBe(0);
|
|
180
|
+
expect(compareSpecificity([1, 0, 0], [1, 0, 0])).toBe(0);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("formatSpecificity", () => {
|
|
185
|
+
it("formats tuple as readable string", () => {
|
|
186
|
+
expect(formatSpecificity([0, 1, 2])).toBe("(0,1,2)");
|
|
187
|
+
expect(formatSpecificity([1, 0, 0])).toBe("(1,0,0)");
|
|
188
|
+
expect(formatSpecificity([0, 0, 0])).toBe("(0,0,0)");
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("describeElement", () => {
|
|
193
|
+
it("describes a plain element", () => {
|
|
194
|
+
const el = document.createElement("div");
|
|
195
|
+
expect(describeElement(el)).toBe("div");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("includes id", () => {
|
|
199
|
+
const el = document.createElement("div");
|
|
200
|
+
el.id = "main";
|
|
201
|
+
expect(describeElement(el)).toBe("div#main");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("includes classes", () => {
|
|
205
|
+
const el = document.createElement("button");
|
|
206
|
+
el.classList.add("btn", "primary");
|
|
207
|
+
expect(describeElement(el)).toBe("button.btn.primary");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("includes both id and classes", () => {
|
|
211
|
+
const el = document.createElement("button");
|
|
212
|
+
el.id = "submit";
|
|
213
|
+
el.classList.add("btn", "primary");
|
|
214
|
+
expect(describeElement(el)).toBe("button#submit.btn.primary");
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("formatCSSInspection", () => {
|
|
219
|
+
it("formats empty result", () => {
|
|
220
|
+
const result: CSSInspectionResult = {
|
|
221
|
+
element: "div",
|
|
222
|
+
properties: [],
|
|
223
|
+
unreachableSheets: [],
|
|
224
|
+
};
|
|
225
|
+
const formatted = formatCSSInspection(result);
|
|
226
|
+
expect(formatted).toContain("CSS Rules for div");
|
|
227
|
+
expect(formatted).toContain("No matching CSS rules found.");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("formats properties with winning/losing rules", () => {
|
|
231
|
+
const result: CSSInspectionResult = {
|
|
232
|
+
element: "button.primary",
|
|
233
|
+
properties: [
|
|
234
|
+
{
|
|
235
|
+
property: "color",
|
|
236
|
+
value: "rgb(51, 51, 51)",
|
|
237
|
+
rules: [
|
|
238
|
+
{
|
|
239
|
+
selector: ".button.primary",
|
|
240
|
+
value: "#333",
|
|
241
|
+
specificity: [0, 2, 0] as SpecificityTuple,
|
|
242
|
+
important: false,
|
|
243
|
+
source: "components.css",
|
|
244
|
+
winning: true,
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
selector: ".button",
|
|
248
|
+
value: "#666",
|
|
249
|
+
specificity: [0, 1, 0] as SpecificityTuple,
|
|
250
|
+
important: false,
|
|
251
|
+
source: "base.css",
|
|
252
|
+
winning: false,
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
},
|
|
256
|
+
],
|
|
257
|
+
unreachableSheets: [],
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const formatted = formatCSSInspection(result);
|
|
261
|
+
expect(formatted).toContain("CSS Rules for button.primary");
|
|
262
|
+
expect(formatted).toContain("color: rgb(51, 51, 51)");
|
|
263
|
+
expect(formatted).toContain("✓ .button.primary");
|
|
264
|
+
expect(formatted).toContain("(0,2,0)");
|
|
265
|
+
expect(formatted).toContain("✗ .button");
|
|
266
|
+
expect(formatted).toContain("(0,1,0)");
|
|
267
|
+
expect(formatted).toContain("[#666]"); // losing rule shows its value
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("shows !important flag", () => {
|
|
271
|
+
const result: CSSInspectionResult = {
|
|
272
|
+
element: "div",
|
|
273
|
+
properties: [
|
|
274
|
+
{
|
|
275
|
+
property: "display",
|
|
276
|
+
value: "none",
|
|
277
|
+
rules: [
|
|
278
|
+
{
|
|
279
|
+
selector: ".hidden",
|
|
280
|
+
value: "none",
|
|
281
|
+
specificity: [0, 1, 0] as SpecificityTuple,
|
|
282
|
+
important: true,
|
|
283
|
+
source: "utils.css",
|
|
284
|
+
winning: true,
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
},
|
|
288
|
+
],
|
|
289
|
+
unreachableSheets: [],
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const formatted = formatCSSInspection(result);
|
|
293
|
+
expect(formatted).toContain("!important");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("shows cross-origin warning", () => {
|
|
297
|
+
const result: CSSInspectionResult = {
|
|
298
|
+
element: "div",
|
|
299
|
+
properties: [],
|
|
300
|
+
unreachableSheets: ["cdn-styles.css", "external.css"],
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const formatted = formatCSSInspection(result);
|
|
304
|
+
expect(formatted).toContain("Cross-origin");
|
|
305
|
+
expect(formatted).toContain("cdn-styles.css");
|
|
306
|
+
expect(formatted).toContain("external.css");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("formats with source-order tie breaking", () => {
|
|
310
|
+
// Two rules with identical specificity — second should win because it
|
|
311
|
+
// appears later in the source order.
|
|
312
|
+
const result: CSSInspectionResult = {
|
|
313
|
+
element: "div",
|
|
314
|
+
properties: [
|
|
315
|
+
{
|
|
316
|
+
property: "color",
|
|
317
|
+
value: "blue",
|
|
318
|
+
rules: [
|
|
319
|
+
// Already sorted by inspectCSSRules — winner first.
|
|
320
|
+
{
|
|
321
|
+
selector: ".later",
|
|
322
|
+
value: "blue",
|
|
323
|
+
specificity: [0, 1, 0] as SpecificityTuple,
|
|
324
|
+
important: false,
|
|
325
|
+
source: "later.css",
|
|
326
|
+
winning: true,
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
selector: ".earlier",
|
|
330
|
+
value: "red",
|
|
331
|
+
specificity: [0, 1, 0] as SpecificityTuple,
|
|
332
|
+
important: false,
|
|
333
|
+
source: "earlier.css",
|
|
334
|
+
winning: false,
|
|
335
|
+
},
|
|
336
|
+
],
|
|
337
|
+
},
|
|
338
|
+
],
|
|
339
|
+
unreachableSheets: [],
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const formatted = formatCSSInspection(result);
|
|
343
|
+
expect(formatted).toContain("✓ .later");
|
|
344
|
+
expect(formatted).toContain("✗ .earlier");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("shows inline source for inline rules", () => {
|
|
348
|
+
const result: CSSInspectionResult = {
|
|
349
|
+
element: "div",
|
|
350
|
+
properties: [
|
|
351
|
+
{
|
|
352
|
+
property: "color",
|
|
353
|
+
value: "red",
|
|
354
|
+
rules: [
|
|
355
|
+
{
|
|
356
|
+
selector: "element.style",
|
|
357
|
+
value: "red",
|
|
358
|
+
specificity: [1, 0, 0] as SpecificityTuple,
|
|
359
|
+
important: false,
|
|
360
|
+
source: "inline",
|
|
361
|
+
winning: true,
|
|
362
|
+
},
|
|
363
|
+
],
|
|
364
|
+
},
|
|
365
|
+
],
|
|
366
|
+
unreachableSheets: [],
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const formatted = formatCSSInspection(result);
|
|
370
|
+
expect(formatted).toContain("(inline)");
|
|
371
|
+
expect(formatted).toContain("— inline");
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
describe("inspectCSSRules (integration)", () => {
|
|
376
|
+
let styleEl: HTMLStyleElement | null = null;
|
|
377
|
+
let testEl: HTMLElement | null = null;
|
|
378
|
+
|
|
379
|
+
beforeEach(() => {
|
|
380
|
+
styleEl = document.createElement("style");
|
|
381
|
+
document.head.appendChild(styleEl);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
afterEach(() => {
|
|
385
|
+
if (styleEl?.parentNode) styleEl.parentNode.removeChild(styleEl);
|
|
386
|
+
if (testEl?.parentNode) testEl.parentNode.removeChild(testEl);
|
|
387
|
+
styleEl = null;
|
|
388
|
+
testEl = null;
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
function setStyles(css: string) {
|
|
392
|
+
styleEl!.textContent = css;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Build a DOM element using createElement + setAttribute (no innerHTML).
|
|
397
|
+
* Avoids the XSS-pattern lint warning while keeping tests readable.
|
|
398
|
+
*/
|
|
399
|
+
function make(
|
|
400
|
+
tag: string,
|
|
401
|
+
attrs: Record<string, string> = {},
|
|
402
|
+
...children: HTMLElement[]
|
|
403
|
+
): HTMLElement {
|
|
404
|
+
const node = document.createElement(tag);
|
|
405
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
406
|
+
node.setAttribute(k, v);
|
|
407
|
+
}
|
|
408
|
+
for (const child of children) {
|
|
409
|
+
node.appendChild(child);
|
|
410
|
+
}
|
|
411
|
+
return node;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function mount(node: HTMLElement): HTMLElement {
|
|
415
|
+
document.body.appendChild(node);
|
|
416
|
+
testEl = node;
|
|
417
|
+
return node;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
it("source-order tie breaks: later rule wins on equal specificity", () => {
|
|
421
|
+
setStyles(`
|
|
422
|
+
.btn { color: red; }
|
|
423
|
+
.btn { color: blue; }
|
|
424
|
+
`);
|
|
425
|
+
const el = mount(make("button", { class: "btn" }));
|
|
426
|
+
|
|
427
|
+
const result = inspectCSSRules(el);
|
|
428
|
+
const colorProp = result.properties.find((p) => p.property === "color")!;
|
|
429
|
+
expect(colorProp).toBeDefined();
|
|
430
|
+
// The winning rule should be the LATER one (blue), not the earlier one (red).
|
|
431
|
+
const winning = colorProp.rules.find((r) => r.winning)!;
|
|
432
|
+
expect(winning.value).toBe("blue");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("uses matched-branch specificity for selector lists", () => {
|
|
436
|
+
// Element matches `.btn` (specificity 0,1,0), but the rule's selector
|
|
437
|
+
// text is `.btn, #unrelated`. Specificity must be 0,1,0 (the matching
|
|
438
|
+
// branch), not 1,0,0 (the max of all branches). So a `#main .btn`
|
|
439
|
+
// selector (1,1,0) should still win.
|
|
440
|
+
setStyles(`
|
|
441
|
+
.btn, #unrelated { color: red; }
|
|
442
|
+
#main .btn { color: blue; }
|
|
443
|
+
`);
|
|
444
|
+
const btn = make("button", { class: "btn" });
|
|
445
|
+
mount(make("div", { id: "main" }, btn));
|
|
446
|
+
|
|
447
|
+
const result = inspectCSSRules(btn);
|
|
448
|
+
const colorProp = result.properties.find((p) => p.property === "color")!;
|
|
449
|
+
const winning = colorProp.rules.find((r) => r.winning)!;
|
|
450
|
+
expect(winning.value).toBe("blue");
|
|
451
|
+
expect(winning.selector).toContain("#main");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("higher specificity beats lower regardless of source order", () => {
|
|
455
|
+
setStyles(`
|
|
456
|
+
#main { color: blue; }
|
|
457
|
+
.btn { color: red; }
|
|
458
|
+
`);
|
|
459
|
+
const el = mount(make("div", { id: "main", class: "btn" }));
|
|
460
|
+
|
|
461
|
+
const result = inspectCSSRules(el);
|
|
462
|
+
const colorProp = result.properties.find((p) => p.property === "color")!;
|
|
463
|
+
const winning = colorProp.rules.find((r) => r.winning)!;
|
|
464
|
+
expect(winning.value).toBe("blue"); // #main has higher specificity
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("!important beats higher specificity without !important", () => {
|
|
468
|
+
setStyles(`
|
|
469
|
+
#main { color: blue; }
|
|
470
|
+
.btn { color: red !important; }
|
|
471
|
+
`);
|
|
472
|
+
const el = mount(make("div", { id: "main", class: "btn" }));
|
|
473
|
+
|
|
474
|
+
const result = inspectCSSRules(el);
|
|
475
|
+
const colorProp = result.properties.find((p) => p.property === "color")!;
|
|
476
|
+
const winning = colorProp.rules.find((r) => r.winning)!;
|
|
477
|
+
expect(winning.value).toBe("red");
|
|
478
|
+
expect(winning.important).toBe(true);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("inline styles beat stylesheet rules of same importance", () => {
|
|
482
|
+
setStyles(`
|
|
483
|
+
#main { color: blue; }
|
|
484
|
+
`);
|
|
485
|
+
const el = mount(make("div", { id: "main", style: "color: green;" }));
|
|
486
|
+
|
|
487
|
+
const result = inspectCSSRules(el);
|
|
488
|
+
const colorProp = result.properties.find((p) => p.property === "color")!;
|
|
489
|
+
const winning = colorProp.rules.find((r) => r.winning)!;
|
|
490
|
+
expect(winning.source).toBe("inline");
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("returns empty properties when no rules match", () => {
|
|
494
|
+
setStyles(`.unrelated { color: red; }`);
|
|
495
|
+
const el = mount(make("div"));
|
|
496
|
+
|
|
497
|
+
const result = inspectCSSRules(el);
|
|
498
|
+
expect(result.properties).toEqual([]);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("does not crash when getComputedStyle is unavailable", () => {
|
|
502
|
+
setStyles(`.btn { color: red; }`);
|
|
503
|
+
const el = mount(make("button", { class: "btn" }));
|
|
504
|
+
|
|
505
|
+
// Temporarily clobber getComputedStyle to simulate an environment without it
|
|
506
|
+
const original = window.getComputedStyle;
|
|
507
|
+
(window as any).getComputedStyle = undefined;
|
|
508
|
+
try {
|
|
509
|
+
const result = inspectCSSRules(el);
|
|
510
|
+
const colorProp = result.properties.find((p) => p.property === "color")!;
|
|
511
|
+
expect(colorProp).toBeDefined();
|
|
512
|
+
expect(colorProp.value).toBe("red"); // falls back to winning rule's value
|
|
513
|
+
} finally {
|
|
514
|
+
window.getComputedStyle = original;
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
});
|