browsertrack 0.2.0 → 0.2.2

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 (67) hide show
  1. package/AGENTS.md +11 -6
  2. package/README.md +2 -0
  3. package/dist/{chunk-WB7ZKWK7.js → chunk-4HRLW6YF.js} +509 -109
  4. package/dist/chunk-4HRLW6YF.js.map +1 -0
  5. package/dist/{chunk-3HOXPTM2.js → chunk-AYSVE6NG.js} +808 -53
  6. package/dist/chunk-AYSVE6NG.js.map +1 -0
  7. package/dist/{chunk-6VA7GBAO.js → chunk-QRZ57ME3.js} +70 -2
  8. package/dist/chunk-QRZ57ME3.js.map +1 -0
  9. package/dist/{chunk-UP5JKCFY.js → chunk-TWEYRBDU.js} +281 -44
  10. package/dist/chunk-TWEYRBDU.js.map +1 -0
  11. package/dist/cli/index.js +1040 -546
  12. package/dist/cli/index.js.map +1 -1
  13. package/dist/client/index.cjs +536 -109
  14. package/dist/client/index.d.ts +36 -4
  15. package/dist/client/index.js +8 -4
  16. package/dist/client.iife.js +42 -19
  17. package/dist/{notes-BMnonq46.d.ts → commands-fjuqKzkm.d.ts} +125 -114
  18. package/dist/core/index.d.ts +26 -3
  19. package/dist/core/index.js +9 -1
  20. package/dist/daemon/index.d.ts +4 -4
  21. package/dist/daemon/index.js +6 -8
  22. package/dist/{engine-B43IohQY.d.ts → engine-CmchnMDq.d.ts} +2 -2
  23. package/dist/index.d.ts +5 -5
  24. package/dist/index.js +14 -7
  25. package/dist/mcp/index.d.ts +4 -4
  26. package/dist/mcp/index.js +7 -4
  27. package/dist/{projects-D5J-egVN.d.ts → projects-DB7S312i.d.ts} +1 -1
  28. package/dist/{server-BztYp1Zc.d.ts → server-DjV7RWQM.d.ts} +10 -2
  29. package/docs/cli.md +4 -1
  30. package/docs/component-resolver.md +108 -0
  31. package/docs/getting-started.md +60 -6
  32. package/docs/index.md +1 -0
  33. package/docs/mcp-reference.md +44 -2
  34. package/docs/visual-notes.md +36 -0
  35. package/package.json +1 -1
  36. package/packages/cli/src/index.ts +247 -151
  37. package/packages/client/src/client.ts +18 -1
  38. package/packages/client/src/config.ts +84 -1
  39. package/packages/client/src/index.ts +4 -1
  40. package/packages/client/src/interceptors/interaction.ts +3 -0
  41. package/packages/client/src/interceptors/navigation.ts +38 -26
  42. package/packages/client/src/interceptors/network.ts +22 -17
  43. package/packages/client/src/notes/inspector.ts +186 -51
  44. package/packages/client/src/source/resolver.ts +278 -0
  45. package/packages/client/src/transport/websocket.ts +23 -18
  46. package/packages/core/src/index.ts +1 -0
  47. package/packages/core/src/safety.ts +86 -0
  48. package/packages/core/src/types/events.ts +3 -0
  49. package/packages/core/src/types/notes.ts +11 -0
  50. package/packages/daemon/src/server/daemon.ts +7 -1
  51. package/packages/daemon/src/server/http.ts +125 -5
  52. package/packages/daemon/src/server/ws.ts +33 -29
  53. package/packages/daemon/src/storage/db.ts +57 -35
  54. package/packages/mcp/src/handlers.ts +115 -45
  55. package/packages/mcp/src/server.ts +202 -2
  56. package/test/client/component-resolver.test.ts +141 -0
  57. package/test/client/interceptors.test.ts +56 -0
  58. package/test/core/safety.test.ts +106 -0
  59. package/test/daemon/storage.test.ts +36 -0
  60. package/test/e2e/daemon-mcp-e2e.test.ts +10 -0
  61. package/test/mcp/auto-start.test.ts +87 -0
  62. package/dist/chunk-3HOXPTM2.js.map +0 -1
  63. package/dist/chunk-6VA7GBAO.js.map +0 -1
  64. package/dist/chunk-7OCOQGDN.js +0 -635
  65. package/dist/chunk-7OCOQGDN.js.map +0 -1
  66. package/dist/chunk-UP5JKCFY.js.map +0 -1
  67. package/dist/chunk-WB7ZKWK7.js.map +0 -1
