browsertrack 0.1.2 → 0.2.1
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/AGENTS.md +6 -2
- package/dist/{chunk-ILRYKMME.js → chunk-3HOXPTM2.js} +97 -3
- package/dist/chunk-3HOXPTM2.js.map +1 -0
- package/dist/{chunk-G2Y3CXCY.js → chunk-464D4U2U.js} +86 -3
- package/dist/chunk-464D4U2U.js.map +1 -0
- package/dist/{chunk-SPCIROIU.js → chunk-7OCOQGDN.js} +24 -5
- package/dist/chunk-7OCOQGDN.js.map +1 -0
- package/dist/{chunk-SKCMT2DE.js → chunk-INXDWPJW.js} +806 -160
- package/dist/chunk-INXDWPJW.js.map +1 -0
- package/dist/cli/index.js +202 -6
- package/dist/cli/index.js.map +1 -1
- package/dist/client/index.cjs +809 -161
- package/dist/client/index.d.ts +62 -11
- package/dist/client/index.js +7 -3
- package/dist/client.iife.js +206 -27
- package/dist/{notes-CBvN91Wf.d.ts → commands-fjuqKzkm.d.ts} +125 -91
- package/dist/core/index.d.ts +2 -2
- package/dist/daemon/index.d.ts +3 -3
- package/dist/daemon/index.js +2 -2
- package/dist/{engine-CeT9URuN.d.ts → engine-CmchnMDq.d.ts} +13 -2
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/mcp/index.d.ts +4 -4
- package/dist/mcp/index.js +2 -2
- package/dist/{projects-CY8ungMt.d.ts → projects-DB7S312i.d.ts} +1 -1
- package/dist/{server-Dd8NX2Mk.d.ts → server-DiVmTrIR.d.ts} +1 -1
- package/docs/component-resolver.md +108 -0
- package/docs/index.md +3 -1
- package/docs/mcp-reference.md +15 -2
- package/docs/scenarios-flows.md +86 -0
- package/docs/visual-notes.md +36 -0
- package/package.json +1 -1
- package/packages/client/src/client.ts +18 -1
- package/packages/client/src/config.ts +84 -1
- package/packages/client/src/index.ts +4 -1
- package/packages/client/src/interceptors/interaction.ts +3 -0
- package/packages/client/src/notes/inspector.ts +640 -166
- package/packages/client/src/source/resolver.ts +272 -0
- package/packages/core/src/types/events.ts +3 -0
- package/packages/core/src/types/notes.ts +36 -0
- package/packages/daemon/src/notes/engine.ts +6 -0
- package/packages/daemon/src/server/ws.ts +23 -1
- package/packages/daemon/src/storage/db.ts +106 -3
- package/packages/mcp/src/handlers.ts +59 -0
- package/packages/mcp/src/tools.ts +28 -0
- package/test/client/component-resolver.test.ts +141 -0
- package/test/client/interceptors.test.ts +120 -0
- package/test/daemon/scenario-storage.test.ts +158 -0
- package/dist/chunk-G2Y3CXCY.js.map +0 -1
- package/dist/chunk-ILRYKMME.js.map +0 -1
- package/dist/chunk-SKCMT2DE.js.map +0 -1
- package/dist/chunk-SPCIROIU.js.map +0 -1
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import type { ComponentSourceInfo } from '../../../core/src/index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolves framework component metadata, source file paths, line numbers,
|
|
5
|
+
* and component hierarchy directly from a live DOM element.
|
|
6
|
+
* Supports React (Fiber), Vue 2 & 3, Svelte, Custom Elements, and standard data attributes.
|
|
7
|
+
*/
|
|
8
|
+
export function resolveComponentSource(el: HTMLElement): ComponentSourceInfo | undefined {
|
|
9
|
+
if (!el || typeof el !== 'object') return undefined;
|
|
10
|
+
|
|
11
|
+
// 1. Try React Fiber Resolution
|
|
12
|
+
const reactInfo = resolveReactComponent(el);
|
|
13
|
+
if (reactInfo) return reactInfo;
|
|
14
|
+
|
|
15
|
+
// 2. Try Vue (Vue 3 / Vue 2) Resolution
|
|
16
|
+
const vueInfo = resolveVueComponent(el);
|
|
17
|
+
if (vueInfo) return vueInfo;
|
|
18
|
+
|
|
19
|
+
// 3. Try Svelte Resolution
|
|
20
|
+
const svelteInfo = resolveSvelteComponent(el);
|
|
21
|
+
if (svelteInfo) return svelteInfo;
|
|
22
|
+
|
|
23
|
+
// 4. Try Web Component / Custom Element
|
|
24
|
+
if (el.tagName && el.tagName.includes('-')) {
|
|
25
|
+
return {
|
|
26
|
+
framework: 'web-component',
|
|
27
|
+
componentName: el.tagName.toLowerCase(),
|
|
28
|
+
hierarchy: [el.tagName.toLowerCase()],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 5. Try Data Attribute Annotations (e.g. data-component, data-source-file)
|
|
33
|
+
const attrInfo = resolveDataAttributeComponent(el);
|
|
34
|
+
if (attrInfo) return attrInfo;
|
|
35
|
+
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Extracts React Fiber debug source, owner, and hierarchy
|
|
41
|
+
*/
|
|
42
|
+
function resolveReactComponent(el: HTMLElement): ComponentSourceInfo | undefined {
|
|
43
|
+
try {
|
|
44
|
+
const fiberKey = Object.keys(el).find(
|
|
45
|
+
(key) => key.startsWith('__reactFiber$') || key.startsWith('__reactInternalInstance$')
|
|
46
|
+
);
|
|
47
|
+
if (!fiberKey) return undefined;
|
|
48
|
+
|
|
49
|
+
const hostFiber = (el as any)[fiberKey];
|
|
50
|
+
if (!hostFiber) return undefined;
|
|
51
|
+
|
|
52
|
+
let sourceFile: string | undefined;
|
|
53
|
+
let sourceLine: number | undefined;
|
|
54
|
+
let sourceColumn: number | undefined;
|
|
55
|
+
let componentName: string | undefined;
|
|
56
|
+
const hierarchy: string[] = [];
|
|
57
|
+
let props: Record<string, any> | undefined;
|
|
58
|
+
|
|
59
|
+
// Check host fiber debugSource first (often populated by JSX transform)
|
|
60
|
+
if (hostFiber._debugSource) {
|
|
61
|
+
sourceFile = hostFiber._debugSource.fileName;
|
|
62
|
+
sourceLine = hostFiber._debugSource.lineNumber;
|
|
63
|
+
sourceColumn = hostFiber._debugSource.columnNumber;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Check _debugOwner if available
|
|
67
|
+
if (hostFiber._debugOwner) {
|
|
68
|
+
const ownerType = hostFiber._debugOwner.type;
|
|
69
|
+
componentName = getReactComponentName(ownerType);
|
|
70
|
+
if (!sourceFile && hostFiber._debugOwner._debugSource) {
|
|
71
|
+
sourceFile = hostFiber._debugOwner._debugSource.fileName;
|
|
72
|
+
sourceLine = hostFiber._debugOwner._debugSource.lineNumber;
|
|
73
|
+
sourceColumn = hostFiber._debugOwner._debugSource.columnNumber;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Walk up fiber return tree to discover composite components and hierarchy
|
|
78
|
+
let curr = hostFiber;
|
|
79
|
+
while (curr) {
|
|
80
|
+
const type = curr.type;
|
|
81
|
+
const name = getReactComponentName(type);
|
|
82
|
+
|
|
83
|
+
if (name && !name.startsWith('html:') && name !== 'Fragment') {
|
|
84
|
+
if (!componentName) {
|
|
85
|
+
componentName = name;
|
|
86
|
+
}
|
|
87
|
+
if (!hierarchy.includes(name)) {
|
|
88
|
+
hierarchy.unshift(name);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Check if composite component has debug source
|
|
92
|
+
if (!sourceFile && curr._debugSource) {
|
|
93
|
+
sourceFile = curr._debugSource.fileName;
|
|
94
|
+
sourceLine = curr._debugSource.lineNumber;
|
|
95
|
+
sourceColumn = curr._debugSource.columnNumber;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Capture safe props from the nearest custom component
|
|
99
|
+
if (!props && curr.memoizedProps && typeof curr.memoizedProps === 'object') {
|
|
100
|
+
props = sanitizeProps(curr.memoizedProps);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
curr = curr.return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!componentName && !sourceFile) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
framework: 'react',
|
|
113
|
+
componentName,
|
|
114
|
+
sourceFile: normalizeFilePath(sourceFile),
|
|
115
|
+
sourceLine,
|
|
116
|
+
sourceColumn,
|
|
117
|
+
hierarchy: hierarchy.length > 0 ? hierarchy : componentName ? [componentName] : undefined,
|
|
118
|
+
props,
|
|
119
|
+
};
|
|
120
|
+
} catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function getReactComponentName(type: any): string | undefined {
|
|
126
|
+
if (!type) return undefined;
|
|
127
|
+
if (typeof type === 'string') return undefined; // Host DOM tag (div, span, button)
|
|
128
|
+
if (type.displayName) return type.displayName;
|
|
129
|
+
if (type.name) return type.name;
|
|
130
|
+
if (type.render?.displayName) return type.render.displayName;
|
|
131
|
+
if (type.render?.name) return type.render.name;
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Extracts Vue 3 and Vue 2 component instances and file paths
|
|
137
|
+
*/
|
|
138
|
+
function resolveVueComponent(el: HTMLElement): ComponentSourceInfo | undefined {
|
|
139
|
+
try {
|
|
140
|
+
// Vue 3
|
|
141
|
+
const vueParent = (el as any).__vueParentComponent || (el as any).__vnode?.ctx;
|
|
142
|
+
if (vueParent) {
|
|
143
|
+
const type = vueParent.type || {};
|
|
144
|
+
const componentName = type.name || type.__name || type.displayName || 'AnonymousComponent';
|
|
145
|
+
const sourceFile = type.__file;
|
|
146
|
+
const hierarchy: string[] = [];
|
|
147
|
+
|
|
148
|
+
let curr = vueParent;
|
|
149
|
+
while (curr) {
|
|
150
|
+
const cType = curr.type || {};
|
|
151
|
+
const cName = cType.name || cType.__name || cType.displayName;
|
|
152
|
+
if (cName && !hierarchy.includes(cName)) {
|
|
153
|
+
hierarchy.unshift(cName);
|
|
154
|
+
}
|
|
155
|
+
curr = curr.parent;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
framework: 'vue',
|
|
160
|
+
componentName,
|
|
161
|
+
sourceFile: normalizeFilePath(sourceFile),
|
|
162
|
+
hierarchy: hierarchy.length > 0 ? hierarchy : [componentName],
|
|
163
|
+
props: vueParent.props ? sanitizeProps(vueParent.props) : undefined,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Vue 2
|
|
168
|
+
const vue2Instance = (el as any).__vue__;
|
|
169
|
+
if (vue2Instance) {
|
|
170
|
+
const options = vue2Instance.$options || {};
|
|
171
|
+
const componentName = options.name || options._componentTag || 'VueComponent';
|
|
172
|
+
const sourceFile = options.__file;
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
framework: 'vue',
|
|
176
|
+
componentName,
|
|
177
|
+
sourceFile: normalizeFilePath(sourceFile),
|
|
178
|
+
hierarchy: [componentName],
|
|
179
|
+
props: vue2Instance.$props ? sanitizeProps(vue2Instance.$props) : undefined,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return undefined;
|
|
184
|
+
} catch {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Extracts Svelte __svelte_meta debug locations
|
|
191
|
+
*/
|
|
192
|
+
function resolveSvelteComponent(el: HTMLElement): ComponentSourceInfo | undefined {
|
|
193
|
+
try {
|
|
194
|
+
let curr: HTMLElement | null = el;
|
|
195
|
+
while (curr) {
|
|
196
|
+
const meta = (curr as any).__svelte_meta;
|
|
197
|
+
if (meta && meta.loc) {
|
|
198
|
+
return {
|
|
199
|
+
framework: 'svelte',
|
|
200
|
+
sourceFile: normalizeFilePath(meta.loc.file),
|
|
201
|
+
sourceLine: meta.loc.line,
|
|
202
|
+
sourceColumn: meta.loc.column,
|
|
203
|
+
componentName: meta.loc.file ? getBaseNameWithoutExt(meta.loc.file) : undefined,
|
|
204
|
+
hierarchy: meta.loc.file ? [getBaseNameWithoutExt(meta.loc.file)] : undefined,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
curr = curr.parentElement;
|
|
208
|
+
}
|
|
209
|
+
return undefined;
|
|
210
|
+
} catch {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Resolves components from standard data attributes (e.g. data-component="UserCard")
|
|
217
|
+
*/
|
|
218
|
+
function resolveDataAttributeComponent(el: HTMLElement): ComponentSourceInfo | undefined {
|
|
219
|
+
try {
|
|
220
|
+
const compEl = el.closest('[data-component], [data-component-name], [data-source-file]');
|
|
221
|
+
if (!compEl) return undefined;
|
|
222
|
+
|
|
223
|
+
const componentName =
|
|
224
|
+
compEl.getAttribute('data-component') || compEl.getAttribute('data-component-name') || undefined;
|
|
225
|
+
const sourceFile = compEl.getAttribute('data-source-file') || undefined;
|
|
226
|
+
const lineAttr = compEl.getAttribute('data-source-line');
|
|
227
|
+
const sourceLine = lineAttr ? parseInt(lineAttr, 10) : undefined;
|
|
228
|
+
|
|
229
|
+
if (!componentName && !sourceFile) return undefined;
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
framework: 'vanilla',
|
|
233
|
+
componentName,
|
|
234
|
+
sourceFile: normalizeFilePath(sourceFile),
|
|
235
|
+
sourceLine: isNaN(sourceLine as number) ? undefined : sourceLine,
|
|
236
|
+
hierarchy: componentName ? [componentName] : undefined,
|
|
237
|
+
};
|
|
238
|
+
} catch {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function normalizeFilePath(filePath?: string): string | undefined {
|
|
244
|
+
if (!filePath) return undefined;
|
|
245
|
+
// Strip query strings or vite bundle hashes (e.g. /App.tsx?t=123)
|
|
246
|
+
const cleaned = filePath.split('?')[0];
|
|
247
|
+
return cleaned;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function getBaseNameWithoutExt(filePath: string): string {
|
|
251
|
+
const parts = filePath.split('/');
|
|
252
|
+
const last = parts[parts.length - 1] || filePath;
|
|
253
|
+
return last.split('.')[0] || last;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function sanitizeProps(props: Record<string, any>): Record<string, any> {
|
|
257
|
+
const result: Record<string, any> = {};
|
|
258
|
+
for (const [key, value] of Object.entries(props)) {
|
|
259
|
+
if (key.startsWith('__') || typeof value === 'function') continue;
|
|
260
|
+
|
|
261
|
+
if (value === null || value === undefined) {
|
|
262
|
+
result[key] = value;
|
|
263
|
+
} else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
264
|
+
result[key] = value;
|
|
265
|
+
} else if (Array.isArray(value)) {
|
|
266
|
+
result[key] = `Array(${value.length})`;
|
|
267
|
+
} else if (typeof value === 'object') {
|
|
268
|
+
result[key] = '[Object]';
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ComponentSourceInfo } from './notes.js';
|
|
2
|
+
|
|
1
3
|
export type EventType =
|
|
2
4
|
| 'runtime_error'
|
|
3
5
|
| 'unhandled_rejection'
|
|
@@ -17,6 +19,7 @@ export interface ElementSummary {
|
|
|
17
19
|
attributes?: Record<string, string>;
|
|
18
20
|
outerHTML?: string;
|
|
19
21
|
innerText?: string;
|
|
22
|
+
componentSource?: ComponentSourceInfo;
|
|
20
23
|
boundingRect?: {
|
|
21
24
|
x: number;
|
|
22
25
|
y: number;
|
|
@@ -39,12 +39,23 @@ export interface NoteTarget {
|
|
|
39
39
|
confidence?: 'high' | 'medium' | 'low';
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export interface ComponentSourceInfo {
|
|
43
|
+
framework?: 'react' | 'vue' | 'svelte' | 'web-component' | 'vanilla';
|
|
44
|
+
componentName?: string;
|
|
45
|
+
sourceFile?: string;
|
|
46
|
+
sourceLine?: number;
|
|
47
|
+
sourceColumn?: number;
|
|
48
|
+
hierarchy?: string[];
|
|
49
|
+
props?: Record<string, any>;
|
|
50
|
+
}
|
|
51
|
+
|
|
42
52
|
export interface ElementContext {
|
|
43
53
|
selector: string;
|
|
44
54
|
tag: string;
|
|
45
55
|
attributes?: Record<string, string>;
|
|
46
56
|
outerHTML?: string;
|
|
47
57
|
innerText?: string;
|
|
58
|
+
componentSource?: ComponentSourceInfo;
|
|
48
59
|
parent?: {
|
|
49
60
|
selector: string;
|
|
50
61
|
tag: string;
|
|
@@ -73,6 +84,9 @@ export interface VisualNote {
|
|
|
73
84
|
region?: RegionContext;
|
|
74
85
|
status: NoteStatus;
|
|
75
86
|
incidentId?: string;
|
|
87
|
+
scenarioId?: string;
|
|
88
|
+
stepNumber?: number;
|
|
89
|
+
scenarioTitle?: string;
|
|
76
90
|
screenshots?: {
|
|
77
91
|
original?: string;
|
|
78
92
|
after?: string;
|
|
@@ -82,6 +96,28 @@ export interface VisualNote {
|
|
|
82
96
|
resolvedAt?: string;
|
|
83
97
|
}
|
|
84
98
|
|
|
99
|
+
export interface ScenarioOverview {
|
|
100
|
+
id: string;
|
|
101
|
+
projectId: string;
|
|
102
|
+
title: string;
|
|
103
|
+
stepsCount: number;
|
|
104
|
+
status: NoteStatus;
|
|
105
|
+
route: string;
|
|
106
|
+
firstStepAt: string;
|
|
107
|
+
lastStepAt: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface ScenarioDetail {
|
|
111
|
+
id: string;
|
|
112
|
+
projectId: string;
|
|
113
|
+
title: string;
|
|
114
|
+
stepsCount: number;
|
|
115
|
+
status: NoteStatus;
|
|
116
|
+
steps: VisualNote[];
|
|
117
|
+
createdAt: string;
|
|
118
|
+
updatedAt: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
85
121
|
export interface NoteVerificationResult {
|
|
86
122
|
noteId: string;
|
|
87
123
|
status: NoteStatus;
|
|
@@ -27,6 +27,9 @@ export class NotesEngine {
|
|
|
27
27
|
region?: any;
|
|
28
28
|
screenshot?: string;
|
|
29
29
|
incidentId?: string;
|
|
30
|
+
scenarioId?: string;
|
|
31
|
+
stepNumber?: number;
|
|
32
|
+
scenarioTitle?: string;
|
|
30
33
|
}): VisualNote {
|
|
31
34
|
const session = this.db.getSession(payload.sessionId);
|
|
32
35
|
const projectId = session?.projectId || 'default';
|
|
@@ -56,6 +59,9 @@ export class NotesEngine {
|
|
|
56
59
|
region: payload.region,
|
|
57
60
|
status: 'OPEN',
|
|
58
61
|
incidentId: payload.incidentId,
|
|
62
|
+
scenarioId: payload.scenarioId,
|
|
63
|
+
stepNumber: payload.stepNumber,
|
|
64
|
+
scenarioTitle: payload.scenarioTitle,
|
|
59
65
|
screenshots: screenshotPath ? { original: screenshotPath } : undefined,
|
|
60
66
|
createdAt: now,
|
|
61
67
|
updatedAt: now,
|
|
@@ -134,10 +134,17 @@ export function setupWebSocketServer(
|
|
|
134
134
|
region: data.region,
|
|
135
135
|
screenshot: data.screenshot,
|
|
136
136
|
incidentId: data.incidentId,
|
|
137
|
+
scenarioId: data.scenarioId,
|
|
138
|
+
stepNumber: data.stepNumber,
|
|
139
|
+
scenarioTitle: data.scenarioTitle,
|
|
137
140
|
});
|
|
138
141
|
|
|
139
142
|
if (verbose) {
|
|
140
|
-
console.log(
|
|
143
|
+
console.log(
|
|
144
|
+
`[BrowserTrack] Visual note created: ${note.id} on ${note.route} ("${note.message}")${
|
|
145
|
+
note.scenarioId ? ` [Scenario: ${note.scenarioTitle || note.scenarioId} Step ${note.stepNumber}]` : ''
|
|
146
|
+
}`
|
|
147
|
+
);
|
|
141
148
|
}
|
|
142
149
|
|
|
143
150
|
ws.send(
|
|
@@ -145,6 +152,8 @@ export function setupWebSocketServer(
|
|
|
145
152
|
type: 'note_created_ack',
|
|
146
153
|
noteId: note.id,
|
|
147
154
|
status: note.status,
|
|
155
|
+
scenarioId: note.scenarioId,
|
|
156
|
+
stepNumber: note.stepNumber,
|
|
148
157
|
})
|
|
149
158
|
);
|
|
150
159
|
|
|
@@ -197,6 +206,19 @@ export function setupWebSocketServer(
|
|
|
197
206
|
return;
|
|
198
207
|
}
|
|
199
208
|
|
|
209
|
+
if (data.type === 'delete_scenario' && data.scenarioId) {
|
|
210
|
+
const scenario = db.getScenario(data.scenarioId);
|
|
211
|
+
if (scenario) {
|
|
212
|
+
db.deleteScenario(data.scenarioId);
|
|
213
|
+
const allNotes = db.listNotes({ projectId: scenario.projectId, limit: 100 });
|
|
214
|
+
sessionManager.broadcastToProject(scenario.projectId, {
|
|
215
|
+
type: 'notes_sync',
|
|
216
|
+
notes: allNotes,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
200
222
|
if (data.type === 'get_notes') {
|
|
201
223
|
const session = currentSessionId ? db.getSession(currentSessionId) : null;
|
|
202
224
|
const projectId = data.projectId || session?.projectId;
|
|
@@ -15,6 +15,8 @@ import type {
|
|
|
15
15
|
VisualNote,
|
|
16
16
|
NoteStatus,
|
|
17
17
|
NoteVerificationResult,
|
|
18
|
+
ScenarioOverview,
|
|
19
|
+
ScenarioDetail,
|
|
18
20
|
} from '../../../core/src/index.js';
|
|
19
21
|
|
|
20
22
|
export class StorageDB {
|
|
@@ -134,6 +136,9 @@ export class StorageDB {
|
|
|
134
136
|
region_json TEXT,
|
|
135
137
|
screenshot_path TEXT,
|
|
136
138
|
incident_id TEXT,
|
|
139
|
+
scenario_id TEXT,
|
|
140
|
+
step_number INTEGER,
|
|
141
|
+
scenario_title TEXT,
|
|
137
142
|
status TEXT DEFAULT 'OPEN',
|
|
138
143
|
created_at TEXT,
|
|
139
144
|
updated_at TEXT,
|
|
@@ -156,7 +161,19 @@ export class StorageDB {
|
|
|
156
161
|
CREATE INDEX IF NOT EXISTS idx_incidents_project ON incidents(project_id, status);
|
|
157
162
|
CREATE INDEX IF NOT EXISTS idx_incidents_fp ON incidents(fingerprint);
|
|
158
163
|
CREATE INDEX IF NOT EXISTS idx_notes_project ON notes(project_id, status);
|
|
164
|
+
CREATE INDEX IF NOT EXISTS idx_notes_scenario ON notes(scenario_id, step_number);
|
|
159
165
|
`);
|
|
166
|
+
|
|
167
|
+
// Schema migrations for scenario fields
|
|
168
|
+
try {
|
|
169
|
+
this.db.exec('ALTER TABLE notes ADD COLUMN scenario_id TEXT;');
|
|
170
|
+
} catch {}
|
|
171
|
+
try {
|
|
172
|
+
this.db.exec('ALTER TABLE notes ADD COLUMN step_number INTEGER;');
|
|
173
|
+
} catch {}
|
|
174
|
+
try {
|
|
175
|
+
this.db.exec('ALTER TABLE notes ADD COLUMN scenario_title TEXT;');
|
|
176
|
+
} catch {}
|
|
160
177
|
}
|
|
161
178
|
|
|
162
179
|
// --- PROJECTS ---
|
|
@@ -539,8 +556,8 @@ export class StorageDB {
|
|
|
539
556
|
`INSERT INTO notes (
|
|
540
557
|
id, project_id, session_id, type, message, route, url,
|
|
541
558
|
viewport_json, scroll_json, target_json, element_context_json, region_json,
|
|
542
|
-
screenshot_path, incident_id, status, created_at, updated_at, resolved_at
|
|
543
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
559
|
+
screenshot_path, incident_id, scenario_id, step_number, scenario_title, status, created_at, updated_at, resolved_at
|
|
560
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
544
561
|
)
|
|
545
562
|
.run(
|
|
546
563
|
note.id,
|
|
@@ -557,6 +574,9 @@ export class StorageDB {
|
|
|
557
574
|
note.region ? JSON.stringify(note.region) : null,
|
|
558
575
|
note.screenshots?.original || null,
|
|
559
576
|
note.incidentId || null,
|
|
577
|
+
note.scenarioId || null,
|
|
578
|
+
note.stepNumber ?? null,
|
|
579
|
+
note.scenarioTitle || null,
|
|
560
580
|
note.status,
|
|
561
581
|
note.createdAt,
|
|
562
582
|
note.updatedAt,
|
|
@@ -570,7 +590,7 @@ export class StorageDB {
|
|
|
570
590
|
return this.mapNoteRow(row);
|
|
571
591
|
}
|
|
572
592
|
|
|
573
|
-
public listNotes(options: { projectId?: string; status?: NoteStatus; route?: string; limit?: number } = {}): VisualNote[] {
|
|
593
|
+
public listNotes(options: { projectId?: string; status?: NoteStatus; route?: string; scenarioId?: string; limit?: number } = {}): VisualNote[] {
|
|
574
594
|
let sql = 'SELECT * FROM notes WHERE 1=1';
|
|
575
595
|
const params: any[] = [];
|
|
576
596
|
|
|
@@ -586,6 +606,10 @@ export class StorageDB {
|
|
|
586
606
|
sql += ' AND route = ?';
|
|
587
607
|
params.push(options.route);
|
|
588
608
|
}
|
|
609
|
+
if (options.scenarioId) {
|
|
610
|
+
sql += ' AND scenario_id = ?';
|
|
611
|
+
params.push(options.scenarioId);
|
|
612
|
+
}
|
|
589
613
|
|
|
590
614
|
sql += ' ORDER BY created_at DESC LIMIT ?';
|
|
591
615
|
params.push(options.limit || 50);
|
|
@@ -599,6 +623,82 @@ export class StorageDB {
|
|
|
599
623
|
this.db.prepare('DELETE FROM notes WHERE id = ?').run(id);
|
|
600
624
|
}
|
|
601
625
|
|
|
626
|
+
// --- SCENARIOS (MULTI-STEP FLOWS) ---
|
|
627
|
+
public listScenarios(options: { projectId?: string; status?: NoteStatus; limit?: number } = {}): ScenarioOverview[] {
|
|
628
|
+
let sql = `
|
|
629
|
+
SELECT
|
|
630
|
+
scenario_id,
|
|
631
|
+
project_id,
|
|
632
|
+
COALESCE(MAX(scenario_title), 'Scenario') as title,
|
|
633
|
+
COUNT(id) as steps_count,
|
|
634
|
+
CASE WHEN SUM(CASE WHEN status != 'RESOLVED' THEN 1 ELSE 0 END) = 0 THEN 'RESOLVED' ELSE 'OPEN' END as status,
|
|
635
|
+
MIN(route) as route,
|
|
636
|
+
MIN(created_at) as first_step_at,
|
|
637
|
+
MAX(created_at) as last_step_at
|
|
638
|
+
FROM notes
|
|
639
|
+
WHERE scenario_id IS NOT NULL
|
|
640
|
+
`;
|
|
641
|
+
const params: any[] = [];
|
|
642
|
+
if (options.projectId) {
|
|
643
|
+
sql += ' AND (project_id = ? OR project_id IS NULL)';
|
|
644
|
+
params.push(options.projectId);
|
|
645
|
+
}
|
|
646
|
+
sql += ' GROUP BY scenario_id';
|
|
647
|
+
|
|
648
|
+
if (options.status) {
|
|
649
|
+
if (options.status === 'RESOLVED') {
|
|
650
|
+
sql += " HAVING SUM(CASE WHEN status != 'RESOLVED' THEN 1 ELSE 0 END) = 0";
|
|
651
|
+
} else {
|
|
652
|
+
sql += " HAVING SUM(CASE WHEN status != 'RESOLVED' THEN 1 ELSE 0 END) > 0";
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
sql += ' ORDER BY last_step_at DESC LIMIT ?';
|
|
657
|
+
params.push(options.limit || 50);
|
|
658
|
+
|
|
659
|
+
const rows = this.db.prepare(sql).all(...params) as any[];
|
|
660
|
+
return rows.map((r) => ({
|
|
661
|
+
id: r.scenario_id,
|
|
662
|
+
projectId: r.project_id,
|
|
663
|
+
title: r.title,
|
|
664
|
+
stepsCount: Number(r.steps_count),
|
|
665
|
+
status: r.status as NoteStatus,
|
|
666
|
+
route: r.route || '/',
|
|
667
|
+
firstStepAt: r.first_step_at,
|
|
668
|
+
lastStepAt: r.last_step_at,
|
|
669
|
+
}));
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
public getScenario(scenarioId: string): ScenarioDetail | null {
|
|
673
|
+
const rows = this.db
|
|
674
|
+
.prepare('SELECT * FROM notes WHERE scenario_id = ? ORDER BY COALESCE(step_number, 999999) ASC, created_at ASC')
|
|
675
|
+
.all(scenarioId) as any[];
|
|
676
|
+
|
|
677
|
+
if (!rows || rows.length === 0) return null;
|
|
678
|
+
|
|
679
|
+
const steps = rows.map((r) => this.mapNoteRow(r));
|
|
680
|
+
const title = steps.find((s) => s.scenarioTitle)?.scenarioTitle || `Scenario ${scenarioId}`;
|
|
681
|
+
const allResolved = steps.every((s) => s.status === 'RESOLVED');
|
|
682
|
+
|
|
683
|
+
return {
|
|
684
|
+
id: scenarioId,
|
|
685
|
+
projectId: steps[0].projectId,
|
|
686
|
+
title,
|
|
687
|
+
stepsCount: steps.length,
|
|
688
|
+
status: allResolved ? 'RESOLVED' : 'OPEN',
|
|
689
|
+
steps,
|
|
690
|
+
createdAt: steps[0].createdAt,
|
|
691
|
+
updatedAt: steps[steps.length - 1].updatedAt,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
public deleteScenario(scenarioId: string): void {
|
|
696
|
+
const notes = this.db.prepare('SELECT id FROM notes WHERE scenario_id = ?').all(scenarioId) as any[];
|
|
697
|
+
for (const n of notes) {
|
|
698
|
+
this.deleteNote(n.id);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
602
702
|
public updateNoteStatus(id: string, status: NoteStatus): void {
|
|
603
703
|
const now = new Date().toISOString();
|
|
604
704
|
const resolvedAt = status === 'RESOLVED' ? now : null;
|
|
@@ -725,6 +825,9 @@ export class StorageDB {
|
|
|
725
825
|
region: row.region_json ? JSON.parse(row.region_json) : undefined,
|
|
726
826
|
status: row.status as NoteStatus,
|
|
727
827
|
incidentId: row.incident_id || undefined,
|
|
828
|
+
scenarioId: row.scenario_id || undefined,
|
|
829
|
+
stepNumber: row.step_number != null ? Number(row.step_number) : undefined,
|
|
830
|
+
scenarioTitle: row.scenario_title || undefined,
|
|
728
831
|
screenshots: row.screenshot_path
|
|
729
832
|
? {
|
|
730
833
|
original: row.screenshot_path,
|
|
@@ -104,6 +104,7 @@ export async function handleToolCall(name: string, args: any, ctx: McpContext):
|
|
|
104
104
|
visible: incident.lastElement.visible,
|
|
105
105
|
innerText: incident.lastElement.innerText,
|
|
106
106
|
outerHTML: incident.lastElement.outerHTML,
|
|
107
|
+
componentSource: incident.lastElement.componentSource,
|
|
107
108
|
}
|
|
108
109
|
: undefined,
|
|
109
110
|
recentBreadcrumbs: breadcrumbsTimeline,
|
|
@@ -252,6 +253,7 @@ export async function handleToolCall(name: string, args: any, ctx: McpContext):
|
|
|
252
253
|
case 'list_notes': {
|
|
253
254
|
const notes = db.listNotes({
|
|
254
255
|
projectId: args.projectId,
|
|
256
|
+
scenarioId: args.scenarioId,
|
|
255
257
|
status: args.status,
|
|
256
258
|
limit: args.limit || 20,
|
|
257
259
|
});
|
|
@@ -263,6 +265,9 @@ export async function handleToolCall(name: string, args: any, ctx: McpContext):
|
|
|
263
265
|
type: n.type,
|
|
264
266
|
status: n.status,
|
|
265
267
|
route: n.route,
|
|
268
|
+
scenarioId: n.scenarioId,
|
|
269
|
+
stepNumber: n.stepNumber,
|
|
270
|
+
scenarioTitle: n.scenarioTitle,
|
|
266
271
|
viewport: `${n.viewport.width} × ${n.viewport.height}`,
|
|
267
272
|
target: n.target?.selector || n.type,
|
|
268
273
|
message: n.message,
|
|
@@ -272,6 +277,57 @@ export async function handleToolCall(name: string, args: any, ctx: McpContext):
|
|
|
272
277
|
};
|
|
273
278
|
}
|
|
274
279
|
|
|
280
|
+
case 'list_scenarios': {
|
|
281
|
+
const scenarios = db.listScenarios({
|
|
282
|
+
projectId: args.projectId,
|
|
283
|
+
status: args.status,
|
|
284
|
+
limit: args.limit || 20,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
total: scenarios.length,
|
|
289
|
+
scenarios: scenarios.map((s) => ({
|
|
290
|
+
id: s.id,
|
|
291
|
+
title: s.title,
|
|
292
|
+
stepsCount: s.stepsCount,
|
|
293
|
+
status: s.status,
|
|
294
|
+
route: s.route,
|
|
295
|
+
firstStepAt: s.firstStepAt,
|
|
296
|
+
lastStepAt: s.lastStepAt,
|
|
297
|
+
})),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
case 'get_scenario': {
|
|
302
|
+
const scenario = db.getScenario(args.scenarioId);
|
|
303
|
+
if (!scenario) {
|
|
304
|
+
throw new Error(`Scenario '${args.scenarioId}' not found.`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
id: scenario.id,
|
|
309
|
+
title: scenario.title,
|
|
310
|
+
stepsCount: scenario.stepsCount,
|
|
311
|
+
status: scenario.status,
|
|
312
|
+
steps: scenario.steps.map((step) => ({
|
|
313
|
+
id: step.id,
|
|
314
|
+
stepNumber: step.stepNumber,
|
|
315
|
+
type: step.type,
|
|
316
|
+
message: step.message,
|
|
317
|
+
route: step.route,
|
|
318
|
+
url: step.url,
|
|
319
|
+
targetSelector: step.target?.selector,
|
|
320
|
+
boundingRect: step.target?.boundingRect || step.region,
|
|
321
|
+
elementContext: step.elementContext,
|
|
322
|
+
screenshot: step.screenshots?.original,
|
|
323
|
+
status: step.status,
|
|
324
|
+
createdAt: step.createdAt,
|
|
325
|
+
})),
|
|
326
|
+
createdAt: scenario.createdAt,
|
|
327
|
+
updatedAt: scenario.updatedAt,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
275
331
|
case 'get_note': {
|
|
276
332
|
const note = db.getNote(args.noteId);
|
|
277
333
|
if (!note) {
|
|
@@ -285,6 +341,9 @@ export async function handleToolCall(name: string, args: any, ctx: McpContext):
|
|
|
285
341
|
type: note.type,
|
|
286
342
|
status: note.status,
|
|
287
343
|
message: note.message,
|
|
344
|
+
scenarioId: note.scenarioId,
|
|
345
|
+
stepNumber: note.stepNumber,
|
|
346
|
+
scenarioTitle: note.scenarioTitle,
|
|
288
347
|
route: note.route,
|
|
289
348
|
url: note.url,
|
|
290
349
|
viewport: note.viewport,
|