browsertrack 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -2
- package/dist/{chunk-ILRYKMME.js → chunk-3HOXPTM2.js} +97 -3
- package/dist/chunk-3HOXPTM2.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-G2Y3CXCY.js → chunk-UP5JKCFY.js} +84 -2
- package/dist/chunk-UP5JKCFY.js.map +1 -0
- package/dist/{chunk-SKCMT2DE.js → chunk-WB7ZKWK7.js} +453 -152
- package/dist/chunk-WB7ZKWK7.js.map +1 -0
- package/dist/cli/index.js +200 -5
- package/dist/cli/index.js.map +1 -1
- package/dist/client/index.cjs +452 -151
- package/dist/client/index.d.ts +28 -9
- package/dist/client/index.js +1 -1
- package/dist/client.iife.js +185 -29
- 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-B43IohQY.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/{notes-CBvN91Wf.d.ts → notes-BMnonq46.d.ts} +24 -1
- package/dist/{projects-CY8ungMt.d.ts → projects-D5J-egVN.d.ts} +1 -1
- package/dist/{server-Dd8NX2Mk.d.ts → server-BztYp1Zc.d.ts} +1 -1
- package/docs/index.md +2 -1
- package/docs/mcp-reference.md +14 -1
- package/docs/scenarios-flows.md +86 -0
- package/package.json +1 -1
- package/packages/client/src/notes/inspector.ts +533 -161
- package/packages/core/src/types/notes.ts +25 -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 +58 -0
- package/packages/mcp/src/tools.ts +28 -0
- package/test/client/interceptors.test.ts +64 -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
|
@@ -73,6 +73,9 @@ export interface VisualNote {
|
|
|
73
73
|
region?: RegionContext;
|
|
74
74
|
status: NoteStatus;
|
|
75
75
|
incidentId?: string;
|
|
76
|
+
scenarioId?: string;
|
|
77
|
+
stepNumber?: number;
|
|
78
|
+
scenarioTitle?: string;
|
|
76
79
|
screenshots?: {
|
|
77
80
|
original?: string;
|
|
78
81
|
after?: string;
|
|
@@ -82,6 +85,28 @@ export interface VisualNote {
|
|
|
82
85
|
resolvedAt?: string;
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
export interface ScenarioOverview {
|
|
89
|
+
id: string;
|
|
90
|
+
projectId: string;
|
|
91
|
+
title: string;
|
|
92
|
+
stepsCount: number;
|
|
93
|
+
status: NoteStatus;
|
|
94
|
+
route: string;
|
|
95
|
+
firstStepAt: string;
|
|
96
|
+
lastStepAt: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ScenarioDetail {
|
|
100
|
+
id: string;
|
|
101
|
+
projectId: string;
|
|
102
|
+
title: string;
|
|
103
|
+
stepsCount: number;
|
|
104
|
+
status: NoteStatus;
|
|
105
|
+
steps: VisualNote[];
|
|
106
|
+
createdAt: string;
|
|
107
|
+
updatedAt: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
85
110
|
export interface NoteVerificationResult {
|
|
86
111
|
noteId: string;
|
|
87
112
|
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,
|
|
@@ -252,6 +252,7 @@ export async function handleToolCall(name: string, args: any, ctx: McpContext):
|
|
|
252
252
|
case 'list_notes': {
|
|
253
253
|
const notes = db.listNotes({
|
|
254
254
|
projectId: args.projectId,
|
|
255
|
+
scenarioId: args.scenarioId,
|
|
255
256
|
status: args.status,
|
|
256
257
|
limit: args.limit || 20,
|
|
257
258
|
});
|
|
@@ -263,6 +264,9 @@ export async function handleToolCall(name: string, args: any, ctx: McpContext):
|
|
|
263
264
|
type: n.type,
|
|
264
265
|
status: n.status,
|
|
265
266
|
route: n.route,
|
|
267
|
+
scenarioId: n.scenarioId,
|
|
268
|
+
stepNumber: n.stepNumber,
|
|
269
|
+
scenarioTitle: n.scenarioTitle,
|
|
266
270
|
viewport: `${n.viewport.width} × ${n.viewport.height}`,
|
|
267
271
|
target: n.target?.selector || n.type,
|
|
268
272
|
message: n.message,
|
|
@@ -272,6 +276,57 @@ export async function handleToolCall(name: string, args: any, ctx: McpContext):
|
|
|
272
276
|
};
|
|
273
277
|
}
|
|
274
278
|
|
|
279
|
+
case 'list_scenarios': {
|
|
280
|
+
const scenarios = db.listScenarios({
|
|
281
|
+
projectId: args.projectId,
|
|
282
|
+
status: args.status,
|
|
283
|
+
limit: args.limit || 20,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
total: scenarios.length,
|
|
288
|
+
scenarios: scenarios.map((s) => ({
|
|
289
|
+
id: s.id,
|
|
290
|
+
title: s.title,
|
|
291
|
+
stepsCount: s.stepsCount,
|
|
292
|
+
status: s.status,
|
|
293
|
+
route: s.route,
|
|
294
|
+
firstStepAt: s.firstStepAt,
|
|
295
|
+
lastStepAt: s.lastStepAt,
|
|
296
|
+
})),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
case 'get_scenario': {
|
|
301
|
+
const scenario = db.getScenario(args.scenarioId);
|
|
302
|
+
if (!scenario) {
|
|
303
|
+
throw new Error(`Scenario '${args.scenarioId}' not found.`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
id: scenario.id,
|
|
308
|
+
title: scenario.title,
|
|
309
|
+
stepsCount: scenario.stepsCount,
|
|
310
|
+
status: scenario.status,
|
|
311
|
+
steps: scenario.steps.map((step) => ({
|
|
312
|
+
id: step.id,
|
|
313
|
+
stepNumber: step.stepNumber,
|
|
314
|
+
type: step.type,
|
|
315
|
+
message: step.message,
|
|
316
|
+
route: step.route,
|
|
317
|
+
url: step.url,
|
|
318
|
+
targetSelector: step.target?.selector,
|
|
319
|
+
boundingRect: step.target?.boundingRect || step.region,
|
|
320
|
+
elementContext: step.elementContext,
|
|
321
|
+
screenshot: step.screenshots?.original,
|
|
322
|
+
status: step.status,
|
|
323
|
+
createdAt: step.createdAt,
|
|
324
|
+
})),
|
|
325
|
+
createdAt: scenario.createdAt,
|
|
326
|
+
updatedAt: scenario.updatedAt,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
275
330
|
case 'get_note': {
|
|
276
331
|
const note = db.getNote(args.noteId);
|
|
277
332
|
if (!note) {
|
|
@@ -285,6 +340,9 @@ export async function handleToolCall(name: string, args: any, ctx: McpContext):
|
|
|
285
340
|
type: note.type,
|
|
286
341
|
status: note.status,
|
|
287
342
|
message: note.message,
|
|
343
|
+
scenarioId: note.scenarioId,
|
|
344
|
+
stepNumber: note.stepNumber,
|
|
345
|
+
scenarioTitle: note.scenarioTitle,
|
|
288
346
|
route: note.route,
|
|
289
347
|
url: note.url,
|
|
290
348
|
viewport: note.viewport,
|
|
@@ -156,6 +156,7 @@ export const TOOLS: Tool[] = [
|
|
|
156
156
|
type: 'object',
|
|
157
157
|
properties: {
|
|
158
158
|
projectId: { type: 'string', description: 'Filter notes by project ID or name' },
|
|
159
|
+
scenarioId: { type: 'string', description: 'Filter notes by scenario / flow ID' },
|
|
159
160
|
status: {
|
|
160
161
|
type: 'string',
|
|
161
162
|
enum: ['OPEN', 'IN_PROGRESS', 'VERIFYING', 'RESOLVED', 'FAILED', 'INCONCLUSIVE'],
|
|
@@ -165,6 +166,33 @@ export const TOOLS: Tool[] = [
|
|
|
165
166
|
},
|
|
166
167
|
},
|
|
167
168
|
},
|
|
169
|
+
{
|
|
170
|
+
name: 'list_scenarios',
|
|
171
|
+
description: 'List recorded reproduction scenarios and multi-step user interaction flows',
|
|
172
|
+
inputSchema: {
|
|
173
|
+
type: 'object',
|
|
174
|
+
properties: {
|
|
175
|
+
projectId: { type: 'string', description: 'Filter scenarios by project ID or name' },
|
|
176
|
+
status: {
|
|
177
|
+
type: 'string',
|
|
178
|
+
enum: ['OPEN', 'RESOLVED'],
|
|
179
|
+
description: 'Filter by scenario status',
|
|
180
|
+
},
|
|
181
|
+
limit: { type: 'number', description: 'Maximum number of scenarios to return (default: 20)' },
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: 'get_scenario',
|
|
187
|
+
description: 'Retrieve full chronological step-by-step reproduction flow with selectors, route, screenshots, and action details',
|
|
188
|
+
inputSchema: {
|
|
189
|
+
type: 'object',
|
|
190
|
+
properties: {
|
|
191
|
+
scenarioId: { type: 'string', description: 'The unique ID of the scenario / flow (e.g. scen_123)' },
|
|
192
|
+
},
|
|
193
|
+
required: ['scenarioId'],
|
|
194
|
+
},
|
|
195
|
+
},
|
|
168
196
|
{
|
|
169
197
|
name: 'get_note',
|
|
170
198
|
description: 'Retrieve full debugging context for a visual note (message, route, viewport dimensions, target element selector, DOM context, screenshot file path, project path)',
|
|
@@ -127,4 +127,68 @@ describe('Client Interceptors & Utilities', () => {
|
|
|
127
127
|
|
|
128
128
|
inspector.destroy();
|
|
129
129
|
});
|
|
130
|
+
|
|
131
|
+
it('should support multi-step scenario recording flow in NoteInspector', async () => {
|
|
132
|
+
const mockTransport = { send: vi.fn(), getSessionId: () => 'sess_123', onMessage: vi.fn(() => () => {}) } as any;
|
|
133
|
+
const mockDriver = { captureElement: vi.fn().mockResolvedValue({ ok: false }) } as any;
|
|
134
|
+
|
|
135
|
+
const inspector = new NoteInspector(mockTransport, mockDriver, {
|
|
136
|
+
showToolbar: true,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
inspector.init();
|
|
140
|
+
|
|
141
|
+
inspector.startScenario('Checkout Journey');
|
|
142
|
+
expect((inspector as any).activeScenario).not.toBeNull();
|
|
143
|
+
expect((inspector as any).activeScenario.title).toBe('Checkout Journey');
|
|
144
|
+
expect((inspector as any).activeScenario.stepNumber).toBe(1);
|
|
145
|
+
expect((inspector as any).activeMode).toBe('element');
|
|
146
|
+
|
|
147
|
+
const fakeElement = {
|
|
148
|
+
tagName: 'BUTTON',
|
|
149
|
+
id: 'checkout-button',
|
|
150
|
+
getAttribute: () => null,
|
|
151
|
+
attributes: [],
|
|
152
|
+
classList: [],
|
|
153
|
+
getBoundingClientRect: () => ({ x: 10, y: 20, width: 100, height: 40, top: 20, left: 10, bottom: 60, right: 110 }),
|
|
154
|
+
} as any;
|
|
155
|
+
|
|
156
|
+
await inspector.saveVisualNote(fakeElement, 'Click checkout', 'element', {
|
|
157
|
+
scenarioId: (inspector as any).activeScenario.id,
|
|
158
|
+
stepNumber: 1,
|
|
159
|
+
scenarioTitle: 'Checkout Journey',
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(mockTransport.send).toHaveBeenCalledWith(
|
|
163
|
+
expect.objectContaining({
|
|
164
|
+
type: 'create_note',
|
|
165
|
+
scenarioId: expect.any(String),
|
|
166
|
+
stepNumber: 1,
|
|
167
|
+
scenarioTitle: 'Checkout Journey',
|
|
168
|
+
})
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
inspector.finishScenario();
|
|
172
|
+
expect((inspector as any).activeScenario).toBeNull();
|
|
173
|
+
expect((inspector as any).activeMode).toBe('idle');
|
|
174
|
+
|
|
175
|
+
inspector.destroy();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should support toast notifications in NoteInspector', () => {
|
|
179
|
+
const mockTransport = { send: vi.fn(), getSessionId: () => 'sess_123', onMessage: vi.fn(() => () => {}) } as any;
|
|
180
|
+
const mockDriver = { captureElement: vi.fn() } as any;
|
|
181
|
+
|
|
182
|
+
const inspector = new NoteInspector(mockTransport, mockDriver, {
|
|
183
|
+
showToolbar: true,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
inspector.init();
|
|
187
|
+
// Test showing toast without errors
|
|
188
|
+
expect(() => {
|
|
189
|
+
inspector.showToast('Test Toast Notification', '✨');
|
|
190
|
+
}).not.toThrow();
|
|
191
|
+
|
|
192
|
+
inspector.destroy();
|
|
193
|
+
});
|
|
130
194
|
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { StorageDB } from '../../packages/daemon/src/storage/db.js';
|
|
5
|
+
import { handleToolCall } from '../../packages/mcp/src/handlers.js';
|
|
6
|
+
import type { VisualNote } from '../../packages/core/src/index.js';
|
|
7
|
+
|
|
8
|
+
const TEST_DB = path.join(process.cwd(), 'tmp', 'test-scenario.db');
|
|
9
|
+
|
|
10
|
+
describe('Scenario & Multi-Step Flow Storage', () => {
|
|
11
|
+
let db: StorageDB;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
if (fs.existsSync(TEST_DB)) fs.unlinkSync(TEST_DB);
|
|
15
|
+
db = new StorageDB(TEST_DB);
|
|
16
|
+
db.upsertProject({
|
|
17
|
+
id: 'proj_e2e',
|
|
18
|
+
name: 'E2E App',
|
|
19
|
+
origin: 'http://localhost:3000',
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
db.close();
|
|
25
|
+
if (fs.existsSync(TEST_DB)) fs.unlinkSync(TEST_DB);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should store sequential scenario steps and retrieve scenario summary and detail', () => {
|
|
29
|
+
const scenarioId = 'scen_checkout_123';
|
|
30
|
+
const title = 'Checkout Promo Flow';
|
|
31
|
+
|
|
32
|
+
const step1: VisualNote = {
|
|
33
|
+
id: 'note_s1',
|
|
34
|
+
projectId: 'proj_e2e',
|
|
35
|
+
sessionId: 'sess_1',
|
|
36
|
+
type: 'element',
|
|
37
|
+
message: '1. Click on Cart button',
|
|
38
|
+
route: '/shop',
|
|
39
|
+
url: 'http://localhost:3000/shop',
|
|
40
|
+
viewport: { width: 1280, height: 800, devicePixelRatio: 1 },
|
|
41
|
+
scroll: { scrollX: 0, scrollY: 0 },
|
|
42
|
+
target: {
|
|
43
|
+
selector: '#btn-cart',
|
|
44
|
+
boundingRect: { x: 10, y: 10, width: 50, height: 30, top: 10, left: 10, bottom: 40, right: 60 },
|
|
45
|
+
visible: true,
|
|
46
|
+
},
|
|
47
|
+
scenarioId,
|
|
48
|
+
stepNumber: 1,
|
|
49
|
+
scenarioTitle: title,
|
|
50
|
+
status: 'OPEN',
|
|
51
|
+
createdAt: '2026-09-01T10:00:00.000Z',
|
|
52
|
+
updatedAt: '2026-09-01T10:00:00.000Z',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const step2: VisualNote = {
|
|
56
|
+
id: 'note_s2',
|
|
57
|
+
projectId: 'proj_e2e',
|
|
58
|
+
sessionId: 'sess_1',
|
|
59
|
+
type: 'element',
|
|
60
|
+
message: '2. Enter coupon code PROMO2026',
|
|
61
|
+
route: '/cart',
|
|
62
|
+
url: 'http://localhost:3000/cart',
|
|
63
|
+
viewport: { width: 1280, height: 800, devicePixelRatio: 1 },
|
|
64
|
+
scroll: { scrollX: 0, scrollY: 0 },
|
|
65
|
+
target: {
|
|
66
|
+
selector: '#input-coupon',
|
|
67
|
+
boundingRect: { x: 50, y: 100, width: 200, height: 40, top: 100, left: 50, bottom: 140, right: 250 },
|
|
68
|
+
visible: true,
|
|
69
|
+
},
|
|
70
|
+
scenarioId,
|
|
71
|
+
stepNumber: 2,
|
|
72
|
+
scenarioTitle: title,
|
|
73
|
+
status: 'OPEN',
|
|
74
|
+
createdAt: '2026-09-01T10:01:00.000Z',
|
|
75
|
+
updatedAt: '2026-09-01T10:01:00.000Z',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const step3: VisualNote = {
|
|
79
|
+
id: 'note_s3',
|
|
80
|
+
projectId: 'proj_e2e',
|
|
81
|
+
sessionId: 'sess_1',
|
|
82
|
+
type: 'region',
|
|
83
|
+
message: '3. Total price did not calculate discount',
|
|
84
|
+
route: '/cart',
|
|
85
|
+
url: 'http://localhost:3000/cart',
|
|
86
|
+
viewport: { width: 1280, height: 800, devicePixelRatio: 1 },
|
|
87
|
+
scroll: { scrollX: 0, scrollY: 0 },
|
|
88
|
+
region: { x: 50, y: 200, width: 300, height: 100 },
|
|
89
|
+
scenarioId,
|
|
90
|
+
stepNumber: 3,
|
|
91
|
+
scenarioTitle: title,
|
|
92
|
+
status: 'OPEN',
|
|
93
|
+
createdAt: '2026-09-01T10:02:00.000Z',
|
|
94
|
+
updatedAt: '2026-09-01T10:02:00.000Z',
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
db.insertNote(step1);
|
|
98
|
+
db.insertNote(step2);
|
|
99
|
+
db.insertNote(step3);
|
|
100
|
+
|
|
101
|
+
// Test listScenarios
|
|
102
|
+
const scenarios = db.listScenarios();
|
|
103
|
+
expect(scenarios.length).toBe(1);
|
|
104
|
+
expect(scenarios[0].id).toBe(scenarioId);
|
|
105
|
+
expect(scenarios[0].title).toBe(title);
|
|
106
|
+
expect(scenarios[0].stepsCount).toBe(3);
|
|
107
|
+
expect(scenarios[0].status).toBe('OPEN');
|
|
108
|
+
|
|
109
|
+
// Test getScenario
|
|
110
|
+
const detail = db.getScenario(scenarioId);
|
|
111
|
+
expect(detail).not.toBeNull();
|
|
112
|
+
expect(detail!.stepsCount).toBe(3);
|
|
113
|
+
expect(detail!.steps[0].id).toBe('note_s1');
|
|
114
|
+
expect(detail!.steps[0].stepNumber).toBe(1);
|
|
115
|
+
expect(detail!.steps[1].id).toBe('note_s2');
|
|
116
|
+
expect(detail!.steps[1].stepNumber).toBe(2);
|
|
117
|
+
expect(detail!.steps[2].id).toBe('note_s3');
|
|
118
|
+
expect(detail!.steps[2].stepNumber).toBe(3);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should handle list_scenarios and get_scenario MCP tool calls', async () => {
|
|
122
|
+
const scenarioId = 'scen_login_bug';
|
|
123
|
+
const note1: VisualNote = {
|
|
124
|
+
id: 'note_step_1',
|
|
125
|
+
projectId: 'proj_e2e',
|
|
126
|
+
sessionId: 'sess_1',
|
|
127
|
+
type: 'element',
|
|
128
|
+
message: 'Click Sign In',
|
|
129
|
+
route: '/login',
|
|
130
|
+
url: 'http://localhost:3000/login',
|
|
131
|
+
viewport: { width: 1280, height: 800, devicePixelRatio: 1 },
|
|
132
|
+
scroll: { scrollX: 0, scrollY: 0 },
|
|
133
|
+
scenarioId,
|
|
134
|
+
stepNumber: 1,
|
|
135
|
+
scenarioTitle: 'Auth Flow',
|
|
136
|
+
status: 'OPEN',
|
|
137
|
+
createdAt: '2026-09-01T10:00:00.000Z',
|
|
138
|
+
updatedAt: '2026-09-01T10:00:00.000Z',
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
db.insertNote(note1);
|
|
142
|
+
|
|
143
|
+
const listRes = await handleToolCall('list_scenarios', {}, { db });
|
|
144
|
+
expect(listRes.total).toBe(1);
|
|
145
|
+
expect(listRes.scenarios[0].id).toBe(scenarioId);
|
|
146
|
+
|
|
147
|
+
const getRes = await handleToolCall('get_scenario', { scenarioId }, { db });
|
|
148
|
+
expect(getRes.id).toBe(scenarioId);
|
|
149
|
+
expect(getRes.title).toBe('Auth Flow');
|
|
150
|
+
expect(getRes.steps.length).toBe(1);
|
|
151
|
+
expect(getRes.steps[0].stepNumber).toBe(1);
|
|
152
|
+
|
|
153
|
+
// Delete scenario
|
|
154
|
+
db.deleteScenario(scenarioId);
|
|
155
|
+
const afterDelete = db.listScenarios();
|
|
156
|
+
expect(afterDelete.length).toBe(0);
|
|
157
|
+
});
|
|
158
|
+
});
|