@@ -1,3 +1,7 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { spawn } from 'node:child_process';
1
5
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
7
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
@@ -7,18 +11,98 @@ import { ScreenshotStore } from '../../daemon/src/storage/screenshot-store.js';
7
11
  import { SessionManager } from '../../daemon/src/session/manager.js';
8
12
  import { NotesEngine } from '../../daemon/src/notes/engine.js';
9
13
  import { NoteVerificationEngine } from '../../daemon/src/notes/verification.js';
10
- import { VerificationEngine } from '../../daemon/src/verification/engine.js';
14
+ import { VerificationEngine, createDaemon } from '../../daemon/src/index.js';
11
15
  import type { McpContext } from './handlers.js';
12
16
  import { handleToolCall } from './handlers.js';
13
17
  import { TOOLS } from './tools.js';
14
18
 
19
+ export async function isDaemonRunning(host: string, port: number): Promise<boolean> {
20
+ try {
21
+ const res = await fetch(`http://${host}:${port}/health`, {
22
+ signal: AbortSignal.timeout(600),
23
+ });
24
+ if (res.ok) {
25
+ const data = (await res.json().catch(() => ({}))) as any;
26
+ return data?.name === 'browsertrack';
27
+ }
28
+ } catch {}
29
+ return false;
30
+ }
31
+
32
+ function resolveCliPath(): string | null {
33
+ if (process.argv[1]) {
34
+ const candidate = process.argv[1];
35
+ if (
36
+ candidate.endsWith('cli/index.js') ||
37
+ candidate.endsWith('browsertrack') ||
38
+ candidate.endsWith('bin/browsertrack.js') ||
39
+ candidate.endsWith('dist/cli/index.js')
40
+ ) {
41
+ if (fs.existsSync(candidate)) return candidate;
42
+ }
43
+ }
44
+
45
+ try {
46
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
47
+ const paths = [
48
+ path.resolve(currentDir, '../cli/index.js'),
49
+ path.resolve(currentDir, '../../cli/index.js'),
50
+ path.resolve(currentDir, '../../dist/cli/index.js'),
51
+ ];
52
+ for (const p of paths) {
53
+ if (fs.existsSync(p)) return p;
54
+ }
55
+ } catch {}
56
+
57
+ return null;
58
+ }
59
+
60
+ function acquireBootLock(lockFile: string): boolean {
61
+ try {
62
+ fs.mkdirSync(path.dirname(lockFile), { recursive: true });
63
+ const fd = fs.openSync(lockFile, 'wx');
64
+ fs.writeSync(fd, String(process.pid));
65
+ fs.closeSync(fd);
66
+ return true;
67
+ } catch {
68
+ try {
69
+ const stats = fs.statSync(lockFile);
70
+ // If older than 5 seconds, assume stale lock and break it
71
+ if (Date.now() - stats.mtimeMs > 5000) {
72
+ fs.unlinkSync(lockFile);
73
+ const fd = fs.openSync(lockFile, 'wx');
74
+ fs.writeSync(fd, String(process.pid));
75
+ fs.closeSync(fd);
76
+ return true;
77
+ }
78
+ } catch {}
79
+ return false;
80
+ }
81
+ }
82
+
83
+ function releaseBootLock(lockFile: string): void {
84
+ try {
85
+ if (fs.existsSync(lockFile)) {
86
+ fs.unlinkSync(lockFile);
87
+ }
88
+ } catch {}
89
+ }
90
+
15
91
  export interface McpServerOptions {
16
92
  dbPath?: string;
17
93
  context?: Partial<McpContext>;
94
+ autoStartDaemon?: boolean;
95
+ port?: number;
96
+ host?: string;
97
+ detached?: boolean;
18
98
  }
