@sqaitech/recorder 0.5.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.
@@ -0,0 +1,348 @@
1
+ import { getElementXpath, isNotContainerElement } from "@sqaitech/shared/extractor";
2
+ const DEBUG = 'true' === localStorage.getItem('DEBUG');
3
+ function debugLog(...args) {
4
+ if (DEBUG) console.log('[EventRecorder]', ...args);
5
+ }
6
+ function generateHashId(type, elementRect) {
7
+ const rectStr = elementRect ? `${elementRect.left}_${elementRect.top}_${elementRect.width}_${elementRect.height}${void 0 !== elementRect.x ? `_${elementRect.x}` : ''}${void 0 !== elementRect.y ? `_${elementRect.y}` : ''}` : 'no_rect';
8
+ const combined = `${type}_${rectStr}`;
9
+ let hash = 0;
10
+ for(let i = 0; i < combined.length; i++){
11
+ const char = combined.charCodeAt(i);
12
+ hash = (hash << 5) - hash + char;
13
+ hash &= hash;
14
+ }
15
+ return Math.abs(hash).toString(36);
16
+ }
17
+ const isSameInputTarget = (event1, event2)=>event1.element === event2.element;
18
+ const isSameScrollTarget = (event1, event2)=>event1.element === event2.element;
19
+ const getLastLabelClick = (events)=>{
20
+ for(let i = events.length - 1; i >= 0; i--){
21
+ const event = events[i];
22
+ if ('click' === event.type && event.isLabelClick) return event;
23
+ }
24
+ };
25
+ function getAllScrollableElements() {
26
+ const elements = [];
27
+ const all = document.querySelectorAll('body *');
28
+ all.forEach((el)=>{
29
+ const style = window.getComputedStyle(el);
30
+ const overflowY = style.overflowY;
31
+ const overflowX = style.overflowX;
32
+ const isScrollableY = ('auto' === overflowY || 'scroll' === overflowY) && el.scrollHeight > el.clientHeight;
33
+ const isScrollableX = ('auto' === overflowX || 'scroll' === overflowX) && el.scrollWidth > el.clientWidth;
34
+ if (isScrollableY || isScrollableX) elements.push(el);
35
+ });
36
+ return elements;
37
+ }
38
+ class EventRecorder {
39
+ isRecording = false;
40
+ eventCallback;
41
+ scrollThrottleTimer = null;
42
+ scrollThrottleDelay = 200;
43
+ inputThrottleTimer = null;
44
+ inputThrottleDelay = 300;
45
+ lastViewportScroll = null;
46
+ scrollTargets = [];
47
+ sessionId;
48
+ constructor(eventCallback, sessionId){
49
+ this.eventCallback = eventCallback;
50
+ this.sessionId = sessionId;
51
+ }
52
+ createNavigationEvent(url, title) {
53
+ return {
54
+ type: 'navigation',
55
+ url,
56
+ title,
57
+ pageInfo: {
58
+ width: window.innerWidth,
59
+ height: window.innerHeight
60
+ },
61
+ timestamp: Date.now(),
62
+ hashId: `navigation_${Date.now()}`
63
+ };
64
+ }
65
+ start() {
66
+ if (this.isRecording) return void debugLog('Recording already active, ignoring start request');
67
+ this.isRecording = true;
68
+ debugLog('Starting event recording');
69
+ this.scrollTargets = [];
70
+ if (0 === this.scrollTargets.length) {
71
+ this.scrollTargets = getAllScrollableElements();
72
+ this.scrollTargets.push(document.body);
73
+ }
74
+ debugLog('Added event listeners for', this.scrollTargets.length, 'scroll targets');
75
+ setTimeout(()=>{
76
+ const navigationEvent = this.createNavigationEvent(window.location.href, document.title);
77
+ this.eventCallback(navigationEvent);
78
+ debugLog('Added final navigation event', navigationEvent);
79
+ }, 0);
80
+ document.addEventListener('click', this.handleClick, true);
81
+ document.addEventListener('input', this.handleInput);
82
+ document.addEventListener('scroll', this.handleScroll, {
83
+ passive: true
84
+ });
85
+ this.scrollTargets.forEach((target)=>{
86
+ target.addEventListener('scroll', this.handleScroll, {
87
+ passive: true
88
+ });
89
+ });
90
+ }
91
+ stop() {
92
+ if (!this.isRecording) return void debugLog('Recording not active, ignoring stop request');
93
+ this.isRecording = false;
94
+ debugLog('Stopping event recording');
95
+ if (this.scrollThrottleTimer) {
96
+ clearTimeout(this.scrollThrottleTimer);
97
+ this.scrollThrottleTimer = null;
98
+ }
99
+ if (this.inputThrottleTimer) {
100
+ clearTimeout(this.inputThrottleTimer);
101
+ this.inputThrottleTimer = null;
102
+ }
103
+ document.removeEventListener('click', this.handleClick);
104
+ document.removeEventListener('input', this.handleInput);
105
+ this.scrollTargets.forEach((target)=>{
106
+ target.removeEventListener('scroll', this.handleScroll);
107
+ });
108
+ debugLog('Removed all event listeners');
109
+ }
110
+ handleClick = (event)=>{
111
+ if (!this.isRecording) return;
112
+ const target = event.target;
113
+ const { isLabelClick, labelInfo } = this.checkLabelClick(target);
114
+ const rect = target.getBoundingClientRect();
115
+ const elementRect = {
116
+ x: Number(event.clientX.toFixed(2)),
117
+ y: Number(event.clientY.toFixed(2))
118
+ };
119
+ console.log('isNotContainerElement', isNotContainerElement(target));
120
+ if (isNotContainerElement(target)) {
121
+ elementRect.left = Number(rect.left.toFixed(2));
122
+ elementRect.top = Number(rect.top.toFixed(2));
123
+ elementRect.width = Number(rect.width.toFixed(2));
124
+ elementRect.height = Number(rect.height.toFixed(2));
125
+ }
126
+ const clickEvent = {
127
+ type: 'click',
128
+ elementRect,
129
+ pageInfo: {
130
+ width: window.innerWidth,
131
+ height: window.innerHeight
132
+ },
133
+ value: '',
134
+ timestamp: Date.now(),
135
+ hashId: generateHashId('click', {
136
+ ...elementRect
137
+ }),
138
+ element: target,
139
+ isLabelClick,
140
+ labelInfo,
141
+ isTrusted: event.isTrusted,
142
+ detail: event.detail
143
+ };
144
+ this.eventCallback(clickEvent);
145
+ };
146
+ handleScroll = (event)=>{
147
+ if (!this.isRecording) return;
148
+ function isDocument(target) {
149
+ return target instanceof Document;
150
+ }
151
+ const target = event.target;
152
+ const scrollXTarget = isDocument(target) ? window.scrollX : target.scrollLeft;
153
+ const scrollYTarget = isDocument(target) ? window.scrollY : target.scrollTop;
154
+ const rect = isDocument(target) ? {
155
+ left: 0,
156
+ top: 0,
157
+ width: window.innerWidth,
158
+ height: window.innerHeight
159
+ } : target.getBoundingClientRect();
160
+ if (this.scrollThrottleTimer) clearTimeout(this.scrollThrottleTimer);
161
+ this.scrollThrottleTimer = window.setTimeout(()=>{
162
+ if (this.isRecording) {
163
+ const elementRect = {
164
+ left: isDocument(target) ? 0 : Number(rect.left.toFixed(2)),
165
+ top: isDocument(target) ? 0 : Number(rect.top.toFixed(2)),
166
+ width: isDocument(target) ? window.innerWidth : Number(rect.width.toFixed(2)),
167
+ height: isDocument(target) ? window.innerHeight : Number(rect.height.toFixed(2))
168
+ };
169
+ const scrollEvent = {
170
+ type: 'scroll',
171
+ elementRect,
172
+ pageInfo: {
173
+ width: window.innerWidth,
174
+ height: window.innerHeight
175
+ },
176
+ value: `${scrollXTarget.toFixed(2)},${scrollYTarget.toFixed(2)}`,
177
+ timestamp: Date.now(),
178
+ hashId: generateHashId('scroll', {
179
+ ...elementRect
180
+ }),
181
+ element: target
182
+ };
183
+ this.eventCallback(scrollEvent);
184
+ }
185
+ this.scrollThrottleTimer = null;
186
+ }, this.scrollThrottleDelay);
187
+ };
188
+ handleInput = (event)=>{
189
+ if (!this.isRecording) return;
190
+ const target = event.target;
191
+ if ('checkbox' === target.type) return;
192
+ const rect = target.getBoundingClientRect();
193
+ const elementRect = {
194
+ left: Number(rect.left.toFixed(2)),
195
+ top: Number(rect.top.toFixed(2)),
196
+ width: Number(rect.width.toFixed(2)),
197
+ height: Number(rect.height.toFixed(2))
198
+ };
199
+ if (this.inputThrottleTimer) clearTimeout(this.inputThrottleTimer);
200
+ this.inputThrottleTimer = window.setTimeout(()=>{
201
+ if (this.isRecording) {
202
+ const inputEvent = {
203
+ type: 'input',
204
+ value: 'password' !== target.type ? target.value : '*****',
205
+ timestamp: Date.now(),
206
+ hashId: generateHashId('input', {
207
+ ...elementRect
208
+ }),
209
+ element: target,
210
+ inputType: target.type || 'text',
211
+ elementRect,
212
+ pageInfo: {
213
+ width: window.innerWidth,
214
+ height: window.innerHeight
215
+ }
216
+ };
217
+ debugLog('Throttled input event:', {
218
+ value: inputEvent.value,
219
+ timestamp: inputEvent.timestamp,
220
+ target: target.tagName,
221
+ inputType: target.type
222
+ });
223
+ this.eventCallback(inputEvent);
224
+ }
225
+ this.inputThrottleTimer = null;
226
+ }, this.inputThrottleDelay);
227
+ };
228
+ checkLabelClick(target) {
229
+ let isLabelClick = false;
230
+ let labelInfo;
231
+ if (target) if ('LABEL' === target.tagName) {
232
+ isLabelClick = true;
233
+ labelInfo = {
234
+ htmlFor: target.htmlFor,
235
+ textContent: target.textContent?.trim(),
236
+ xpath: getElementXpath(target)
237
+ };
238
+ } else {
239
+ let parent = target.parentElement;
240
+ while(parent){
241
+ if ('LABEL' === parent.tagName) {
242
+ isLabelClick = true;
243
+ labelInfo = {
244
+ htmlFor: parent.htmlFor,
245
+ textContent: parent.textContent?.trim(),
246
+ xpath: getElementXpath(parent)
247
+ };
248
+ break;
249
+ }
250
+ parent = parent.parentElement;
251
+ }
252
+ }
253
+ return {
254
+ isLabelClick,
255
+ labelInfo
256
+ };
257
+ }
258
+ isActive() {
259
+ return this.isRecording;
260
+ }
261
+ optimizeEvent(event, events) {
262
+ const lastEvent = events[events.length - 1];
263
+ if ('click' === event.type) {
264
+ const lastEvent = getLastLabelClick(events);
265
+ if (event.element) {
266
+ const { isLabelClick, labelInfo } = this.checkLabelClick(event.element);
267
+ if (lastEvent && isLabelClick && 'click' === lastEvent.type && lastEvent.isLabelClick && (lastEvent.labelInfo?.htmlFor && event.element.id && lastEvent.labelInfo?.htmlFor === event.element.id || labelInfo?.xpath && lastEvent.labelInfo?.xpath && lastEvent.labelInfo?.xpath === labelInfo?.xpath)) {
268
+ debugLog('Skip input event triggered by label click:', event.element);
269
+ return events;
270
+ }
271
+ return [
272
+ ...events,
273
+ event
274
+ ];
275
+ }
276
+ }
277
+ if ('input' === event.type) {
278
+ if (lastEvent && 'click' === lastEvent.type && lastEvent.isLabelClick && lastEvent.labelInfo?.htmlFor === event.targetId) {
279
+ debugLog('Skipping input event - triggered by label click:', {
280
+ labelHtmlFor: getLastLabelClick(events)?.labelInfo?.htmlFor,
281
+ inputId: event.targetId,
282
+ element: event.element
283
+ });
284
+ return events;
285
+ }
286
+ if (lastEvent && 'input' === lastEvent.type && isSameInputTarget(lastEvent, event)) {
287
+ const oldInputEvent = events[events.length - 1];
288
+ const newEvents = [
289
+ ...events
290
+ ];
291
+ newEvents[events.length - 1] = {
292
+ value: event.element?.value,
293
+ ...event
294
+ };
295
+ debugLog('Merging input event:', {
296
+ oldValue: oldInputEvent.value,
297
+ newValue: event.value,
298
+ oldTimestamp: oldInputEvent.timestamp,
299
+ newTimestamp: event.timestamp,
300
+ target: event.targetTagName
301
+ });
302
+ return newEvents;
303
+ }
304
+ }
305
+ if ('scroll' === event.type) {
306
+ if (lastEvent && 'scroll' === lastEvent.type && isSameScrollTarget(lastEvent, event)) {
307
+ const oldScrollEvent = events[events.length - 1];
308
+ const newEvents = [
309
+ ...events
310
+ ];
311
+ newEvents[events.length - 1] = event;
312
+ debugLog('Replacing last scroll event with new scroll event:', {
313
+ oldPosition: `${oldScrollEvent.elementRect?.left},${oldScrollEvent.elementRect?.top}`,
314
+ newPosition: `${event.elementRect?.left},${event.elementRect?.top}`,
315
+ oldTimestamp: oldScrollEvent.timestamp,
316
+ newTimestamp: event.timestamp,
317
+ target: event.targetTagName
318
+ });
319
+ return newEvents;
320
+ }
321
+ }
322
+ return [
323
+ ...events,
324
+ event
325
+ ];
326
+ }
327
+ }
328
+ function convertToChromeEvent(event) {
329
+ return {
330
+ type: event.type,
331
+ url: event.url,
332
+ title: event.title,
333
+ value: event.value,
334
+ elementRect: event.elementRect,
335
+ pageInfo: event.pageInfo,
336
+ screenshotBefore: event.screenshotBefore,
337
+ screenshotAfter: event.screenshotAfter,
338
+ elementDescription: event.elementDescription,
339
+ descriptionLoading: event.descriptionLoading,
340
+ screenshotWithBox: event.screenshotWithBox,
341
+ timestamp: event.timestamp,
342
+ hashId: event.hashId
343
+ };
344
+ }
345
+ function convertToChromeEvents(events) {
346
+ return events.map(convertToChromeEvent);
347
+ }
348
+ export { EventRecorder, convertToChromeEvent, convertToChromeEvents };
@@ -0,0 +1,10 @@
1
+ import './button.css';
2
+ interface ButtonProps {
3
+ primary?: boolean;
4
+ backgroundColor?: string;
5
+ size?: 'small' | 'medium' | 'large';
6
+ label: string;
7
+ onClick?: () => void;
8
+ }
9
+ export declare const Button: ({ primary, size, backgroundColor, label, ...props }: ButtonProps) => import("react/jsx-runtime").JSX.Element;
10
+ export {};
@@ -0,0 +1,8 @@
1
+ import type { RecordedEvent } from './recorder';
2
+ import './RecordTimeline.css';
3
+ interface RecordTimelineProps {
4
+ events: RecordedEvent[];
5
+ onEventClick?: (event: RecordedEvent, index: number) => void;
6
+ }
7
+ export declare const RecordTimeline: ({ events, onEventClick, }: RecordTimelineProps) => import("react/jsx-runtime").JSX.Element;
8
+ export {};
@@ -0,0 +1,12 @@
1
+ import type React from 'react';
2
+ import './shiny-text.css';
3
+ type ColorTheme = 'blue' | 'purple' | 'green' | 'rainbow';
4
+ interface ShinyTextProps {
5
+ text: string;
6
+ disabled?: boolean;
7
+ speed?: number;
8
+ className?: string;
9
+ colorTheme?: ColorTheme;
10
+ }
11
+ export declare const ShinyText: React.FC<ShinyTextProps>;
12
+ export {};
@@ -0,0 +1,3 @@
1
+ export { Button } from './Button';
2
+ export { EventRecorder, type RecordedEvent, type ChromeRecordedEvent, convertToChromeEvents, } from './recorder';
3
+ export { RecordTimeline } from './RecordTimeline';
@@ -0,0 +1,6 @@
1
+ import { EventRecorder } from './recorder';
2
+ declare global {
3
+ interface Window {
4
+ EventRecorder: typeof EventRecorder;
5
+ }
6
+ }
@@ -0,0 +1,64 @@
1
+ export interface ChromeRecordedEvent {
2
+ type: 'click' | 'scroll' | 'input' | 'navigation' | 'setViewport' | 'keydown';
3
+ url?: string;
4
+ title?: string;
5
+ value?: string;
6
+ elementRect?: {
7
+ left?: number;
8
+ top?: number;
9
+ width?: number;
10
+ height?: number;
11
+ x?: number;
12
+ y?: number;
13
+ };
14
+ pageInfo: {
15
+ width: number;
16
+ height: number;
17
+ };
18
+ screenshotBefore?: string;
19
+ screenshotAfter?: string;
20
+ elementDescription?: string;
21
+ descriptionLoading?: boolean;
22
+ screenshotWithBox?: string;
23
+ timestamp: number;
24
+ hashId: string;
25
+ }
26
+ export interface RecordedEvent extends ChromeRecordedEvent {
27
+ element?: HTMLElement;
28
+ targetTagName?: string;
29
+ targetId?: string;
30
+ targetClassName?: string;
31
+ isLabelClick?: boolean;
32
+ labelInfo?: {
33
+ htmlFor?: string;
34
+ textContent?: string;
35
+ xpath?: string;
36
+ };
37
+ isTrusted?: boolean;
38
+ detail?: number;
39
+ inputType?: string;
40
+ }
41
+ export type EventCallback = (event: RecordedEvent) => void;
42
+ export declare class EventRecorder {
43
+ private isRecording;
44
+ private eventCallback;
45
+ private scrollThrottleTimer;
46
+ private scrollThrottleDelay;
47
+ private inputThrottleTimer;
48
+ private inputThrottleDelay;
49
+ private lastViewportScroll;
50
+ private scrollTargets;
51
+ private sessionId;
52
+ constructor(eventCallback: EventCallback, sessionId: string);
53
+ createNavigationEvent(url: string, title: string): ChromeRecordedEvent;
54
+ start(): void;
55
+ stop(): void;
56
+ private handleClick;
57
+ private handleScroll;
58
+ private handleInput;
59
+ private checkLabelClick;
60
+ isActive(): boolean;
61
+ optimizeEvent(event: RecordedEvent, events: RecordedEvent[]): RecordedEvent[];
62
+ }
63
+ export declare function convertToChromeEvent(event: RecordedEvent): ChromeRecordedEvent;
64
+ export declare function convertToChromeEvents(events: RecordedEvent[]): ChromeRecordedEvent[];
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@sqaitech/recorder",
3
+ "version": "0.5.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./dist/types/src/index.d.ts",
8
+ "import": "./dist/index.js"
9
+ }
10
+ },
11
+ "types": "./dist/types/src/index.d.ts",
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "devDependencies": {
16
+ "@rsbuild/plugin-react": "^1.3.1",
17
+ "@rslib/core": "^0.11.2",
18
+ "@types/react": "^18.3.1",
19
+ "react": "18.3.1",
20
+ "typescript": "^5.8.3"
21
+ },
22
+ "dependencies": {
23
+ "@ant-design/icons": "^5.3.1",
24
+ "antd": "^5.21.6",
25
+ "dayjs": "^1.11.11",
26
+ "react-dom": "18.3.1",
27
+ "@sqaitech/shared": "0.5.0"
28
+ },
29
+ "peerDependencies": {
30
+ "react": "18.3.1",
31
+ "react-dom": "18.3.1"
32
+ },
33
+ "scripts": {
34
+ "build": "rslib build",
35
+ "dev": "rslib build --watch",
36
+ "build:watch": "npm run dev"
37
+ }
38
+ }