@treelocator/runtime 0.5.2 → 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 (163) 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/functions/cssRuleInspector.d.ts +83 -0
  41. package/dist/functions/cssRuleInspector.js +608 -0
  42. package/dist/functions/cssRuleInspector.test.d.ts +1 -0
  43. package/dist/functions/cssRuleInspector.test.js +439 -0
  44. package/dist/functions/deduplicateLabels.test.d.ts +1 -0
  45. package/dist/functions/deduplicateLabels.test.js +178 -0
  46. package/dist/functions/enrichAncestrySourceMaps.js +0 -1
  47. package/dist/functions/extractComputedStyles.d.ts +51 -0
  48. package/dist/functions/extractComputedStyles.js +447 -0
  49. package/dist/functions/extractComputedStyles.test.d.ts +1 -0
  50. package/dist/functions/extractComputedStyles.test.js +549 -0
  51. package/dist/functions/formatAncestryChain.d.ts +8 -0
  52. package/dist/functions/formatAncestryChain.js +21 -1
  53. package/dist/functions/formatAncestryChain.test.js +18 -0
  54. package/dist/functions/getUsableName.test.d.ts +1 -0
  55. package/dist/functions/getUsableName.test.js +219 -0
  56. package/dist/functions/isCombinationModifiersPressed.test.d.ts +1 -0
  57. package/dist/functions/isCombinationModifiersPressed.test.js +192 -0
  58. package/dist/functions/mergeRects.test.js +210 -1
  59. package/dist/functions/namedSnapshots.d.ts +52 -0
  60. package/dist/functions/namedSnapshots.js +161 -0
  61. package/dist/functions/namedSnapshots.test.d.ts +1 -0
  62. package/dist/functions/namedSnapshots.test.js +85 -0
  63. package/dist/functions/normalizeFilePath.test.d.ts +1 -0
  64. package/dist/functions/normalizeFilePath.test.js +66 -0
  65. package/dist/functions/parseDataId.test.d.ts +1 -0
  66. package/dist/functions/parseDataId.test.js +101 -0
  67. package/dist/hooks/getStorage.d.ts +3 -0
  68. package/dist/hooks/getStorage.js +17 -0
  69. package/dist/hooks/useEventListeners.d.ts +15 -0
  70. package/dist/hooks/useEventListeners.js +56 -0
  71. package/dist/hooks/useLocatorStorage.d.ts +18 -0
  72. package/dist/hooks/useLocatorStorage.js +41 -0
  73. package/dist/hooks/useLocatorStorage.test.d.ts +1 -0
  74. package/dist/hooks/useLocatorStorage.test.js +124 -0
  75. package/dist/hooks/useRecordingState.d.ts +43 -0
  76. package/dist/hooks/useRecordingState.js +387 -0
  77. package/dist/hooks/useSettings.d.ts +13 -0
  78. package/dist/hooks/useSettings.js +66 -0
  79. package/dist/index.d.ts +5 -2
  80. package/dist/index.js +4 -2
  81. package/dist/initRuntime.d.ts +3 -1
  82. package/dist/initRuntime.js +4 -1
  83. package/dist/mcpBridge.d.ts +61 -0
  84. package/dist/mcpBridge.js +534 -0
  85. package/dist/mcpBridge.test.d.ts +1 -0
  86. package/dist/mcpBridge.test.js +248 -0
  87. package/dist/output.css +20 -0
  88. package/dist/visualDiff/diff.d.ts +9 -0
  89. package/dist/visualDiff/diff.js +209 -0
  90. package/dist/visualDiff/diff.test.d.ts +1 -0
  91. package/dist/visualDiff/diff.test.js +253 -0
  92. package/dist/visualDiff/settle.d.ts +3 -0
  93. package/dist/visualDiff/settle.js +50 -0
  94. package/dist/visualDiff/settle.test.d.ts +1 -0
  95. package/dist/visualDiff/settle.test.js +65 -0
  96. package/dist/visualDiff/snapshot.d.ts +4 -0
  97. package/dist/visualDiff/snapshot.js +84 -0
  98. package/dist/visualDiff/snapshot.test.d.ts +1 -0
  99. package/dist/visualDiff/snapshot.test.js +245 -0
  100. package/dist/visualDiff/types.d.ts +37 -0
  101. package/dist/visualDiff/types.js +1 -0
  102. package/package.json +2 -2
  103. package/scripts/wrapCSS.js +1 -1
  104. package/scripts/wrapImage.js +1 -1
  105. package/src/_generated_styles.ts +21 -1
  106. package/src/_generated_tree_icon.ts +1 -1
  107. package/src/adapters/HtmlElementTreeNode.ts +10 -7
  108. package/src/adapters/createTreeNode.ts +12 -51
  109. package/src/adapters/detectFramework.test.ts +73 -0
  110. package/src/adapters/detectFramework.ts +28 -0
  111. package/src/adapters/jsx/jsxAdapter.test.ts +240 -0
  112. package/src/adapters/jsx/jsxAdapter.ts +53 -106
  113. package/src/adapters/nextjs/parseNextjsDataAttributes.test.ts +212 -0
  114. package/src/adapters/nextjs/parseNextjsDataAttributes.ts +1 -1
  115. package/src/adapters/react/findDebugSource.ts +5 -6
  116. package/src/adapters/react/findFiberByHtmlElement.ts +3 -3
  117. package/src/adapters/react/getAllParentsElementsAndRootComponent.ts +3 -0
  118. package/src/adapters/react/reactAdapter.ts +1 -2
  119. package/src/adapters/resolveAdapter.ts +4 -14
  120. package/src/adapters/svelte/svelteAdapter.test.ts +334 -0
  121. package/src/adapters/vue/vueAdapter.test.ts +259 -0
  122. package/src/browserApi.test.ts +329 -0
  123. package/src/browserApi.ts +351 -4
  124. package/src/components/RecordingPillButton.tsx +301 -0
  125. package/src/components/RecordingResults.tsx +114 -13
  126. package/src/components/Runtime.tsx +176 -621
  127. package/src/components/SettingsPanel.tsx +339 -0
  128. package/src/consoleCapture.ts +113 -0
  129. package/src/functions/cssRuleInspector.test.ts +517 -0
  130. package/src/functions/cssRuleInspector.ts +708 -0
  131. package/src/functions/deduplicateLabels.test.ts +115 -0
  132. package/src/functions/enrichAncestrySourceMaps.ts +6 -3
  133. package/src/functions/extractComputedStyles.test.ts +681 -0
  134. package/src/functions/extractComputedStyles.ts +768 -0
  135. package/src/functions/formatAncestryChain.test.ts +23 -1
  136. package/src/functions/formatAncestryChain.ts +22 -1
  137. package/src/functions/getUsableName.test.ts +242 -0
  138. package/src/functions/isCombinationModifiersPressed.test.ts +156 -0
  139. package/src/functions/mergeRects.test.ts +111 -1
  140. package/src/functions/namedSnapshots.test.ts +106 -0
  141. package/src/functions/namedSnapshots.ts +232 -0
  142. package/src/functions/normalizeFilePath.test.ts +80 -0
  143. package/src/functions/parseDataId.test.ts +125 -0
  144. package/src/hooks/getStorage.ts +26 -0
  145. package/src/hooks/useEventListeners.ts +97 -0
  146. package/src/hooks/useLocatorStorage.test.ts +127 -0
  147. package/src/hooks/useLocatorStorage.ts +60 -0
  148. package/src/hooks/useRecordingState.ts +516 -0
  149. package/src/hooks/useSettings.ts +83 -0
  150. package/src/index.ts +10 -5
  151. package/src/initRuntime.ts +5 -0
  152. package/src/mcpBridge.test.ts +260 -0
  153. package/src/mcpBridge.ts +677 -0
  154. package/src/visualDiff/diff.test.ts +167 -0
  155. package/src/visualDiff/diff.ts +242 -0
  156. package/src/visualDiff/settle.test.ts +77 -0
  157. package/src/visualDiff/settle.ts +62 -0
  158. package/src/visualDiff/snapshot.test.ts +200 -0
  159. package/src/visualDiff/snapshot.ts +119 -0
  160. package/src/visualDiff/types.ts +40 -0
  161. package/tsconfig.json +3 -1
  162. package/vitest.config.ts +18 -0
  163. package/jest.config.ts +0 -195
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Named element snapshots persisted in localStorage.
3
+ *
4
+ * Workflow: takeSnapshot(selector, id) → change code → getSnapshotDiff(id).
5
+ * Stored snapshots are immutable — getSnapshotDiff never overwrites the
6
+ * baseline, so iterating on a fix always diffs against the original state.
7
+ */
8
+
9
+ import { readSnapshot, formatSnapshotDiff } from "./extractComputedStyles";
10
+ const STORAGE_KEY_PREFIX = "treelocator:snapshot:";
11
+ export class NamedSnapshotError extends Error {
12
+ constructor(code, message) {
13
+ super(message);
14
+ this.name = "NamedSnapshotError";
15
+ this.code = code;
16
+ }
17
+ }
18
+ function getStorage() {
19
+ try {
20
+ return typeof localStorage !== "undefined" ? localStorage : null;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+ function storageKey(snapshotId) {
26
+ return `${STORAGE_KEY_PREFIX}${snapshotId}`;
27
+ }
28
+ function resolveElement(selector, index) {
29
+ let nodes;
30
+ try {
31
+ nodes = document.querySelectorAll(selector);
32
+ } catch {
33
+ throw new NamedSnapshotError("invalid_selector", `Invalid selector: ${selector}`);
34
+ }
35
+ const node = nodes.item(index);
36
+ if (!(node instanceof HTMLElement) && !(node instanceof SVGElement)) {
37
+ throw new NamedSnapshotError("element_not_found", `No element found for selector "${selector}" at index ${index}`);
38
+ }
39
+ return node;
40
+ }
41
+ function writeStored(stored) {
42
+ const storage = getStorage();
43
+ if (!storage) {
44
+ throw new NamedSnapshotError("storage_unavailable", "localStorage is not available in this environment");
45
+ }
46
+ try {
47
+ storage.setItem(storageKey(stored.snapshotId), JSON.stringify(stored));
48
+ } catch (error) {
49
+ throw new NamedSnapshotError("storage_write_failed", error instanceof Error ? error.message : "Failed to write snapshot");
50
+ }
51
+ }
52
+ function readStored(snapshotId) {
53
+ const storage = getStorage();
54
+ if (!storage) {
55
+ throw new NamedSnapshotError("storage_unavailable", "localStorage is not available in this environment");
56
+ }
57
+ const raw = storage.getItem(storageKey(snapshotId));
58
+ if (!raw) {
59
+ throw new NamedSnapshotError("snapshot_not_found", `No snapshot stored under id "${snapshotId}"`);
60
+ }
61
+ try {
62
+ return JSON.parse(raw);
63
+ } catch (error) {
64
+ throw new NamedSnapshotError("snapshot_corrupt", error instanceof Error ? error.message : "Failed to parse stored snapshot");
65
+ }
66
+ }
67
+ export function takeNamedSnapshot(selector, snapshotId, options = {}) {
68
+ if (!selector) {
69
+ throw new NamedSnapshotError("invalid_args", "selector is required");
70
+ }
71
+ if (!snapshotId) {
72
+ throw new NamedSnapshotError("invalid_args", "snapshotId is required");
73
+ }
74
+ const index = options.index ?? 0;
75
+ const element = resolveElement(selector, index);
76
+ const snapshot = readSnapshot(element);
77
+ const takenAt = new Date().toISOString();
78
+ const stored = {
79
+ snapshotId,
80
+ selector,
81
+ index,
82
+ label: options.label,
83
+ takenAt,
84
+ snapshot
85
+ };
86
+ writeStored(stored);
87
+ return {
88
+ snapshotId,
89
+ selector,
90
+ index,
91
+ takenAt,
92
+ propertyCount: Object.keys(snapshot.properties).length,
93
+ boundingRect: snapshot.boundingRect
94
+ };
95
+ }
96
+ export function getNamedSnapshotDiff(snapshotId) {
97
+ if (!snapshotId) {
98
+ throw new NamedSnapshotError("invalid_args", "snapshotId is required");
99
+ }
100
+ const stored = readStored(snapshotId);
101
+ const element = resolveElement(stored.selector, stored.index);
102
+ const current = readSnapshot(element);
103
+ const changes = [];
104
+ const allProps = new Set([...Object.keys(stored.snapshot.properties), ...Object.keys(current.properties)]);
105
+ for (const prop of allProps) {
106
+ const before = stored.snapshot.properties[prop] || "";
107
+ const after = current.properties[prop] || "";
108
+ if (before === after) continue;
109
+ if (!before) {
110
+ changes.push({
111
+ type: "added",
112
+ property: prop,
113
+ after
114
+ });
115
+ } else if (!after) {
116
+ changes.push({
117
+ type: "removed",
118
+ property: prop,
119
+ before
120
+ });
121
+ } else {
122
+ changes.push({
123
+ type: "changed",
124
+ property: prop,
125
+ before,
126
+ after
127
+ });
128
+ }
129
+ }
130
+ const boundingRectChanges = [];
131
+ const rectKeys = ["x", "y", "width", "height"];
132
+ for (const key of rectKeys) {
133
+ const b = stored.snapshot.boundingRect[key];
134
+ const a = current.boundingRect[key];
135
+ if (b !== a) {
136
+ boundingRectChanges.push({
137
+ key,
138
+ before: b,
139
+ after: a
140
+ });
141
+ }
142
+ }
143
+ return {
144
+ snapshotId,
145
+ selector: stored.selector,
146
+ index: stored.index,
147
+ takenAt: stored.takenAt,
148
+ formatted: formatSnapshotDiff(stored.snapshot, current, stored.label),
149
+ changes,
150
+ boundingRectChanges
151
+ };
152
+ }
153
+ export function clearNamedSnapshot(snapshotId) {
154
+ const storage = getStorage();
155
+ if (!storage) return;
156
+ try {
157
+ storage.removeItem(storageKey(snapshotId));
158
+ } catch {
159
+ // Swallow — same rationale as getStorage fallbacks.
160
+ }
161
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,85 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, beforeEach } from "vitest";
3
+ import { takeNamedSnapshot, getNamedSnapshotDiff, clearNamedSnapshot, NamedSnapshotError } from "./namedSnapshots";
4
+ describe("namedSnapshots", () => {
5
+ beforeEach(() => {
6
+ localStorage.clear();
7
+ document.body.innerHTML = "";
8
+ });
9
+ it("persists a snapshot under the given id and reports property count", () => {
10
+ document.body.innerHTML = `<button id="b">hi</button>`;
11
+ const result = takeNamedSnapshot("#b", "baseline");
12
+ expect(result.snapshotId).toBe("baseline");
13
+ expect(result.selector).toBe("#b");
14
+ expect(result.index).toBe(0);
15
+ expect(result.propertyCount).toBeGreaterThan(0);
16
+ expect(localStorage.getItem("treelocator:snapshot:baseline")).not.toBeNull();
17
+ });
18
+ it("returns no-op diff when nothing changed", () => {
19
+ document.body.innerHTML = `<button id="b" style="color: red">hi</button>`;
20
+ takeNamedSnapshot("#b", "baseline");
21
+ const diff = getNamedSnapshotDiff("baseline");
22
+ expect(diff.changes).toEqual([]);
23
+ expect(diff.boundingRectChanges).toEqual([]);
24
+ expect(diff.formatted).toContain("No changes detected");
25
+ });
26
+ it("reports property transitions without mutating the baseline", () => {
27
+ document.body.innerHTML = `<button id="b" style="color: rgb(255, 0, 0)">hi</button>`;
28
+ takeNamedSnapshot("#b", "baseline");
29
+ const button = document.getElementById("b");
30
+ button.style.color = "rgb(0, 128, 0)";
31
+ const first = getNamedSnapshotDiff("baseline");
32
+ const colorChange = first.changes.find(c => c.property === "color");
33
+ expect(colorChange).toBeDefined();
34
+ expect(colorChange).toMatchObject({
35
+ type: "changed",
36
+ before: "rgb(255, 0, 0)",
37
+ after: "rgb(0, 128, 0)"
38
+ });
39
+
40
+ // Revert and diff again — the baseline must still be the original red.
41
+ button.style.color = "rgb(255, 0, 0)";
42
+ const second = getNamedSnapshotDiff("baseline");
43
+ expect(second.changes.find(c => c.property === "color")).toBeUndefined();
44
+ });
45
+ it("overwrites baseline only when takeSnapshot is called again", () => {
46
+ document.body.innerHTML = `<button id="b" style="opacity: 1">hi</button>`;
47
+ takeNamedSnapshot("#b", "baseline");
48
+ const button = document.getElementById("b");
49
+ button.style.opacity = "0.5";
50
+
51
+ // Re-taking replaces baseline with current state.
52
+ takeNamedSnapshot("#b", "baseline");
53
+ const diff = getNamedSnapshotDiff("baseline");
54
+ expect(diff.changes.find(c => c.property === "opacity")).toBeUndefined();
55
+ });
56
+ it("throws snapshot_not_found for unknown id", () => {
57
+ expect(() => getNamedSnapshotDiff("nope")).toThrow(NamedSnapshotError);
58
+ });
59
+ it("throws element_not_found when baseline selector no longer resolves", () => {
60
+ document.body.innerHTML = `<button id="b">hi</button>`;
61
+ takeNamedSnapshot("#b", "baseline");
62
+ document.body.innerHTML = "";
63
+ expect(() => getNamedSnapshotDiff("baseline")).toThrow(/element_not_found|No element found/i);
64
+ });
65
+ it("clearNamedSnapshot removes the baseline", () => {
66
+ document.body.innerHTML = `<button id="b">hi</button>`;
67
+ takeNamedSnapshot("#b", "baseline");
68
+ clearNamedSnapshot("baseline");
69
+ expect(localStorage.getItem("treelocator:snapshot:baseline")).toBeNull();
70
+ expect(() => getNamedSnapshotDiff("baseline")).toThrow(NamedSnapshotError);
71
+ });
72
+ it("respects `index` option when multiple elements match", () => {
73
+ document.body.innerHTML = `
74
+ <button class="t" style="color: rgb(255, 0, 0)">one</button>
75
+ <button class="t" style="color: rgb(0, 0, 255)">two</button>
76
+ `;
77
+ takeNamedSnapshot(".t", "second", {
78
+ index: 1
79
+ });
80
+ const diff = getNamedSnapshotDiff("second");
81
+ expect(diff.selector).toBe(".t");
82
+ expect(diff.index).toBe(1);
83
+ expect(diff.changes).toEqual([]);
84
+ });
85
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,66 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { normalizeFilePath } from "./normalizeFilePath";
3
+ describe("normalizeFilePath", () => {
4
+ test("relative path unchanged", () => {
5
+ const result = normalizeFilePath("src/App.tsx");
6
+ expect(result).toBe("src/App.tsx");
7
+ });
8
+ test("relative path with components unchanged", () => {
9
+ const result = normalizeFilePath("components/Button.tsx");
10
+ expect(result).toBe("components/Button.tsx");
11
+ });
12
+ test("absolute path with /src/ indicator", () => {
13
+ const result = normalizeFilePath("/Users/name/project/src/App.tsx");
14
+ expect(result).toBe("src/App.tsx");
15
+ });
16
+ test("absolute path with /app/ indicator", () => {
17
+ const result = normalizeFilePath("/workspace/apps/next-16/app/page.tsx");
18
+ expect(result).toBe("app/page.tsx");
19
+ });
20
+ test("absolute path with /pages/ indicator", () => {
21
+ const result = normalizeFilePath("/home/project/pages/index.tsx");
22
+ expect(result).toBe("pages/index.tsx");
23
+ });
24
+ test("absolute path with /components/ indicator", () => {
25
+ const result = normalizeFilePath("/home/project/components/Header.tsx");
26
+ expect(result).toBe("components/Header.tsx");
27
+ });
28
+ test("absolute path with /lib/ indicator", () => {
29
+ const result = normalizeFilePath("/home/project/lib/utils.ts");
30
+ expect(result).toBe("lib/utils.ts");
31
+ });
32
+ test("absolute path without indicator uses last 4 segments", () => {
33
+ const result = normalizeFilePath("/a/b/c/d/e/f/g.ts");
34
+ // Parts: ['', 'a', 'b', 'c', 'd', 'e', 'f', 'g.ts']
35
+ // Last 4: ['d', 'e', 'f', 'g.ts']
36
+ expect(result).toBe("d/e/f/g.ts");
37
+ });
38
+ test("absolute path with only 3 segments returns as-is", () => {
39
+ const result = normalizeFilePath("/home/project/file.ts");
40
+ expect(result).toBe("/home/project/file.ts");
41
+ });
42
+ test("absolute path with 2 segments returns as-is", () => {
43
+ const result = normalizeFilePath("/home/file.ts");
44
+ expect(result).toBe("/home/file.ts");
45
+ });
46
+ test("single segment path returns as-is", () => {
47
+ const result = normalizeFilePath("/file.ts");
48
+ expect(result).toBe("/file.ts");
49
+ });
50
+ test("deep nested path without indicator gets last 4 segments", () => {
51
+ const result = normalizeFilePath("/home/user/projects/nomarker/src/components/Button/index.tsx");
52
+ // Parts: ['', 'home', 'user', 'projects', 'nomarker', 'src', 'components', 'Button', 'index.tsx']
53
+ // But /src/ is in indicators, so it matches and returns from /src/ onwards
54
+ expect(result).toBe("src/components/Button/index.tsx");
55
+ });
56
+ test("uses first matching indicator when multiple present", () => {
57
+ const result = normalizeFilePath("/home/project/src/app/components/Header.tsx");
58
+ // /app/ comes before /src/ in the indicators array, and both are present
59
+ // /app/ is found first, so it returns from /app/ onwards
60
+ expect(result).toBe("app/components/Header.tsx");
61
+ });
62
+ test("empty string unchanged", () => {
63
+ const result = normalizeFilePath("");
64
+ expect(result).toBe("");
65
+ });
66
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,101 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { parseDataId, parseDataPath, splitFullPath } from "./parseDataId";
3
+ describe("parseDataId", () => {
4
+ test("happy path with valid dataId", () => {
5
+ const result = parseDataId("path/file.tsx::0");
6
+ expect(result).toEqual(["path/file.tsx", "0"]);
7
+ });
8
+ test("throws on missing double colon separator", () => {
9
+ expect(() => parseDataId("path/file.tsx:0")).toThrow("locatorjsId is malformed");
10
+ });
11
+ test("throws on missing file path", () => {
12
+ expect(() => parseDataId("::0")).toThrow("locatorjsId is malformed");
13
+ });
14
+ test("throws on missing id", () => {
15
+ expect(() => parseDataId("path/file.tsx::")).toThrow("locatorjsId is malformed");
16
+ });
17
+ test("throws on empty string", () => {
18
+ expect(() => parseDataId("")).toThrow("locatorjsId is malformed");
19
+ });
20
+ test("handles complex paths with multiple slashes", () => {
21
+ const result = parseDataId("/home/user/projects/src/components/Button.tsx::abc123");
22
+ expect(result).toEqual(["/home/user/projects/src/components/Button.tsx", "abc123"]);
23
+ });
24
+ });
25
+ describe("parseDataPath", () => {
26
+ test("standard unix path with line and column", () => {
27
+ const result = parseDataPath("/path/file.tsx:12:3");
28
+ expect(result).toEqual(["/path/file.tsx", 12, 3]);
29
+ });
30
+ test("Windows path with line and column", () => {
31
+ const result = parseDataPath("C:\\path\\file.tsx:12:3");
32
+ expect(result).toEqual(["C:\\path\\file.tsx", 12, 3]);
33
+ });
34
+ test("complex absolute path", () => {
35
+ const result = parseDataPath("/home/user/projects/src/components/Button.tsx:42:15");
36
+ expect(result).toEqual(["/home/user/projects/src/components/Button.tsx", 42, 15]);
37
+ });
38
+ test("returns null when missing colon", () => {
39
+ const result = parseDataPath("/path/file.tsx12:3");
40
+ expect(result).toBeNull();
41
+ });
42
+ test("returns null when only one colon", () => {
43
+ const result = parseDataPath("/path/file.tsx:12");
44
+ expect(result).toBeNull();
45
+ });
46
+ test("returns null when line is not numeric", () => {
47
+ const result = parseDataPath("/path/file.tsx:abc:3");
48
+ expect(result).toBeNull();
49
+ });
50
+ test("returns null when column is not numeric", () => {
51
+ const result = parseDataPath("/path/file.tsx:12:xyz");
52
+ expect(result).toBeNull();
53
+ });
54
+ test("returns null when no colons present", () => {
55
+ const result = parseDataPath("/path/file.tsx");
56
+ expect(result).toBeNull();
57
+ });
58
+ test("handles zero as valid line and column numbers", () => {
59
+ const result = parseDataPath("/path/file.tsx:0:0");
60
+ expect(result).toEqual(["/path/file.tsx", 0, 0]);
61
+ });
62
+ });
63
+ describe("splitFullPath", () => {
64
+ test("splits on /src/ indicator", () => {
65
+ const result = splitFullPath("/home/user/project/src/components/Button.tsx");
66
+ expect(result).toEqual(["/home/user/project", "/src/components/Button.tsx"]);
67
+ });
68
+ test("splits on /app/ indicator", () => {
69
+ const result = splitFullPath("/workspace/myapp/app/page.tsx");
70
+ expect(result).toEqual(["/workspace/myapp", "/app/page.tsx"]);
71
+ });
72
+ test("splits on /pages/ indicator", () => {
73
+ const result = splitFullPath("/home/project/pages/index.tsx");
74
+ expect(result).toEqual(["/home/project", "/pages/index.tsx"]);
75
+ });
76
+ test("splits on /components/ indicator", () => {
77
+ const result = splitFullPath("/home/project/components/Header.tsx");
78
+ expect(result).toEqual(["/home/project", "/components/Header.tsx"]);
79
+ });
80
+ test("uses first matching indicator when multiple present", () => {
81
+ const result = splitFullPath("/home/project/src/pages/index.tsx");
82
+ // /src/ comes first in the indicators array
83
+ expect(result).toEqual(["/home/project", "/src/pages/index.tsx"]);
84
+ });
85
+ test("fallback to last slash when no indicator found", () => {
86
+ const result = splitFullPath("/home/user/myfile.txt");
87
+ expect(result).toEqual(["/home/user/", "myfile.txt"]);
88
+ });
89
+ test("handles path with no slashes", () => {
90
+ const result = splitFullPath("filename.txt");
91
+ expect(result).toEqual(["filename.txt", ""]);
92
+ });
93
+ test("handles empty path", () => {
94
+ const result = splitFullPath("");
95
+ expect(result).toEqual(["", ""]);
96
+ });
97
+ test("handles root path", () => {
98
+ const result = splitFullPath("/");
99
+ expect(result).toEqual(["/", ""]);
100
+ });
101
+ });
@@ -0,0 +1,3 @@
1
+ type StorageLike = Pick<Storage, "getItem" | "setItem" | "removeItem">;
2
+ export declare function getStorage(): StorageLike | null;
3
+ export {};
@@ -0,0 +1,17 @@
1
+ function isStorageLike(value) {
2
+ if (!value || typeof value !== "object") return false;
3
+ const candidate = value;
4
+ return typeof candidate.getItem === "function" && typeof candidate.setItem === "function" && typeof candidate.removeItem === "function";
5
+ }
6
+ export function getStorage() {
7
+ try {
8
+ if (typeof window !== "undefined" && isStorageLike(window.localStorage)) {
9
+ return window.localStorage;
10
+ }
11
+ } catch {
12
+ // Ignore localStorage access errors (SSR, permissions, opaque origin)
13
+ }
14
+ const candidate = globalThis.localStorage;
15
+ if (isStorageLike(candidate)) return candidate;
16
+ return null;
17
+ }
@@ -0,0 +1,15 @@
1
+ export interface EventListenerCallbacks {
2
+ mouseOverListener: (e: MouseEvent) => void;
3
+ mouseMoveListener: (e: MouseEvent) => void;
4
+ keyDownListener: (e: KeyboardEvent) => void;
5
+ keyUpListener: (e: KeyboardEvent) => void;
6
+ clickListener: (e: MouseEvent) => void;
7
+ mouseDownUpListener: (e: MouseEvent) => void;
8
+ scrollListener: () => void;
9
+ onCleanup?: () => void;
10
+ }
11
+ /**
12
+ * Wire up event listeners across the document and all shadow roots,
13
+ * and clean them up when the component unmounts.
14
+ */
15
+ export declare function useEventListeners(callbacks: EventListenerCallbacks): void;
@@ -0,0 +1,56 @@
1
+ import { onCleanup } from "solid-js";
2
+ /**
3
+ * Wire up event listeners across the document and all shadow roots,
4
+ * and clean them up when the component unmounts.
5
+ */
6
+ export function useEventListeners(callbacks) {
7
+ const roots = [document];
8
+ document.querySelectorAll("*").forEach(node => {
9
+ if (node.id === "locatorjs-wrapper") {
10
+ return;
11
+ }
12
+ if (node.shadowRoot) {
13
+ roots.push(node.shadowRoot);
14
+ }
15
+ });
16
+ for (const root of roots) {
17
+ root.addEventListener("mouseover", callbacks.mouseOverListener, {
18
+ capture: true
19
+ });
20
+ root.addEventListener("mousemove", callbacks.mouseMoveListener, {
21
+ capture: true
22
+ });
23
+ root.addEventListener("keydown", callbacks.keyDownListener);
24
+ root.addEventListener("keyup", callbacks.keyUpListener);
25
+ root.addEventListener("click", callbacks.clickListener, {
26
+ capture: true
27
+ });
28
+ root.addEventListener("mousedown", callbacks.mouseDownUpListener, {
29
+ capture: true
30
+ });
31
+ root.addEventListener("mouseup", callbacks.mouseDownUpListener, {
32
+ capture: true
33
+ });
34
+ root.addEventListener("scroll", callbacks.scrollListener);
35
+ }
36
+ onCleanup(() => {
37
+ callbacks.onCleanup?.();
38
+ for (const root of roots) {
39
+ root.removeEventListener("keyup", callbacks.keyUpListener);
40
+ root.removeEventListener("keydown", callbacks.keyDownListener);
41
+ root.removeEventListener("mouseover", callbacks.mouseOverListener, {
42
+ capture: true
43
+ });
44
+ root.removeEventListener("click", callbacks.clickListener, {
45
+ capture: true
46
+ });
47
+ root.removeEventListener("mousedown", callbacks.mouseDownUpListener, {
48
+ capture: true
49
+ });
50
+ root.removeEventListener("mouseup", callbacks.mouseDownUpListener, {
51
+ capture: true
52
+ });
53
+ root.removeEventListener("scroll", callbacks.scrollListener);
54
+ }
55
+ });
56
+ }
@@ -0,0 +1,18 @@
1
+ import type { DejitterFinding, DejitterSummary } from "../dejitter/recorder";
2
+ import type { InteractionEvent } from "../components/RecordingResults";
3
+ import type { DeltaReport } from "../visualDiff/types";
4
+ export declare const STORAGE_KEY = "__treelocator_recording__";
5
+ export type SavedRecording = {
6
+ findings: DejitterFinding[];
7
+ summary: DejitterSummary | null;
8
+ data: any;
9
+ elementPath: string;
10
+ interactions: InteractionEvent[];
11
+ visualDiff?: DeltaReport | null;
12
+ };
13
+ export declare function loadFromStorage(): {
14
+ last: SavedRecording | null;
15
+ previous: SavedRecording | null;
16
+ };
17
+ export declare function saveToStorage(current: SavedRecording): void;
18
+ export declare function clearStorage(): void;
@@ -0,0 +1,41 @@
1
+ import { getStorage } from "./getStorage";
2
+ export const STORAGE_KEY = "__treelocator_recording__";
3
+ export function loadFromStorage() {
4
+ const storage = getStorage();
5
+ if (!storage) return {
6
+ last: null,
7
+ previous: null
8
+ };
9
+ try {
10
+ const raw = storage.getItem(STORAGE_KEY);
11
+ if (raw) return JSON.parse(raw);
12
+ } catch {
13
+ // Ignore localStorage errors (SSR, permissions, quota)
14
+ }
15
+ return {
16
+ last: null,
17
+ previous: null
18
+ };
19
+ }
20
+ export function saveToStorage(current) {
21
+ const storage = getStorage();
22
+ if (!storage) return;
23
+ try {
24
+ const stored = loadFromStorage();
25
+ storage.setItem(STORAGE_KEY, JSON.stringify({
26
+ last: current,
27
+ previous: stored.last
28
+ }));
29
+ } catch {
30
+ // Ignore localStorage errors (SSR, permissions, quota)
31
+ }
32
+ }
33
+ export function clearStorage() {
34
+ const storage = getStorage();
35
+ if (!storage) return;
36
+ try {
37
+ storage.removeItem(STORAGE_KEY);
38
+ } catch {
39
+ // Ignore localStorage errors (SSR, permissions, quota)
40
+ }
41
+ }
@@ -0,0 +1 @@
1
+ export {};