19
99
 
20
100
  export function createMcpServer(options: McpServerOptions = {}) {
21
- const config = getDaemonConfig({ dbPath: options.dbPath });
101
+ const config = getDaemonConfig({
102
+ dbPath: options.dbPath,
103
+ port: options.port,
104
+ host: options.host,
105
+ });
22
106
  const db = options.context?.db || new StorageDB(config.dbPath);
23
107
  const screenshotStore = new ScreenshotStore(config.screenshotsDir);
24
108
  const sessionManager = options.context?.sessionManager || new SessionManager(db);
@@ -35,6 +119,113 @@ export function createMcpServer(options: McpServerOptions = {}) {
35
119
  ...options.context,
36
120
  };
37
121
 
122
+ let embeddedDaemon: any = null;
123
+
124
+ async function ensureDaemon(): Promise<void> {
125
+ if (options.autoStartDaemon === false || options.context?.sessionManager) {
126
+ return;
127
+ }
128
+
129
+ // 1. Fast path: check if a singleton daemon is already running
130
+ if (await isDaemonRunning(config.host, config.port)) {
131
+ console.error(`[BrowserTrack MCP] Connected to active singleton daemon at http://${config.host}:${config.port}`);
132
+ return;
133
+ }
134
+
135
+ // 2. Concurrency lock to guarantee only ONE daemon is ever started across multiple IDE sessions
136
+ const lockFile = path.join(config.dataDir, 'daemon_boot.lock');
137
+ const hasLock = acquireBootLock(lockFile);
138
+
139
+ if (!hasLock) {
140
+ // Another session is currently booting the daemon, wait for it
141
+ for (let i = 0; i < 30; i++) {
142
+ await new Promise((r) => setTimeout(r, 100));
143
+ if (await isDaemonRunning(config.host, config.port)) {
144
+ console.error(`[BrowserTrack MCP] Connected to shared singleton daemon at http://${config.host}:${config.port}`);
145
+ return;
146
+ }
147
+ }
148
+ }
149
+
150
+ try {
151
+ // 3. Double-check before starting
152
+ if (await isDaemonRunning(config.host, config.port)) {
153
+ console.error(`[BrowserTrack MCP] Connected to shared singleton daemon at http://${config.host}:${config.port}`);
154
+ return;
155
+ }
156
+
157
+ // 4. Detached background process (singleton daemon that survives across IDE windows/sessions)
158
+ if (options.detached !== false) {
159
+ const cliPath = resolveCliPath();
160
+ if (cliPath && fs.existsSync(cliPath)) {
161
+ try {
162
+ const child = spawn(
163
+ process.execPath,
164
+ [cliPath, 'start', '--port', String(config.port), '--host', config.host],
165
+ {
166
+ detached: true,
167
+ stdio: 'ignore',
168
+ env: { ...process.env, BROWSERTRACK_DAEMON_DETACHED: '1' },
169
+ }
170
+ );
171
+ child.unref();
172
+
173
+ for (let i = 0; i < 30; i++) {
174
+ await new Promise((r) => setTimeout(r, 100));
175
+ if (await isDaemonRunning(config.host, config.port)) {
176
+ console.error(
177
+ `[BrowserTrack MCP] Started singleton background daemon on http://${config.host}:${config.port}`
178
+ );
179
+ return;
180
+ }
181
+ }
182
+ } catch (spawnErr: any) {
183
+ console.error(
184
+ `[BrowserTrack MCP] Detached daemon spawn failed (${spawnErr?.message}), falling back to in-process daemon.`
185
+ );
186
+ }
187
+ }
188
+ }
189
+
190
+ // 5. In-process fallback (used when detached is false or in test runners)
191
+ embeddedDaemon = createDaemon({
192
+ host: config.host,
193
+ port: config.port,
194
+ dbPath: config.dbPath,
195
+ screenshotsDir: config.screenshotsDir,
196
+ verbose: false,
197
+ });
198
+ await embeddedDaemon.start();
199
+ console.error(`[BrowserTrack MCP] Started singleton daemon on http://${config.host}:${config.port}`);
200
+
201
+ ctx.db = embeddedDaemon.db;
202
+ ctx.sessionManager = embeddedDaemon.sessionManager;
203
+ ctx.verificationEngine = embeddedDaemon.verificationEngine;
204
+ ctx.noteVerificationEngine = embeddedDaemon.noteVerificationEngine;
205
+
206
+ const cleanup = () => {
207
+ if (embeddedDaemon) {
208
+ try {
209
+ embeddedDaemon.stop();
210
+ } catch {}
211
+ embeddedDaemon = null;
212
+ }
213
+ };
214
+
215
+ process.on('exit', cleanup);
216
+ process.on('SIGINT', cleanup);
217
+ process.on('SIGTERM', cleanup);
218
+ } catch (err: any) {
219
+ console.error(
220
+ `[BrowserTrack MCP] Note: Daemon startup skipped (${err?.message}). Running MCP in standalone database mode.`
221
+ );
222
+ } finally {
223
+ if (hasLock) {
224
+ releaseBootLock(lockFile);
225
+ }
226
+ }
227
+ }
228
+
38
229
  const server = new Server(
39
230
  {
40
231
  name: 'browsertrack-mcp',
@@ -78,7 +269,16 @@ export function createMcpServer(options: McpServerOptions = {}) {
78
269
 
79
270
  return {
80
271
  server,
272
+ ctx,
273
+ ensureDaemon,
274
+ async stopDaemon() {
275
+ if (embeddedDaemon) {
276
+ await embeddedDaemon.stop();
277
+ embeddedDaemon = null;
278
+ }
279
+ },
81
280
  async startStdio() {
281
+ await ensureDaemon();
82
282
  const transport = new StdioServerTransport();
83
283
  await server.connect(transport);
84
284
  },
@@ -0,0 +1,141 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { resolveComponentSource } from '../../packages/client/src/source/resolver.js';
3
+
4
+ describe('Component & Source-Map Resolver', () => {
5
+ it('should resolve React component name, source file, line number, and hierarchy from Fiber', () => {
6
+ const parentFiber = {
7
+ type: { name: 'ShopLayout', displayName: 'ShopLayout' },
8
+ return: {
9
+ type: { name: 'App' },
10
+ return: null,
11
+ },
12
+ };
13
+
14
+ const ownerFiber = {
15
+ type: { name: 'CheckoutButton' },
16
+ _debugSource: {
17
+ fileName: 'src/components/CheckoutButton.tsx',
18
+ lineNumber: 42,
19
+ columnNumber: 8,
20
+ },
21
+ };
22
+
23
+ const hostFiber = {
24
+ type: 'button',
25
+ _debugSource: {
26
+ fileName: 'src/components/CheckoutButton.tsx',
27
+ lineNumber: 42,
28
+ columnNumber: 8,
29
+ },
30
+ _debugOwner: ownerFiber,
31
+ return: {
32
+ type: { name: 'CheckoutButton' },
33
+ memoizedProps: { label: 'Complete Purchase', amount: 49.99 },
34
+ return: parentFiber,
35
+ },
36
+ };
37
+
38
+ const fakeButton = {
39
+ tagName: 'BUTTON',
40
+ __reactFiber$abc123: hostFiber,
41
+ } as any;
42
+
43
+ const resolved = resolveComponentSource(fakeButton);
44
+ expect(resolved).toBeDefined();
45
+ expect(resolved?.framework).toBe('react');
46
+ expect(resolved?.componentName).toBe('CheckoutButton');
47
+ expect(resolved?.sourceFile).toBe('src/components/CheckoutButton.tsx');
48
+ expect(resolved?.sourceLine).toBe(42);
49
+ expect(resolved?.sourceColumn).toBe(8);
50
+ expect(resolved?.hierarchy).toContain('CheckoutButton');
51
+ expect(resolved?.hierarchy).toContain('ShopLayout');
52
+ expect(resolved?.hierarchy).toContain('App');
53
+ expect(resolved?.props?.label).toBe('Complete Purchase');
54
+ expect(resolved?.props?.amount).toBe(49.99);
55
+ });
56
+
57
+ it('should resolve Vue 3 component name and source file from __vueParentComponent', () => {
58
+ const fakeVueElement = {
59
+ tagName: 'DIV',
60
+ __vueParentComponent: {
61
+ type: {
62
+ __name: 'UserProfileCard',
63
+ __file: 'src/components/UserProfileCard.vue?t=12345',
64
+ },
65
+ props: {
66
+ username: 'alice',
67
+ isAdmin: false,
68
+ },
69
+ parent: {
70
+ type: { name: 'DashboardView' },
71
+ parent: null,
72
+ },
73
+ },
74
+ } as any;
75
+
76
+ const resolved = resolveComponentSource(fakeVueElement);
77
+ expect(resolved).toBeDefined();
78
+ expect(resolved?.framework).toBe('vue');
79
+ expect(resolved?.componentName).toBe('UserProfileCard');
80
+ expect(resolved?.sourceFile).toBe('src/components/UserProfileCard.vue');
81
+ expect(resolved?.hierarchy).toEqual(['DashboardView', 'UserProfileCard']);
82
+ expect(resolved?.props?.username).toBe('alice');
83
+ });
84
+
85
+ it('should resolve Svelte component source location from __svelte_meta', () => {
86
+ const fakeSvelteElement = {
87
+ tagName: 'DIV',
88
+ __svelte_meta: {
89
+ loc: {
90
+ file: 'src/routes/Counter.svelte',
91
+ line: 18,
92
+ column: 4,
93
+ },
94
+ },
95
+ } as any;
96
+
97
+ const resolved = resolveComponentSource(fakeSvelteElement);
98
+ expect(resolved).toBeDefined();
99
+ expect(resolved?.framework).toBe('svelte');
100
+ expect(resolved?.sourceFile).toBe('src/routes/Counter.svelte');
101
+ expect(resolved?.sourceLine).toBe(18);
102
+ expect(resolved?.componentName).toBe('Counter');
103
+ });
104
+
105
+ it('should identify Custom Elements / Web Components by hyphenated tag name', () => {
106
+ const fakeCustomElement = {
107
+ tagName: 'USER-BADGE',
108
+ } as any;
109
+
110
+ const resolved = resolveComponentSource(fakeCustomElement);
111
+ expect(resolved).toBeDefined();
112
+ expect(resolved?.framework).toBe('web-component');
113
+ expect(resolved?.componentName).toBe('user-badge');
114
+ });
115
+
116
+ it('should fallback to data-component and data-source-file attributes', () => {
117
+ const fakeElement = {
118
+ tagName: 'DIV',
119
+ closest: (selector: string) => {
120
+ if (selector.includes('data-component')) {
121
+ return {
122
+ getAttribute: (attr: string) => {
123
+ if (attr === 'data-component') return 'MarketingBanner';
124
+ if (attr === 'data-source-file') return 'src/components/Banner.tsx';
125
+ if (attr === 'data-source-line') return '88';
126
+ return null;
127
+ },
128
+ };
129
+ }
130
+ return null;
131
+ },
132
+ } as any;
133
+
134
+ const resolved = resolveComponentSource(fakeElement);
135
+ expect(resolved).toBeDefined();
136
+ expect(resolved?.framework).toBe('vanilla');
137
+ expect(resolved?.componentName).toBe('MarketingBanner');
138
+ expect(resolved?.sourceFile).toBe('src/components/Banner.tsx');
139
+ expect(resolved?.sourceLine).toBe(88);
140
+ });
141
+ });
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { BreadcrumbBuffer } from '../../packages/client/src/breadcrumbs.js';
3
+ import { shouldHideUIFromUrl } from '../../packages/client/src/config.js';
3
4
  import { setupConsoleInterceptors } from '../../packages/client/src/interceptors/console.js';
4
5
  import { NoteInspector } from '../../packages/client/src/notes/inspector.js';
5
6
  import { getSemanticSelector } from '../../packages/core/src/selector.js';
@@ -191,4 +192,59 @@ describe('Client Interceptors & Utilities', () => {
191
192
 
192
193
  inspector.destroy();
193
194
  });
195
+
196
+ it('should detect UI hide directives from various URL query strings', () => {
197
+ // Built-in ?bt=0, ?bt=false, ?bt=hidden, ?bt=off, ?bt=none
198
+ expect(shouldHideUIFromUrl(undefined, '?bt=0')).toBe(true);
199
+ expect(shouldHideUIFromUrl(undefined, '?bt=false')).toBe(true);
200
+ expect(shouldHideUIFromUrl(undefined, '?bt=hidden')).toBe(true);
201
+ expect(shouldHideUIFromUrl(undefined, '?bt=off')).toBe(true);
202
+ expect(shouldHideUIFromUrl(undefined, '?bt=none')).toBe(true);
203
+ expect(shouldHideUIFromUrl(undefined, '?bt=disabled')).toBe(true);
204
+
205
+ // ?browsertrack=false, ?browsertrack=0, ?browsertrack=hidden
206
+ expect(shouldHideUIFromUrl(undefined, '?browsertrack=false')).toBe(true);
207
+ expect(shouldHideUIFromUrl(undefined, '?browsertrack=0')).toBe(true);
208
+ expect(shouldHideUIFromUrl(undefined, '?browsertrack=hidden')).toBe(true);
209
+
210
+ // Flags: ?no_bt, ?no_browsertrack, ?hide_bt, ?hide_browsertrack
211
+ expect(shouldHideUIFromUrl(undefined, '?no_bt')).toBe(true);
212
+ expect(shouldHideUIFromUrl(undefined, '?no_bt=1')).toBe(true);
213
+ expect(shouldHideUIFromUrl(undefined, '?no_browsertrack')).toBe(true);
214
+ expect(shouldHideUIFromUrl(undefined, '?hide_bt=1')).toBe(true);
215
+ expect(shouldHideUIFromUrl(undefined, '?hide_browsertrack=true')).toBe(true);
216
+ expect(shouldHideUIFromUrl(undefined, '?bt_ui=0')).toBe(true);
217
+ expect(shouldHideUIFromUrl(undefined, '?bt_hide=1')).toBe(true);
218
+
219
+ // Custom query parameters
220
+ expect(shouldHideUIFromUrl('clean_view', '?clean_view=1')).toBe(true);
221
+ expect(shouldHideUIFromUrl(['e2e', 'cypress'], '?cypress=true')).toBe(true);
222
+
223
+ // Non-hide queries
224
+ expect(shouldHideUIFromUrl(undefined, '')).toBe(false);
225
+ expect(shouldHideUIFromUrl(undefined, '?user=alice&tab=profile')).toBe(false);
226
+ expect(shouldHideUIFromUrl(undefined, '?bt=1')).toBe(false);
227
+ expect(shouldHideUIFromUrl(undefined, '?bt=true')).toBe(false);
228
+ });
229
+
230
+ it('should support hiding and toggling visibility in NoteInspector and BrowserTrackClient', () => {
231
+ const mockTransport = { send: vi.fn(), getSessionId: () => 'sess_123', onMessage: vi.fn(() => () => {}) } as any;
232
+ const mockDriver = { captureElement: vi.fn() } as any;
233
+
234
+ const hiddenInspector = new NoteInspector(mockTransport, mockDriver, {
235
+ showToolbar: true,
236
+ hidden: true,
237
+ });
238
+
239
+ expect(hiddenInspector.isVisible()).toBe(false);
240
+
241
+ // Toggling visibility
242
+ hiddenInspector.setVisible(true);
243
+ expect(hiddenInspector.isVisible()).toBe(true);
244
+
245
+ hiddenInspector.setVisible(false);
246
+ expect(hiddenInspector.isVisible()).toBe(false);
247
+
248
+ hiddenInspector.destroy();
249
+ });
194
250
  });
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { safeAsync, safeExecute, safeJsonParse, safeJsonStringify } from '../../packages/core/src/safety.js';
3
+
4
+ describe('BrowserTrack Core Safety Utilities', () => {
5
+ describe('safeJsonParse', () => {
6
+ it('should parse valid JSON correctly', () => {
7
+ const result = safeJsonParse('{"foo":"bar","num":42}', {});
8
+ expect(result).toEqual({ foo: 'bar', num: 42 });
9
+ });
10
+
11
+ it('should return fallback on malformed JSON without throwing', () => {
12
+ const fallback = { status: 'fallback' };
13
+ const result = safeJsonParse('{ broken json: true', fallback);
14
+ expect(result).toBe(fallback);
15
+ });
16
+
17
+ it('should handle null, undefined, or empty string gracefully', () => {
18
+ expect(safeJsonParse(null, 'default')).toBe('default');
19
+ expect(safeJsonParse(undefined, 'default')).toBe('default');
20
+ expect(safeJsonParse('', 'default')).toBe('default');
21
+ });
22
+
23
+ it('should return raw object if input is already parsed object', () => {
24
+ const obj = { already: 'object' };
25
+ expect(safeJsonParse(obj, {})).toBe(obj);
26
+ });
27
+ });
28
+
29
+ describe('safeJsonStringify', () => {
30
+ it('should serialize simple objects into valid JSON', () => {
31
+ const json = safeJsonStringify({ name: 'BrowserTrack', active: true });
32
+ expect(JSON.parse(json)).toEqual({ name: 'BrowserTrack', active: true });
33
+ });
34
+
35
+ it('should serialize circular structures without throwing', () => {
36
+ const circular: any = { name: 'circular-node' };
37
+ circular.self = circular;
38
+ circular.child = { parent: circular };
39
+
40
+ let json = '';
41
+ expect(() => {
42
+ json = safeJsonStringify(circular);
43
+ }).not.toThrow();
44
+
45
+ expect(json).toContain('[Circular]');
46
+ const parsed = JSON.parse(json);
47
+ expect(parsed.name).toBe('circular-node');
48
+ expect(parsed.self).toBe('[Circular]');
49
+ expect(parsed.child.parent).toBe('[Circular]');
50
+ });
51
+
52
+ it('should serialize BigInt without throwing', () => {
53
+ const obj = { id: BigInt(9007199254740991) };
54
+ expect(() => safeJsonStringify(obj)).not.toThrow();
55
+ expect(safeJsonStringify(obj)).toContain('9007199254740991');
56
+ });
57
+
58
+ it('should handle undefined gracefully', () => {
59
+ expect(safeJsonStringify(undefined, 'empty')).toBe('empty');
60
+ });
61
+ });
62
+
63
+ describe('safeExecute', () => {
64
+ it('should return function result on success', () => {
65
+ const res = safeExecute(() => 10 + 20, 0);
66
+ expect(res).toBe(30);
67
+ });
68
+
69
+ it('should return fallback on error and notify onError callback', () => {
70
+ const errorHandler = vi.fn();
71
+ const res = safeExecute(
72
+ () => {
73
+ throw new Error('Test crash');
74
+ },
75
+ -1,
76
+ errorHandler
77
+ );
78
+
79
+ expect(res).toBe(-1);
80
+ expect(errorHandler).toHaveBeenCalledTimes(1);
81
+ expect(errorHandler.mock.calls[0][0].message).toBe('Test crash');
82
+ });
83
+ });
84
+
85
+ describe('safeAsync', () => {
86
+ it('should resolve async function correctly', async () => {
87
+ const res = await safeAsync(async () => 'resolved_val', 'fallback');
88
+ expect(res).toBe('resolved_val');
89
+ });
90
+
91
+ it('should catch rejection without throwing and return fallback', async () => {
92
+ const errorHandler = vi.fn();
93
+ const res = await safeAsync(
94
+ async () => {
95
+ throw new Error('Async boom');
96
+ },
97
+ 'fallback',
98
+ errorHandler
99
+ );
100
+
101
+ expect(res).toBe('fallback');
102
+ expect(errorHandler).toHaveBeenCalledTimes(1);
103
+ expect(errorHandler.mock.calls[0][0].message).toBe('Async boom');
104
+ });
105
+ });
106
+ });
@@ -120,4 +120,40 @@ describe('StorageDB (SQLite)', () => {
120
120
  const updated = db.getIncident('inc_1');
121
121
  expect(updated?.occurrences).toBe(2);
122
122
  });
123
+
124
+ it('should seamlessly migrate a legacy database without scenario columns', () => {
125
+ const legacyDbPath = path.join(os.tmpdir(), `bt_legacy_${Date.now()}.db`);
126
+ try {
127
+ // Create legacy database with old notes schema lacking scenario_id
128
+ const Database = (db as any).db.constructor;
129
+ const legacyRaw = new Database(legacyDbPath);
130
+ legacyRaw.exec(`
131
+ CREATE TABLE notes (
132
+ id TEXT PRIMARY KEY,
133
+ project_id TEXT,
134
+ session_id TEXT,
135
+ type TEXT,
136
+ message TEXT,
137
+ route TEXT,
138
+ url TEXT,
139
+ status TEXT DEFAULT 'OPEN',
140
+ created_at TEXT,
141
+ updated_at TEXT
142
+ );
143
+ `);
144
+ legacyRaw.close();
145
+
146
+ // Opening with StorageDB should not throw "no such column: scenario_id"
147
+ const migratedDb = new StorageDB(legacyDbPath);
148
+ const cols = (migratedDb as any).db.prepare('PRAGMA table_info(notes)').all().map((c: any) => c.name);
149
+ expect(cols).toContain('scenario_id');
150
+ expect(cols).toContain('step_number');
151
+ expect(cols).toContain('scenario_title');
152
+ migratedDb.close();
153
+ } finally {
154
+ if (fs.existsSync(legacyDbPath)) {
155
+ fs.unlinkSync(legacyDbPath);
156
+ }
157
+ }
158
+ });
123
159
  });
@@ -149,5 +149,15 @@ describe('BrowserTrack Full E2E Lifecycle (Daemon + WebSocket Client + MCP)', ()
149
149
 
150
150
  expect(pageState.url).toBe('http://localhost:5173/products/123');
151
151
  expect(pageState.title).toBe('Product Details');
152
+
153
+ // 6. Test Multi-process Standalone MCP Tool Call (no in-process session manager, bridges over HTTP to daemon)
154
+ const remotePageState = await handleToolCall(
155
+ 'get_page_state',
156
+ { sessionId: receivedSessionId },
157
+ { db: daemon.db, daemonUrl: `http://127.0.0.1:${testPort}` }
158
+ );
159
+
160
+ expect(remotePageState.url).toBe('http://localhost:5173/products/123');
161
+ expect(remotePageState.title).toBe('Product Details');
152
162
  });
153
163
  });