@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,127 @@
1
+ import { describe, expect, test, beforeAll, afterAll, beforeEach, vi } from "vitest";
2
+ import {
3
+ STORAGE_KEY,
4
+ loadFromStorage,
5
+ saveToStorage,
6
+ clearStorage,
7
+ } from "./useLocatorStorage";
8
+
9
+ const mockStorage = new Map<string, string>();
10
+
11
+ const mockLocalStorage = {
12
+ getItem(key: string): string | null {
13
+ return mockStorage.has(key) ? mockStorage.get(key)! : null;
14
+ },
15
+ setItem(key: string, value: string): void {
16
+ mockStorage.set(key, String(value));
17
+ },
18
+ removeItem(key: string): void {
19
+ mockStorage.delete(key);
20
+ },
21
+ clear(): void {
22
+ mockStorage.clear();
23
+ },
24
+ };
25
+
26
+ describe("useLocatorStorage", () => {
27
+ beforeAll(() => {
28
+ vi.stubGlobal("localStorage", mockLocalStorage);
29
+ });
30
+
31
+ afterAll(() => {
32
+ vi.unstubAllGlobals();
33
+ });
34
+
35
+ beforeEach(() => {
36
+ mockLocalStorage.clear();
37
+ });
38
+
39
+ describe("loadFromStorage", () => {
40
+ test("returns defaults when storage is empty", () => {
41
+ const result = loadFromStorage();
42
+ expect(result).toEqual({ last: null, previous: null });
43
+ });
44
+
45
+ test("returns parsed data from localStorage", () => {
46
+ const data = {
47
+ last: {
48
+ findings: [],
49
+ summary: null,
50
+ data: null,
51
+ elementPath: "div > button",
52
+ interactions: [],
53
+ },
54
+ previous: null,
55
+ };
56
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
57
+ const result = loadFromStorage();
58
+ expect(result).toEqual(data);
59
+ });
60
+
61
+ test("returns defaults on corrupt JSON", () => {
62
+ localStorage.setItem(STORAGE_KEY, "not-valid-json{{{");
63
+ const result = loadFromStorage();
64
+ expect(result).toEqual({ last: null, previous: null });
65
+ });
66
+ });
67
+
68
+ describe("saveToStorage", () => {
69
+ test("saves recording and shifts previous", () => {
70
+ const first = {
71
+ findings: [],
72
+ summary: null,
73
+ data: "first",
74
+ elementPath: "path1",
75
+ interactions: [],
76
+ };
77
+ const second = {
78
+ findings: [],
79
+ summary: null,
80
+ data: "second",
81
+ elementPath: "path2",
82
+ interactions: [],
83
+ };
84
+
85
+ saveToStorage(first as any);
86
+ let stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
87
+ expect(stored.last.data).toBe("first");
88
+ expect(stored.previous).toBeNull();
89
+
90
+ saveToStorage(second as any);
91
+ stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
92
+ expect(stored.last.data).toBe("second");
93
+ expect(stored.previous.data).toBe("first");
94
+ });
95
+ });
96
+
97
+ describe("clearStorage", () => {
98
+ test("removes the storage key", () => {
99
+ localStorage.setItem(STORAGE_KEY, "something");
100
+ clearStorage();
101
+ expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
102
+ });
103
+
104
+ test("does not throw when key does not exist", () => {
105
+ expect(() => clearStorage()).not.toThrow();
106
+ });
107
+ });
108
+
109
+ describe("roundtrip", () => {
110
+ test("save then load returns correct data", () => {
111
+ const recording = {
112
+ findings: [{ type: "jitter", severity: "low" }],
113
+ summary: { totalFindings: 1 },
114
+ data: { frames: 100 },
115
+ elementPath: "App > Header > Button",
116
+ interactions: [
117
+ { t: 0, type: "click", target: "button", x: 100, y: 200 },
118
+ ],
119
+ };
120
+
121
+ saveToStorage(recording as any);
122
+ const loaded = loadFromStorage();
123
+ expect(loaded.last).toEqual(recording);
124
+ expect(loaded.previous).toBeNull();
125
+ });
126
+ });
127
+ });
@@ -0,0 +1,60 @@
1
+ import type { DejitterFinding, DejitterSummary } from "../dejitter/recorder";
2
+ import type { InteractionEvent } from "../components/RecordingResults";
3
+ import type { DeltaReport } from "../visualDiff/types";
4
+ import { getStorage } from "./getStorage";
5
+
6
+ export const STORAGE_KEY = "__treelocator_recording__";
7
+
8
+ export type SavedRecording = {
9
+ findings: DejitterFinding[];
10
+ summary: DejitterSummary | null;
11
+ data: any;
12
+ elementPath: string;
13
+ interactions: InteractionEvent[];
14
+ visualDiff?: DeltaReport | null;
15
+ };
16
+
17
+ export function loadFromStorage(): {
18
+ last: SavedRecording | null;
19
+ previous: SavedRecording | null;
20
+ } {
21
+ const storage = getStorage();
22
+ if (!storage) return { last: null, previous: null };
23
+
24
+ try {
25
+ const raw = storage.getItem(STORAGE_KEY);
26
+ if (raw) return JSON.parse(raw);
27
+ } catch {
28
+ // Ignore localStorage errors (SSR, permissions, quota)
29
+ }
30
+ return { last: null, previous: null };
31
+ }
32
+
33
+ export function saveToStorage(current: SavedRecording): void {
34
+ const storage = getStorage();
35
+ if (!storage) return;
36
+
37
+ try {
38
+ const stored = loadFromStorage();
39
+ storage.setItem(
40
+ STORAGE_KEY,
41
+ JSON.stringify({
42
+ last: current,
43
+ previous: stored.last,
44
+ })
45
+ );
46
+ } catch {
47
+ // Ignore localStorage errors (SSR, permissions, quota)
48
+ }
49
+ }
50
+
51
+ export function clearStorage(): void {
52
+ const storage = getStorage();
53
+ if (!storage) return;
54
+
55
+ try {
56
+ storage.removeItem(STORAGE_KEY);
57
+ } catch {
58
+ // Ignore localStorage errors (SSR, permissions, quota)
59
+ }
60
+ }
@@ -0,0 +1,516 @@
1
+ import { createSignal, type Accessor } from "solid-js";
2
+ import { AdapterId } from "../consts";
3
+ import {
4
+ createDejitterRecorder,
5
+ type DejitterAPI,
6
+ type DejitterConfig,
7
+ type DejitterFinding,
8
+ type DejitterSummary,
9
+ } from "../dejitter/recorder";
10
+ import type { InteractionEvent } from "../components/RecordingResults";
11
+ import { isLocatorsOwnElement } from "../functions/isLocatorsOwnElement";
12
+ import { collectAncestry, formatAncestryChain } from "../functions/formatAncestryChain";
13
+ import { createTreeNode } from "../adapters/createTreeNode";
14
+ import {
15
+ loadFromStorage,
16
+ saveToStorage,
17
+ clearStorage,
18
+ } from "./useLocatorStorage";
19
+ import { settings } from "./useSettings";
20
+ import { takeSnapshot } from "../visualDiff/snapshot";
21
+ import { computeDiff, formatReport } from "../visualDiff/diff";
22
+ import { waitForSettle } from "../visualDiff/settle";
23
+ import type { DeltaReport, ElementSnapshot } from "../visualDiff/types";
24
+
25
+ export type RecordingState = "idle" | "selecting" | "recording" | "results";
26
+
27
+ function buildDejitterConfig(): Partial<DejitterConfig> {
28
+ const s = settings();
29
+ return {
30
+ selector: "[data-treelocator-recording]",
31
+ props: ["opacity", "transform", "boundingRect", "width", "height"],
32
+ sampleRate: s.sampleRate,
33
+ maxDuration: s.maxDurationMs,
34
+ idleTimeout: 0,
35
+ mutations: true,
36
+ thresholds: {
37
+ jump: { minAbsolute: s.jumpMinAbsolute },
38
+ lag: { minDelay: s.lagMinDelay },
39
+ },
40
+ } as Partial<DejitterConfig>;
41
+ }
42
+
43
+ export interface RecordingStateAPI {
44
+ recordingState: Accessor<RecordingState>;
45
+ recordedElement: Accessor<HTMLElement | null>;
46
+ recordingFindings: Accessor<DejitterFinding[]>;
47
+ recordingSummary: Accessor<DejitterSummary | null>;
48
+ recordingData: Accessor<any>;
49
+ recordingElementPath: Accessor<string>;
50
+ interactionLog: Accessor<InteractionEvent[]>;
51
+ visualDiff: Accessor<DeltaReport | null>;
52
+ replayBox: Accessor<{ x: number; y: number; w: number; h: number } | null>;
53
+ replaying: Accessor<boolean>;
54
+ viewingPrevious: Accessor<boolean>;
55
+ handleRecordClick: () => void;
56
+ startRecording: (element: HTMLElement) => void;
57
+ stopRecording: () => Promise<void>;
58
+ replayRecording: () => void;
59
+ stopReplay: () => void;
60
+ replayWithRecord: (
61
+ elementOrSelector: HTMLElement | string
62
+ ) => Promise<{
63
+ path: string;
64
+ findings: DejitterFinding[];
65
+ summary: DejitterSummary | null;
66
+ data: any;
67
+ interactions: InteractionEvent[];
68
+ visualDiff: DeltaReport | null;
69
+ } | null>;
70
+ dismissResults: () => void;
71
+ hasPreviousRecording: () => boolean;
72
+ loadPreviousRecording: () => void;
73
+ loadLatestRecording: () => void;
74
+ cleanup: () => void;
75
+ }
76
+
77
+ export function useRecordingState(adapterId?: AdapterId): RecordingStateAPI {
78
+ // Restore last results on mount
79
+ const restored = loadFromStorage();
80
+ const restoredLast = restored.last;
81
+
82
+ const [recordingState, setRecordingState] = createSignal<RecordingState>(
83
+ restoredLast ? "results" : "idle"
84
+ );
85
+ const [recordedElement, setRecordedElement] =
86
+ createSignal<HTMLElement | null>(null);
87
+ const [recordingFindings, setRecordingFindings] = createSignal<
88
+ DejitterFinding[]
89
+ >(restoredLast?.findings ?? []);
90
+ const [recordingSummary, setRecordingSummary] =
91
+ createSignal<DejitterSummary | null>(restoredLast?.summary ?? null);
92
+ const [interactionLog, setInteractionLog] = createSignal<InteractionEvent[]>(
93
+ restoredLast?.interactions ?? []
94
+ );
95
+ const [recordingData, setRecordingData] = createSignal<any>(
96
+ restoredLast?.data ?? null
97
+ );
98
+ const [recordingElementPath, setRecordingElementPath] = createSignal<string>(
99
+ restoredLast?.elementPath ?? ""
100
+ );
101
+ const [visualDiff, setVisualDiff] = createSignal<DeltaReport | null>(
102
+ restoredLast?.visualDiff ?? null
103
+ );
104
+ const [replayBox, setReplayBox] = createSignal<{
105
+ x: number;
106
+ y: number;
107
+ w: number;
108
+ h: number;
109
+ } | null>(null);
110
+ const [replaying, setReplaying] = createSignal(false);
111
+ const [viewingPrevious, setViewingPrevious] = createSignal(false);
112
+
113
+ let dejitterInstance: DejitterAPI | null = null;
114
+ let interactionClickHandler: ((e: MouseEvent) => void) | null = null;
115
+ let recordingStartTime = 0;
116
+ let recordingStartPerf = 0;
117
+ let replayTimerId: number | null = null;
118
+ let visualDiffContext: {
119
+ before: ElementSnapshot[];
120
+ root: HTMLElement;
121
+ } | null = null;
122
+
123
+ async function finalizeVisualDiff(): Promise<DeltaReport | null> {
124
+ if (!visualDiffContext) return null;
125
+ const { before, root } = visualDiffContext;
126
+ visualDiffContext = null;
127
+ const settle = await waitForSettle(1000, root);
128
+ const after = takeSnapshot(root);
129
+ const report = computeDiff(before, after);
130
+ report.elapsedMs = performance.now() - recordingStartPerf;
131
+ report.settle = settle;
132
+ report.text = formatReport(report.entries, {
133
+ elapsedMs: report.elapsedMs,
134
+ settle,
135
+ });
136
+ return report;
137
+ }
138
+
139
+ function collectElementPath(el: HTMLElement): string {
140
+ const treeNode = createTreeNode(el, adapterId);
141
+ if (treeNode) {
142
+ const ancestry = collectAncestry(treeNode);
143
+ return formatAncestryChain(ancestry);
144
+ }
145
+ return "";
146
+ }
147
+
148
+ // --- Recording lifecycle ---
149
+
150
+ function handleRecordClick() {
151
+ switch (recordingState()) {
152
+ case "idle":
153
+ setRecordingState("selecting");
154
+ break;
155
+ case "selecting":
156
+ setRecordingState("idle");
157
+ break;
158
+ case "recording":
159
+ stopRecording();
160
+ break;
161
+ case "results":
162
+ dismissResults();
163
+ setRecordingState("selecting");
164
+ break;
165
+ }
166
+ }
167
+
168
+ function startRecording(element: HTMLElement) {
169
+ element.setAttribute("data-treelocator-recording", "true");
170
+ setRecordedElement(element);
171
+
172
+ recordingStartPerf = performance.now();
173
+ visualDiffContext = settings().visualDiff
174
+ ? { before: takeSnapshot(element), root: element }
175
+ : null;
176
+
177
+ dejitterInstance = createDejitterRecorder();
178
+ dejitterInstance.configure(buildDejitterConfig());
179
+ dejitterInstance.start();
180
+ startInteractionTracker();
181
+ setRecordingState("recording");
182
+ }
183
+
184
+ async function stopRecording() {
185
+ const instance = dejitterInstance;
186
+ if (!instance) return;
187
+ dejitterInstance = null;
188
+ instance.stop();
189
+ const anomalyEnabled = settings().anomalyTracking;
190
+ const findings = anomalyEnabled
191
+ ? (instance.findings(true) as DejitterFinding[])
192
+ : [];
193
+ const summary = instance.summary(true) as DejitterSummary;
194
+ const data = anomalyEnabled ? instance.getData() : null;
195
+
196
+ const el = recordedElement();
197
+ const elementPath = el ? collectElementPath(el) : "";
198
+
199
+ stopInteractionTracker();
200
+ const diffReport = await finalizeVisualDiff();
201
+
202
+ setRecordingFindings(findings);
203
+ setRecordingSummary(summary);
204
+ setRecordingData(data);
205
+ setRecordingElementPath(elementPath);
206
+ setVisualDiff(diffReport);
207
+
208
+ saveToStorage({
209
+ findings,
210
+ summary,
211
+ data,
212
+ elementPath,
213
+ interactions: interactionLog(),
214
+ visualDiff: diffReport,
215
+ });
216
+
217
+ el?.removeAttribute("data-treelocator-recording");
218
+ setRecordingState("results");
219
+ }
220
+
221
+ function replayRecording() {
222
+ const events = interactionLog();
223
+ if (events.length === 0) return;
224
+
225
+ stopReplay();
226
+ setReplaying(true);
227
+
228
+ let eventIdx = 0;
229
+
230
+ function scheduleNext() {
231
+ if (eventIdx >= events.length) {
232
+ stopReplay();
233
+ return;
234
+ }
235
+
236
+ const evt = events[eventIdx]!;
237
+ const delay = eventIdx === 0 ? 100 : evt.t - events[eventIdx - 1]!.t;
238
+
239
+ replayTimerId = window.setTimeout(() => {
240
+ setReplayBox({ x: evt.x - 12, y: evt.y - 12, w: 24, h: 24 });
241
+
242
+ const target = document.elementFromPoint(evt.x, evt.y);
243
+ if (target) {
244
+ target.dispatchEvent(
245
+ new MouseEvent("click", {
246
+ bubbles: true,
247
+ cancelable: true,
248
+ clientX: evt.x,
249
+ clientY: evt.y,
250
+ view: window,
251
+ })
252
+ );
253
+ }
254
+
255
+ window.setTimeout(() => setReplayBox(null), 200);
256
+
257
+ eventIdx++;
258
+ scheduleNext();
259
+ }, Math.max(delay, 50));
260
+ }
261
+
262
+ scheduleNext();
263
+ }
264
+
265
+ function stopReplay() {
266
+ if (replayTimerId) {
267
+ clearTimeout(replayTimerId);
268
+ replayTimerId = null;
269
+ }
270
+ setReplaying(false);
271
+ setReplayBox(null);
272
+ }
273
+
274
+ function replayWithRecord(
275
+ elementOrSelector: HTMLElement | string
276
+ ): Promise<{
277
+ path: string;
278
+ findings: DejitterFinding[];
279
+ summary: DejitterSummary | null;
280
+ data: any;
281
+ interactions: InteractionEvent[];
282
+ visualDiff: DeltaReport | null;
283
+ } | null> {
284
+ let element: HTMLElement | null;
285
+ if (typeof elementOrSelector === "string") {
286
+ const found = document.querySelector(elementOrSelector);
287
+ element = found instanceof HTMLElement ? found : null;
288
+ } else {
289
+ element = elementOrSelector;
290
+ }
291
+ if (!element) return Promise.resolve(null);
292
+
293
+ const stored = loadFromStorage();
294
+ const events = stored.last?.interactions ?? interactionLog();
295
+ if (events.length === 0) return Promise.resolve(null);
296
+
297
+ return new Promise((resolve) => {
298
+ element!.setAttribute("data-treelocator-recording", "true");
299
+ setRecordedElement(element);
300
+
301
+ recordingStartPerf = performance.now();
302
+ visualDiffContext = settings().visualDiff
303
+ ? { before: takeSnapshot(element!), root: element! }
304
+ : null;
305
+
306
+ dejitterInstance = createDejitterRecorder();
307
+ dejitterInstance.configure(buildDejitterConfig());
308
+ dejitterInstance.start();
309
+ setRecordingState("recording");
310
+ setReplaying(true);
311
+
312
+ let eventIdx = 0;
313
+
314
+ async function finishRecording() {
315
+ setReplaying(false);
316
+ setReplayBox(null);
317
+
318
+ const instance = dejitterInstance;
319
+ if (!instance) {
320
+ resolve(null);
321
+ return;
322
+ }
323
+ dejitterInstance = null;
324
+ instance.stop();
325
+ const anomalyEnabled = settings().anomalyTracking;
326
+ const findings = anomalyEnabled
327
+ ? (instance.findings(true) as DejitterFinding[])
328
+ : [];
329
+ const summary = instance.summary(true) as DejitterSummary;
330
+ const data = anomalyEnabled ? instance.getData() : null;
331
+
332
+ const el = recordedElement();
333
+ const elementPath = el ? collectElementPath(el) : "";
334
+
335
+ const diffReport = await finalizeVisualDiff();
336
+
337
+ setRecordingFindings(findings);
338
+ setRecordingSummary(summary);
339
+ setRecordingData(data);
340
+ setRecordingElementPath(elementPath);
341
+ setInteractionLog(events);
342
+ setVisualDiff(diffReport);
343
+
344
+ saveToStorage({
345
+ findings,
346
+ summary,
347
+ data,
348
+ elementPath,
349
+ interactions: events,
350
+ visualDiff: diffReport,
351
+ });
352
+
353
+ el?.removeAttribute("data-treelocator-recording");
354
+ setRecordingState("results");
355
+
356
+ resolve({
357
+ path: elementPath,
358
+ findings,
359
+ summary,
360
+ data,
361
+ interactions: events,
362
+ visualDiff: diffReport,
363
+ });
364
+ }
365
+
366
+ function scheduleNext() {
367
+ if (eventIdx >= events.length) {
368
+ replayTimerId = window.setTimeout(finishRecording, 500);
369
+ return;
370
+ }
371
+
372
+ const evt = events[eventIdx]!;
373
+ const delay = eventIdx === 0 ? 100 : evt.t - events[eventIdx - 1]!.t;
374
+
375
+ replayTimerId = window.setTimeout(() => {
376
+ setReplayBox({ x: evt.x - 12, y: evt.y - 12, w: 24, h: 24 });
377
+
378
+ const target = document.elementFromPoint(evt.x, evt.y);
379
+ if (target) {
380
+ target.dispatchEvent(
381
+ new MouseEvent("click", {
382
+ bubbles: true,
383
+ cancelable: true,
384
+ clientX: evt.x,
385
+ clientY: evt.y,
386
+ view: window,
387
+ })
388
+ );
389
+ }
390
+
391
+ window.setTimeout(() => setReplayBox(null), 200);
392
+
393
+ eventIdx++;
394
+ scheduleNext();
395
+ }, Math.max(delay, 50));
396
+ }
397
+
398
+ scheduleNext();
399
+ });
400
+ }
401
+
402
+ function dismissResults() {
403
+ stopReplay();
404
+ setRecordingFindings([]);
405
+ setRecordingSummary(null);
406
+ setRecordingData(null);
407
+ setRecordingElementPath("");
408
+ setInteractionLog([]);
409
+ setVisualDiff(null);
410
+ setRecordedElement(null);
411
+ setViewingPrevious(false);
412
+ setRecordingState("idle");
413
+ clearStorage();
414
+ }
415
+
416
+ function hasPreviousRecording(): boolean {
417
+ return loadFromStorage().previous !== null;
418
+ }
419
+
420
+ function loadPreviousRecording() {
421
+ const stored = loadFromStorage();
422
+ if (!stored.previous) return;
423
+ const prev = stored.previous;
424
+ setRecordingFindings(prev.findings);
425
+ setRecordingSummary(prev.summary);
426
+ setRecordingData(prev.data);
427
+ setRecordingElementPath(prev.elementPath);
428
+ setInteractionLog(prev.interactions);
429
+ setVisualDiff(prev.visualDiff ?? null);
430
+ setViewingPrevious(true);
431
+ setRecordingState("results");
432
+ }
433
+
434
+ function loadLatestRecording() {
435
+ const stored = loadFromStorage();
436
+ if (!stored.last) return;
437
+ const last = stored.last;
438
+ setRecordingFindings(last.findings);
439
+ setRecordingSummary(last.summary);
440
+ setRecordingData(last.data);
441
+ setRecordingElementPath(last.elementPath);
442
+ setInteractionLog(last.interactions);
443
+ setVisualDiff(last.visualDiff ?? null);
444
+ setViewingPrevious(false);
445
+ setRecordingState("results");
446
+ }
447
+
448
+ // --- Interaction tracking ---
449
+
450
+ function startInteractionTracker() {
451
+ recordingStartTime = performance.now();
452
+ setInteractionLog([]);
453
+ interactionClickHandler = (e: MouseEvent) => {
454
+ if (isLocatorsOwnElement(e.target as HTMLElement)) return;
455
+ const el = e.target as HTMLElement;
456
+ const tag = el.tagName?.toLowerCase() || "unknown";
457
+ const id = el.id ? "#" + el.id : "";
458
+ const cls =
459
+ el.className && typeof el.className === "string"
460
+ ? "." + el.className.split(" ")[0]
461
+ : "";
462
+ setInteractionLog((prev) => [
463
+ ...prev,
464
+ {
465
+ t: Math.round(performance.now() - recordingStartTime),
466
+ type: "click",
467
+ target: `${tag}${id}${cls}`,
468
+ x: e.clientX,
469
+ y: e.clientY,
470
+ },
471
+ ]);
472
+ };
473
+ document.addEventListener("click", interactionClickHandler, {
474
+ capture: true,
475
+ });
476
+ }
477
+
478
+ function stopInteractionTracker() {
479
+ if (interactionClickHandler) {
480
+ document.removeEventListener("click", interactionClickHandler, {
481
+ capture: true,
482
+ });
483
+ interactionClickHandler = null;
484
+ }
485
+ }
486
+
487
+ function cleanup() {
488
+ stopReplay();
489
+ stopInteractionTracker();
490
+ }
491
+
492
+ return {
493
+ recordingState,
494
+ recordedElement,
495
+ recordingFindings,
496
+ recordingSummary,
497
+ recordingData,
498
+ recordingElementPath,
499
+ interactionLog,
500
+ visualDiff,
501
+ replayBox,
502
+ replaying,
503
+ viewingPrevious,
504
+ handleRecordClick,
505
+ startRecording,
506
+ stopRecording,
507
+ replayRecording,
508
+ stopReplay,
509
+ replayWithRecord,
510
+ dismissResults,
511
+ hasPreviousRecording,
512
+ loadPreviousRecording,
513
+ loadLatestRecording,
514
+ cleanup,
515
+ };
516
+ }