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.
- package/AGENTS.md +11 -6
- package/README.md +2 -0
- package/dist/{chunk-WB7ZKWK7.js → chunk-4HRLW6YF.js} +509 -109
- package/dist/chunk-4HRLW6YF.js.map +1 -0
- package/dist/{chunk-3HOXPTM2.js → chunk-AYSVE6NG.js} +808 -53
- package/dist/chunk-AYSVE6NG.js.map +1 -0
- package/dist/{chunk-6VA7GBAO.js → chunk-QRZ57ME3.js} +70 -2
- package/dist/chunk-QRZ57ME3.js.map +1 -0
- package/dist/{chunk-UP5JKCFY.js → chunk-TWEYRBDU.js} +281 -44
- package/dist/chunk-TWEYRBDU.js.map +1 -0
- package/dist/cli/index.js +1040 -546
- package/dist/cli/index.js.map +1 -1
- package/dist/client/index.cjs +536 -109
- package/dist/client/index.d.ts +36 -4
- package/dist/client/index.js +8 -4
- package/dist/client.iife.js +42 -19
- package/dist/{notes-BMnonq46.d.ts → commands-fjuqKzkm.d.ts} +125 -114
- package/dist/core/index.d.ts +26 -3
- package/dist/core/index.js +9 -1
- package/dist/daemon/index.d.ts +4 -4
- package/dist/daemon/index.js +6 -8
- package/dist/{engine-B43IohQY.d.ts → engine-CmchnMDq.d.ts} +2 -2
- package/dist/index.d.ts +5 -5
- package/dist/index.js +14 -7
- package/dist/mcp/index.d.ts +4 -4
- package/dist/mcp/index.js +7 -4
- package/dist/{projects-D5J-egVN.d.ts → projects-DB7S312i.d.ts} +1 -1
- package/dist/{server-BztYp1Zc.d.ts → server-DjV7RWQM.d.ts} +10 -2
- package/docs/cli.md +4 -1
- package/docs/component-resolver.md +108 -0
- package/docs/getting-started.md +60 -6
- package/docs/index.md +1 -0
- package/docs/mcp-reference.md +44 -2
- package/docs/visual-notes.md +36 -0
- package/package.json +1 -1
- package/packages/cli/src/index.ts +247 -151
- 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/interceptors/navigation.ts +38 -26
- package/packages/client/src/interceptors/network.ts +22 -17
- package/packages/client/src/notes/inspector.ts +186 -51
- package/packages/client/src/source/resolver.ts +278 -0
- package/packages/client/src/transport/websocket.ts +23 -18
- package/packages/core/src/index.ts +1 -0
- package/packages/core/src/safety.ts +86 -0
- package/packages/core/src/types/events.ts +3 -0
- package/packages/core/src/types/notes.ts +11 -0
- package/packages/daemon/src/server/daemon.ts +7 -1
- package/packages/daemon/src/server/http.ts +125 -5
- package/packages/daemon/src/server/ws.ts +33 -29
- package/packages/daemon/src/storage/db.ts +57 -35
- package/packages/mcp/src/handlers.ts +115 -45
- package/packages/mcp/src/server.ts +202 -2
- package/test/client/component-resolver.test.ts +141 -0
- package/test/client/interceptors.test.ts +56 -0
- package/test/core/safety.test.ts +106 -0
- package/test/daemon/storage.test.ts +36 -0
- package/test/e2e/daemon-mcp-e2e.test.ts +10 -0
- package/test/mcp/auto-start.test.ts +87 -0
- package/dist/chunk-3HOXPTM2.js.map +0 -1
- package/dist/chunk-6VA7GBAO.js.map +0 -1
- package/dist/chunk-7OCOQGDN.js +0 -635
- package/dist/chunk-7OCOQGDN.js.map +0 -1
- package/dist/chunk-UP5JKCFY.js.map +0 -1
- 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/
|
|
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({
|
|
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
|
});
|