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.
Files changed (43) hide show
  1. package/AGENTS.md +4 -2
  2. package/dist/{chunk-ILRYKMME.js → chunk-3HOXPTM2.js} +97 -3
  3. package/dist/chunk-3HOXPTM2.js.map +1 -0
  4. package/dist/{chunk-SPCIROIU.js → chunk-7OCOQGDN.js} +24 -5
  5. package/dist/chunk-7OCOQGDN.js.map +1 -0
  6. package/dist/{chunk-G2Y3CXCY.js → chunk-UP5JKCFY.js} +84 -2
  7. package/dist/chunk-UP5JKCFY.js.map +1 -0
  8. package/dist/{chunk-SKCMT2DE.js → chunk-WB7ZKWK7.js} +453 -152
  9. package/dist/chunk-WB7ZKWK7.js.map +1 -0
  10. package/dist/cli/index.js +200 -5
  11. package/dist/cli/index.js.map +1 -1
  12. package/dist/client/index.cjs +452 -151
  13. package/dist/client/index.d.ts +28 -9
  14. package/dist/client/index.js +1 -1
  15. package/dist/client.iife.js +185 -29
  16. package/dist/core/index.d.ts +2 -2
  17. package/dist/daemon/index.d.ts +3 -3
  18. package/dist/daemon/index.js +2 -2
  19. package/dist/{engine-CeT9URuN.d.ts → engine-B43IohQY.d.ts} +13 -2
  20. package/dist/index.d.ts +4 -4
  21. package/dist/index.js +4 -4
  22. package/dist/mcp/index.d.ts +4 -4
  23. package/dist/mcp/index.js +2 -2
  24. package/dist/{notes-CBvN91Wf.d.ts → notes-BMnonq46.d.ts} +24 -1
  25. package/dist/{projects-CY8ungMt.d.ts → projects-D5J-egVN.d.ts} +1 -1
  26. package/dist/{server-Dd8NX2Mk.d.ts → server-BztYp1Zc.d.ts} +1 -1
  27. package/docs/index.md +2 -1
  28. package/docs/mcp-reference.md +14 -1
  29. package/docs/scenarios-flows.md +86 -0
  30. package/package.json +1 -1
  31. package/packages/client/src/notes/inspector.ts +533 -161
  32. package/packages/core/src/types/notes.ts +25 -0
  33. package/packages/daemon/src/notes/engine.ts +6 -0
  34. package/packages/daemon/src/server/ws.ts +23 -1
  35. package/packages/daemon/src/storage/db.ts +106 -3
  36. package/packages/mcp/src/handlers.ts +58 -0
  37. package/packages/mcp/src/tools.ts +28 -0
  38. package/test/client/interceptors.test.ts +64 -0
  39. package/test/daemon/scenario-storage.test.ts +158 -0
  40. package/dist/chunk-G2Y3CXCY.js.map +0 -1
  41. package/dist/chunk-ILRYKMME.js.map +0 -1
  42. package/dist/chunk-SKCMT2DE.js.map +0 -1
  43. 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(`[BrowserTrack] Visual note created: ${note.id} on ${note.route} ("${note.message}")`);
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
+ });