browsertrack 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/docs.yml +54 -0
- package/AGENTS.md +133 -0
- package/README.md +143 -0
- package/dist/chunk-6VA7GBAO.js +246 -0
- package/dist/chunk-6VA7GBAO.js.map +1 -0
- package/dist/chunk-G2Y3CXCY.js +659 -0
- package/dist/chunk-G2Y3CXCY.js.map +1 -0
- package/dist/chunk-ILRYKMME.js +1228 -0
- package/dist/chunk-ILRYKMME.js.map +1 -0
- package/dist/chunk-SKCMT2DE.js +2641 -0
- package/dist/chunk-SKCMT2DE.js.map +1 -0
- package/dist/chunk-SPCIROIU.js +616 -0
- package/dist/chunk-SPCIROIU.js.map +1 -0
- package/dist/cli/index.js +2942 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/client/index.cjs +2780 -0
- package/dist/client/index.d.ts +211 -0
- package/dist/client/index.js +20 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client.iife.js +630 -0
- package/dist/core/index.d.ts +86 -0
- package/dist/core/index.js +37 -0
- package/dist/core/index.js.map +1 -0
- package/dist/daemon/index.d.ts +58 -0
- package/dist/daemon/index.js +32 -0
- package/dist/daemon/index.js.map +1 -0
- package/dist/engine-CeT9URuN.d.ts +161 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +56 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/index.d.ts +11 -0
- package/dist/mcp/index.js +12 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/notes-CBvN91Wf.d.ts +260 -0
- package/dist/projects-CY8ungMt.d.ts +99 -0
- package/dist/server-Dd8NX2Mk.d.ts +55 -0
- package/docboot.config.js +16 -0
- package/docs/cli.md +51 -0
- package/docs/closed-loop-verification.md +57 -0
- package/docs/getting-started.md +122 -0
- package/docs/incidents-diagnostics.md +61 -0
- package/docs/index.md +46 -0
- package/docs/mcp-reference.md +128 -0
- package/docs/security-privacy.md +32 -0
- package/docs/visual-notes.md +67 -0
- package/examples/test-app/index.html +299 -0
- package/package.json +56 -7
- package/packages/cli/src/index.ts +413 -0
- package/packages/client/package.json +28 -0
- package/packages/client/src/breadcrumbs.ts +30 -0
- package/packages/client/src/client.ts +278 -0
- package/packages/client/src/commands/handler.ts +301 -0
- package/packages/client/src/config.ts +37 -0
- package/packages/client/src/index.ts +50 -0
- package/packages/client/src/interceptors/console.ts +68 -0
- package/packages/client/src/interceptors/interaction.ts +119 -0
- package/packages/client/src/interceptors/navigation.ts +93 -0
- package/packages/client/src/interceptors/network.ts +165 -0
- package/packages/client/src/interceptors/runtime.ts +65 -0
- package/packages/client/src/notes/inspector.ts +1553 -0
- package/packages/client/src/screenshot/browser-script-driver.ts +192 -0
- package/packages/client/src/screenshot/driver.ts +14 -0
- package/packages/client/src/transport/websocket.ts +194 -0
- package/packages/client/tsconfig.json +8 -0
- package/packages/core/package.json +26 -0
- package/packages/core/src/fingerprint.ts +120 -0
- package/packages/core/src/index.ts +9 -0
- package/packages/core/src/redaction.ts +174 -0
- package/packages/core/src/selector.ts +77 -0
- package/packages/core/src/types/commands.ts +101 -0
- package/packages/core/src/types/events.ts +108 -0
- package/packages/core/src/types/incidents.ts +54 -0
- package/packages/core/src/types/notes.ts +106 -0
- package/packages/core/src/types/probes.ts +44 -0
- package/packages/core/src/types/projects.ts +20 -0
- package/packages/core/tsconfig.json +8 -0
- package/packages/daemon/src/config.ts +29 -0
- package/packages/daemon/src/incidents/engine.ts +211 -0
- package/packages/daemon/src/index.ts +18 -0
- package/packages/daemon/src/notes/engine.ts +92 -0
- package/packages/daemon/src/notes/verification.ts +191 -0
- package/packages/daemon/src/server/daemon.ts +112 -0
- package/packages/daemon/src/server/http.ts +165 -0
- package/packages/daemon/src/server/ws.ts +246 -0
- package/packages/daemon/src/session/manager.ts +147 -0
- package/packages/daemon/src/storage/db.ts +742 -0
- package/packages/daemon/src/storage/screenshot-store.ts +49 -0
- package/packages/daemon/src/verification/engine.ts +264 -0
- package/packages/mcp/src/handlers.ts +398 -0
- package/packages/mcp/src/index.ts +3 -0
- package/packages/mcp/src/server.ts +86 -0
- package/packages/mcp/src/tools.ts +236 -0
- package/src/index.ts +4 -0
- package/test/client/interceptors.test.ts +130 -0
- package/test/core/fingerprint.test.ts +53 -0
- package/test/core/notes.test.ts +66 -0
- package/test/core/redaction.test.ts +60 -0
- package/test/daemon/incident-engine.test.ts +98 -0
- package/test/daemon/notes-storage.test.ts +130 -0
- package/test/daemon/notes-verification.test.ts +135 -0
- package/test/daemon/storage.test.ts +123 -0
- package/test/daemon/verification-engine.test.ts +88 -0
- package/test/e2e/daemon-mcp-e2e.test.ts +153 -0
- package/test/e2e/visual-notes-e2e.test.ts +205 -0
- package/test/mcp/handlers.test.ts +116 -0
- package/test/mcp/notes.test.ts +107 -0
- package/tsconfig.base.json +17 -0
- package/tsconfig.json +26 -0
- package/tsup.config.ts +54 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export interface DaemonConfig {
|
|
5
|
+
host: string;
|
|
6
|
+
port: number;
|
|
7
|
+
dataDir: string;
|
|
8
|
+
dbPath: string;
|
|
9
|
+
screenshotsDir: string;
|
|
10
|
+
maxEventsPerSession: number;
|
|
11
|
+
verbose: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getDaemonConfig(overrides: Partial<DaemonConfig> = {}): DaemonConfig {
|
|
15
|
+
const homeDir = os.homedir();
|
|
16
|
+
const dataDir = overrides.dataDir || path.join(homeDir, '.browsertrack');
|
|
17
|
+
const dbPath = overrides.dbPath || path.join(dataDir, 'browsertrack.db');
|
|
18
|
+
const screenshotsDir = overrides.screenshotsDir || path.join(dataDir, 'projects');
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
host: overrides.host || '127.0.0.1',
|
|
22
|
+
port: overrides.port || 7331,
|
|
23
|
+
dataDir,
|
|
24
|
+
dbPath,
|
|
25
|
+
screenshotsDir,
|
|
26
|
+
maxEventsPerSession: overrides.maxEventsPerSession || 1000,
|
|
27
|
+
verbose: !!overrides.verbose,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import type {
|
|
3
|
+
ClientEventMessage,
|
|
4
|
+
ConsoleEvent,
|
|
5
|
+
Incident,
|
|
6
|
+
IncidentOccurrence,
|
|
7
|
+
IncidentSeverity,
|
|
8
|
+
NetworkEvent,
|
|
9
|
+
RuntimeErrorEvent,
|
|
10
|
+
} from '../../../core/src/index.js';
|
|
11
|
+
import { computeFingerprint, extractSourceFromStack, normalizeSourceFile, redactSensitiveData } from '../../../core/src/index.js';
|
|
12
|
+
import type { StorageDB } from '../storage/db.js';
|
|
13
|
+
import type { ScreenshotStore } from '../storage/screenshot-store.js';
|
|
14
|
+
|
|
15
|
+
export class IncidentEngine {
|
|
16
|
+
private db: StorageDB;
|
|
17
|
+
private screenshotStore: ScreenshotStore;
|
|
18
|
+
|
|
19
|
+
constructor(db: StorageDB, screenshotStore: ScreenshotStore) {
|
|
20
|
+
this.db = db;
|
|
21
|
+
this.screenshotStore = screenshotStore;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public processClientEvent(message: ClientEventMessage & { screenshot?: string }): Incident | null {
|
|
25
|
+
const session = this.db.getSession(message.sessionId);
|
|
26
|
+
const projectId = session?.projectId || 'default';
|
|
27
|
+
const sanitizedBreadcrumbs = (message.breadcrumbs || []).map((b) => redactSensitiveData(b));
|
|
28
|
+
const sanitizedLastElement = message.lastElement ? redactSensitiveData(message.lastElement) : undefined;
|
|
29
|
+
|
|
30
|
+
// Filter network failures from breadcrumbs
|
|
31
|
+
const networkFailures: NetworkEvent[] = sanitizedBreadcrumbs
|
|
32
|
+
.filter((b) => (b.type === 'fetch' || b.type === 'xhr') && b.level === 'error' && b.data)
|
|
33
|
+
.map((b) => ({
|
|
34
|
+
url: b.message.split(' ')[1] || '',
|
|
35
|
+
method: b.message.split(' ')[0] || 'GET',
|
|
36
|
+
status: b.data?.status,
|
|
37
|
+
durationMs: b.data?.durationMs || 0,
|
|
38
|
+
error: b.data?.error,
|
|
39
|
+
aborted: b.data?.aborted,
|
|
40
|
+
timestamp: b.timestamp,
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
if (message.eventType === 'runtime_error' || message.eventType === 'unhandled_rejection') {
|
|
44
|
+
const payload = message.payload as RuntimeErrorEvent;
|
|
45
|
+
return this.handleErrorEvent({
|
|
46
|
+
projectId,
|
|
47
|
+
sessionId: message.sessionId,
|
|
48
|
+
type: payload.errorType || 'runtime_exception',
|
|
49
|
+
severity: 'error',
|
|
50
|
+
message: payload.message || 'Unknown runtime error',
|
|
51
|
+
stack: payload.stack,
|
|
52
|
+
sourceFile: payload.filename,
|
|
53
|
+
line: payload.lineno,
|
|
54
|
+
column: payload.colno,
|
|
55
|
+
route: message.route || '/',
|
|
56
|
+
url: message.url,
|
|
57
|
+
breadcrumbs: sanitizedBreadcrumbs,
|
|
58
|
+
networkFailures,
|
|
59
|
+
lastElement: sanitizedLastElement,
|
|
60
|
+
screenshotDataUrl: message.screenshot,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (message.eventType === 'console') {
|
|
65
|
+
const payload = message.payload as ConsoleEvent;
|
|
66
|
+
if (payload.level === 'error') {
|
|
67
|
+
return this.handleErrorEvent({
|
|
68
|
+
projectId,
|
|
69
|
+
sessionId: message.sessionId,
|
|
70
|
+
type: 'console_error',
|
|
71
|
+
severity: 'error',
|
|
72
|
+
message: payload.message,
|
|
73
|
+
stack: payload.stack,
|
|
74
|
+
route: message.route || '/',
|
|
75
|
+
url: message.url,
|
|
76
|
+
breadcrumbs: sanitizedBreadcrumbs,
|
|
77
|
+
networkFailures,
|
|
78
|
+
lastElement: sanitizedLastElement,
|
|
79
|
+
screenshotDataUrl: message.screenshot,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private handleErrorEvent(input: {
|
|
88
|
+
projectId: string;
|
|
89
|
+
sessionId: string;
|
|
90
|
+
type: string;
|
|
91
|
+
severity: IncidentSeverity;
|
|
92
|
+
message: string;
|
|
93
|
+
stack?: string;
|
|
94
|
+
sourceFile?: string;
|
|
95
|
+
line?: number;
|
|
96
|
+
column?: number;
|
|
97
|
+
route: string;
|
|
98
|
+
url: string;
|
|
99
|
+
breadcrumbs: any[];
|
|
100
|
+
networkFailures: NetworkEvent[];
|
|
101
|
+
lastElement?: any;
|
|
102
|
+
screenshotDataUrl?: string;
|
|
103
|
+
}): Incident {
|
|
104
|
+
const extracted = (!input.sourceFile || !input.line) && input.stack ? extractSourceFromStack(input.stack) : null;
|
|
105
|
+
|
|
106
|
+
const rawSource = input.sourceFile || extracted?.file || 'unknown_source';
|
|
107
|
+
const sourceFile = normalizeSourceFile(rawSource);
|
|
108
|
+
const line = input.line || extracted?.line || 0;
|
|
109
|
+
const column = input.column || extracted?.column;
|
|
110
|
+
|
|
111
|
+
const fingerprint = computeFingerprint({
|
|
112
|
+
type: input.type,
|
|
113
|
+
message: input.message,
|
|
114
|
+
sourceFile,
|
|
115
|
+
line,
|
|
116
|
+
column,
|
|
117
|
+
stack: input.stack,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const now = new Date().toISOString();
|
|
121
|
+
const existing = this.db.findIncidentByFingerprint(fingerprint);
|
|
122
|
+
|
|
123
|
+
if (existing) {
|
|
124
|
+
const updatedOccurrences = existing.occurrences + 1;
|
|
125
|
+
this.db.updateIncidentOccurrence(existing.id, {
|
|
126
|
+
sessionId: input.sessionId,
|
|
127
|
+
lastSeen: now,
|
|
128
|
+
occurrences: updatedOccurrences,
|
|
129
|
+
route: input.route,
|
|
130
|
+
breadcrumbs: input.breadcrumbs,
|
|
131
|
+
lastElement: input.lastElement,
|
|
132
|
+
stack: input.stack,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Insert occurrence
|
|
136
|
+
const occurrenceId = `occ_${crypto.randomUUID().slice(0, 8)}`;
|
|
137
|
+
this.db.insertIncidentOccurrence({
|
|
138
|
+
id: occurrenceId,
|
|
139
|
+
incidentId: existing.id,
|
|
140
|
+
sessionId: input.sessionId,
|
|
141
|
+
timestamp: now,
|
|
142
|
+
route: input.route,
|
|
143
|
+
url: input.url,
|
|
144
|
+
stack: input.stack,
|
|
145
|
+
breadcrumbs: input.breadcrumbs,
|
|
146
|
+
lastElement: input.lastElement,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
...existing,
|
|
151
|
+
occurrences: updatedOccurrences,
|
|
152
|
+
lastSeen: now,
|
|
153
|
+
breadcrumbs: input.breadcrumbs,
|
|
154
|
+
lastElement: input.lastElement || existing.lastElement,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// New incident
|
|
159
|
+
const incidentId = `inc_${crypto.randomUUID().slice(0, 8)}`;
|
|
160
|
+
let screenshotPath: string | undefined;
|
|
161
|
+
|
|
162
|
+
if (input.screenshotDataUrl) {
|
|
163
|
+
const saved = this.screenshotStore.saveScreenshot(input.projectId, incidentId, 'error', input.screenshotDataUrl);
|
|
164
|
+
if (saved) {
|
|
165
|
+
screenshotPath = saved.filePath;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const newIncident: Incident = {
|
|
170
|
+
id: incidentId,
|
|
171
|
+
projectId: input.projectId,
|
|
172
|
+
sessionId: input.sessionId,
|
|
173
|
+
type: input.type,
|
|
174
|
+
severity: input.severity,
|
|
175
|
+
message: input.message,
|
|
176
|
+
source: {
|
|
177
|
+
file: sourceFile,
|
|
178
|
+
line,
|
|
179
|
+
column,
|
|
180
|
+
},
|
|
181
|
+
fingerprint,
|
|
182
|
+
route: input.route,
|
|
183
|
+
firstSeen: now,
|
|
184
|
+
lastSeen: now,
|
|
185
|
+
occurrences: 1,
|
|
186
|
+
status: 'OPEN',
|
|
187
|
+
stack: input.stack,
|
|
188
|
+
breadcrumbs: input.breadcrumbs,
|
|
189
|
+
networkFailures: input.networkFailures,
|
|
190
|
+
lastElement: input.lastElement,
|
|
191
|
+
screenshots: screenshotPath ? { error: screenshotPath } : undefined,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
this.db.insertIncident(newIncident);
|
|
195
|
+
|
|
196
|
+
// Initial occurrence
|
|
197
|
+
this.db.insertIncidentOccurrence({
|
|
198
|
+
id: `occ_${crypto.randomUUID().slice(0, 8)}`,
|
|
199
|
+
incidentId,
|
|
200
|
+
sessionId: input.sessionId,
|
|
201
|
+
timestamp: now,
|
|
202
|
+
route: input.route,
|
|
203
|
+
url: input.url,
|
|
204
|
+
stack: input.stack,
|
|
205
|
+
breadcrumbs: input.breadcrumbs,
|
|
206
|
+
lastElement: input.lastElement,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
return newIncident;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export * from './config.js';
|
|
2
|
+
export * from './storage/db.js';
|
|
3
|
+
export * from './storage/screenshot-store.js';
|
|
4
|
+
export * from './session/manager.js';
|
|
5
|
+
export * from './incidents/engine.js';
|
|
6
|
+
export * from './notes/engine.js';
|
|
7
|
+
export * from './notes/verification.js';
|
|
8
|
+
export * from './verification/engine.js';
|
|
9
|
+
export * from './server/http.js';
|
|
10
|
+
export * from './server/ws.js';
|
|
11
|
+
export * from './server/daemon.js';
|
|
12
|
+
|
|
13
|
+
import { BrowserTrackDaemon } from './server/daemon.js';
|
|
14
|
+
import type { DaemonConfig } from './config.js';
|
|
15
|
+
|
|
16
|
+
export function createDaemon(config: Partial<DaemonConfig> = {}): BrowserTrackDaemon {
|
|
17
|
+
return new BrowserTrackDaemon(config);
|
|
18
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import type { VisualNote } from '../../../core/src/index.js';
|
|
5
|
+
import type { StorageDB } from '../storage/db.js';
|
|
6
|
+
import type { ScreenshotStore } from '../storage/screenshot-store.js';
|
|
7
|
+
|
|
8
|
+
export class NotesEngine {
|
|
9
|
+
private db: StorageDB;
|
|
10
|
+
private screenshotStore: ScreenshotStore;
|
|
11
|
+
|
|
12
|
+
constructor(db: StorageDB, screenshotStore: ScreenshotStore) {
|
|
13
|
+
this.db = db;
|
|
14
|
+
this.screenshotStore = screenshotStore;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
public createNoteFromClient(payload: {
|
|
18
|
+
sessionId: string;
|
|
19
|
+
noteType?: 'element' | 'region' | 'page';
|
|
20
|
+
message: string;
|
|
21
|
+
route?: string;
|
|
22
|
+
url?: string;
|
|
23
|
+
viewport?: { width: number; height: number; devicePixelRatio: number };
|
|
24
|
+
scroll?: { scrollX: number; scrollY: number };
|
|
25
|
+
target?: any;
|
|
26
|
+
elementContext?: any;
|
|
27
|
+
region?: any;
|
|
28
|
+
screenshot?: string;
|
|
29
|
+
incidentId?: string;
|
|
30
|
+
}): VisualNote {
|
|
31
|
+
const session = this.db.getSession(payload.sessionId);
|
|
32
|
+
const projectId = session?.projectId || 'default';
|
|
33
|
+
const noteId = `note_${crypto.randomUUID().slice(0, 8)}`;
|
|
34
|
+
const now = new Date().toISOString();
|
|
35
|
+
|
|
36
|
+
let screenshotPath: string | undefined;
|
|
37
|
+
if (payload.screenshot) {
|
|
38
|
+
const saved = this.saveNoteScreenshot(projectId, noteId, 'original', payload.screenshot);
|
|
39
|
+
if (saved) {
|
|
40
|
+
screenshotPath = saved;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const note: VisualNote = {
|
|
45
|
+
id: noteId,
|
|
46
|
+
projectId,
|
|
47
|
+
sessionId: payload.sessionId,
|
|
48
|
+
type: payload.noteType || 'element',
|
|
49
|
+
message: payload.message,
|
|
50
|
+
route: payload.route || '/',
|
|
51
|
+
url: payload.url || '',
|
|
52
|
+
viewport: payload.viewport || { width: 1280, height: 800, devicePixelRatio: 1 },
|
|
53
|
+
scroll: payload.scroll || { scrollX: 0, scrollY: 0 },
|
|
54
|
+
target: payload.target,
|
|
55
|
+
elementContext: payload.elementContext,
|
|
56
|
+
region: payload.region,
|
|
57
|
+
status: 'OPEN',
|
|
58
|
+
incidentId: payload.incidentId,
|
|
59
|
+
screenshots: screenshotPath ? { original: screenshotPath } : undefined,
|
|
60
|
+
createdAt: now,
|
|
61
|
+
updatedAt: now,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
this.db.insertNote(note);
|
|
65
|
+
return note;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
public saveNoteScreenshot(projectId: string, noteId: string, name: string, dataUrl: string): string | null {
|
|
69
|
+
try {
|
|
70
|
+
if (!dataUrl || !dataUrl.startsWith('data:image/')) return null;
|
|
71
|
+
|
|
72
|
+
const match = dataUrl.match(/^data:image\/([a-zA-Z0-9+]+);base64,(.+)$/);
|
|
73
|
+
if (!match) return null;
|
|
74
|
+
|
|
75
|
+
let format = match[1].toLowerCase();
|
|
76
|
+
if (format === 'jpeg') format = 'jpg';
|
|
77
|
+
const base64Data = match[2];
|
|
78
|
+
const buffer = Buffer.from(base64Data, 'base64');
|
|
79
|
+
|
|
80
|
+
const targetDir = path.join((this.screenshotStore as any).baseDir, projectId || 'default', 'notes', noteId);
|
|
81
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
82
|
+
|
|
83
|
+
const fileName = `${name}.${format}`;
|
|
84
|
+
const filePath = path.join(targetDir, fileName);
|
|
85
|
+
|
|
86
|
+
fs.writeFileSync(filePath, buffer);
|
|
87
|
+
return filePath;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import type { DOMRectJson, NoteStatus, NoteVerificationResult, OverflowCheckResult, VisualNote } from '../../../core/src/index.js';
|
|
3
|
+
import type { NotesEngine } from './engine.js';
|
|
4
|
+
import type { SessionManager } from '../session/manager.js';
|
|
5
|
+
import type { StorageDB } from '../storage/db.js';
|
|
6
|
+
|
|
7
|
+
export class NoteVerificationEngine {
|
|
8
|
+
private db: StorageDB;
|
|
9
|
+
private sessionManager: SessionManager;
|
|
10
|
+
private notesEngine: NotesEngine;
|
|
11
|
+
|
|
12
|
+
constructor(db: StorageDB, sessionManager: SessionManager, notesEngine: NotesEngine) {
|
|
13
|
+
this.db = db;
|
|
14
|
+
this.sessionManager = sessionManager;
|
|
15
|
+
this.notesEngine = notesEngine;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public async verifyNote(noteId: string, options: { observationWindowMs?: number } = {}): Promise<NoteVerificationResult> {
|
|
19
|
+
const note = this.db.getNote(noteId);
|
|
20
|
+
if (!note) {
|
|
21
|
+
return {
|
|
22
|
+
noteId,
|
|
23
|
+
status: 'FAILED',
|
|
24
|
+
checks: [{ type: 'note_exists', passed: false, details: `Note ${noteId} not found` }],
|
|
25
|
+
timestamp: new Date().toISOString(),
|
|
26
|
+
message: `Note ${noteId} not found in database`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 1. Find active session
|
|
31
|
+
let session = this.sessionManager.getActiveSessionForProject(note.projectId);
|
|
32
|
+
if (!session) {
|
|
33
|
+
session = this.sessionManager.getAnyActiveSession();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!session) {
|
|
37
|
+
const result: NoteVerificationResult = {
|
|
38
|
+
noteId,
|
|
39
|
+
status: 'INCONCLUSIVE',
|
|
40
|
+
checks: [],
|
|
41
|
+
timestamp: new Date().toISOString(),
|
|
42
|
+
message: 'No active browser session currently connected to verify visual note.',
|
|
43
|
+
};
|
|
44
|
+
this.recordNoteVerification(result, note);
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 2. Set status to VERIFYING
|
|
49
|
+
this.db.updateNoteStatus(noteId, 'VERIFYING');
|
|
50
|
+
|
|
51
|
+
// 3. Navigation check / route sync
|
|
52
|
+
const pageStateCmd = await this.sessionManager.sendCommand(session.id, {
|
|
53
|
+
id: `cmd_${crypto.randomUUID().slice(0, 8)}`,
|
|
54
|
+
type: 'get_page_state',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const currentRoute = pageStateCmd.result?.route || '/';
|
|
58
|
+
const isRouteMatch = currentRoute === note.route || currentRoute.startsWith(note.route);
|
|
59
|
+
|
|
60
|
+
if (!isRouteMatch && note.route) {
|
|
61
|
+
await this.sessionManager.sendCommand(session.id, {
|
|
62
|
+
id: `cmd_${crypto.randomUUID().slice(0, 8)}`,
|
|
63
|
+
type: 'navigate',
|
|
64
|
+
params: { url: note.route },
|
|
65
|
+
});
|
|
66
|
+
// Wait for navigation observation window
|
|
67
|
+
await new Promise((r) => setTimeout(r, options.observationWindowMs || 1000));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const checks: Array<{ type: string; passed: boolean; details?: string }> = [];
|
|
71
|
+
|
|
72
|
+
checks.push({
|
|
73
|
+
type: 'route_loaded',
|
|
74
|
+
passed: true,
|
|
75
|
+
details: `Route ${note.route || '/'} loaded`,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// 4. Target element checks
|
|
79
|
+
const targetSelector = note.target?.selector || (note.type === 'page' ? 'body' : 'body');
|
|
80
|
+
let elementExists = true;
|
|
81
|
+
let elementVisible = true;
|
|
82
|
+
let currentRect: DOMRectJson | undefined;
|
|
83
|
+
let overflowResult: OverflowCheckResult | undefined;
|
|
84
|
+
|
|
85
|
+
if (note.type === 'element' && targetSelector) {
|
|
86
|
+
const queryCmd = await this.sessionManager.sendCommand(session.id, {
|
|
87
|
+
id: `cmd_${crypto.randomUUID().slice(0, 8)}`,
|
|
88
|
+
type: 'query_element',
|
|
89
|
+
params: { selector: targetSelector },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
elementExists = queryCmd.ok && !!queryCmd.result?.exists;
|
|
93
|
+
elementVisible = queryCmd.ok && !!queryCmd.result?.visible;
|
|
94
|
+
currentRect = queryCmd.result?.boundingRect;
|
|
95
|
+
|
|
96
|
+
checks.push({
|
|
97
|
+
type: 'element_exists',
|
|
98
|
+
passed: elementExists,
|
|
99
|
+
details: elementExists ? `Element ${targetSelector} exists in DOM` : `Element ${targetSelector} not found`,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
checks.push({
|
|
103
|
+
type: 'element_visible',
|
|
104
|
+
passed: elementVisible,
|
|
105
|
+
details: elementVisible ? `Element ${targetSelector} is visible` : `Element ${targetSelector} is hidden`,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// 5. Layout Overflow Probe
|
|
109
|
+
const overflowCmd = await this.sessionManager.sendCommand(session.id, {
|
|
110
|
+
id: `cmd_${crypto.randomUUID().slice(0, 8)}`,
|
|
111
|
+
type: 'check_overflow',
|
|
112
|
+
params: { selector: targetSelector },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (overflowCmd.ok && overflowCmd.result) {
|
|
116
|
+
const ovf = overflowCmd.result as OverflowCheckResult;
|
|
117
|
+
overflowResult = ovf;
|
|
118
|
+
const isOverflowingNow = !!ovf.overflow;
|
|
119
|
+
|
|
120
|
+
checks.push({
|
|
121
|
+
type: 'no_viewport_overflow',
|
|
122
|
+
passed: !isOverflowingNow,
|
|
123
|
+
details: !isOverflowingNow
|
|
124
|
+
? `Element fits within viewport (width: ${ovf.viewportWidth}px, rect right: ${ovf.rect?.right}px)`
|
|
125
|
+
: `Element overflows viewport by ${ovf.overflowRightPx}px (rect right: ${ovf.rect?.right}px, viewport: ${ovf.viewportWidth}px)`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 6. Capture "after" screenshot
|
|
131
|
+
let afterScreenshotPath: string | undefined;
|
|
132
|
+
const captureCmd = await this.sessionManager.sendCommand(session.id, {
|
|
133
|
+
id: `cmd_${crypto.randomUUID().slice(0, 8)}`,
|
|
134
|
+
type: 'capture_element',
|
|
135
|
+
params: { selector: targetSelector },
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (captureCmd.ok && captureCmd.result?.dataUrl) {
|
|
139
|
+
const saved = this.notesEngine.saveNoteScreenshot(note.projectId, note.id, 'after', captureCmd.result.dataUrl);
|
|
140
|
+
if (saved) {
|
|
141
|
+
afterScreenshotPath = saved;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 7. Calculate Geometry Difference
|
|
146
|
+
const beforeRect = note.target?.boundingRect;
|
|
147
|
+
const overflowFixed = overflowResult ? !overflowResult.overflow : undefined;
|
|
148
|
+
|
|
149
|
+
const geometryDiff = {
|
|
150
|
+
before: beforeRect,
|
|
151
|
+
current: currentRect || (overflowResult?.rect as DOMRectJson),
|
|
152
|
+
viewportWidth: overflowResult?.viewportWidth || note.viewport.width,
|
|
153
|
+
overflowFixed,
|
|
154
|
+
overflowPx: overflowResult?.overflowRightPx || 0,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// 8. Verdict
|
|
158
|
+
const anyFailed = checks.some((c) => !c.passed);
|
|
159
|
+
let status: NoteStatus = 'VERIFIED';
|
|
160
|
+
|
|
161
|
+
if (anyFailed) {
|
|
162
|
+
status = 'FAILED';
|
|
163
|
+
} else {
|
|
164
|
+
status = 'VERIFIED';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const verificationResult: NoteVerificationResult = {
|
|
168
|
+
noteId,
|
|
169
|
+
status,
|
|
170
|
+
checks,
|
|
171
|
+
geometryDiff,
|
|
172
|
+
screenshots: {
|
|
173
|
+
before: note.screenshots?.original,
|
|
174
|
+
after: afterScreenshotPath,
|
|
175
|
+
},
|
|
176
|
+
timestamp: new Date().toISOString(),
|
|
177
|
+
message:
|
|
178
|
+
status === 'VERIFIED'
|
|
179
|
+
? 'Visual note verified: target element is present, visible, and layout checks (including viewport overflow) passed.'
|
|
180
|
+
: 'Visual note verification failed: target element missing, hidden, or still overflowing.',
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
this.recordNoteVerification(verificationResult, note);
|
|
184
|
+
return verificationResult;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private recordNoteVerification(result: NoteVerificationResult, note: VisualNote): void {
|
|
188
|
+
this.db.updateNoteStatus(note.id, result.status);
|
|
189
|
+
this.db.insertNoteVerification(result);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { WebSocketServer } from 'ws';
|
|
3
|
+
import type { DaemonConfig } from '../config.js';
|
|
4
|
+
import { getDaemonConfig } from '../config.js';
|
|
5
|
+
import { IncidentEngine } from '../incidents/engine.js';
|
|
6
|
+
import { NotesEngine } from '../notes/engine.js';
|
|
7
|
+
import { NoteVerificationEngine } from '../notes/verification.js';
|
|
8
|
+
import { SessionManager } from '../session/manager.js';
|
|
9
|
+
import { StorageDB } from '../storage/db.js';
|
|
10
|
+
import { ScreenshotStore } from '../storage/screenshot-store.js';
|
|
11
|
+
import { VerificationEngine } from '../verification/engine.js';
|
|
12
|
+
import { createHttpHandler } from './http.js';
|
|
13
|
+
import { setupWebSocketServer } from './ws.js';
|
|
14
|
+
|
|
15
|
+
export class BrowserTrackDaemon {
|
|
16
|
+
public config: DaemonConfig;
|
|
17
|
+
public db: StorageDB;
|
|
18
|
+
public screenshotStore: ScreenshotStore;
|
|
19
|
+
public sessionManager: SessionManager;
|
|
20
|
+
public incidentEngine: IncidentEngine;
|
|
21
|
+
public notesEngine: NotesEngine;
|
|
22
|
+
public verificationEngine: VerificationEngine;
|
|
23
|
+
public noteVerificationEngine: NoteVerificationEngine;
|
|
24
|
+
|
|
25
|
+
private httpServer: http.Server | null = null;
|
|
26
|
+
private wss: WebSocketServer | null = null;
|
|
27
|
+
private isRunning = false;
|
|
28
|
+
|
|
29
|
+
constructor(config: Partial<DaemonConfig> = {}) {
|
|
30
|
+
this.config = getDaemonConfig(config);
|
|
31
|
+
this.db = new StorageDB(this.config.dbPath);
|
|
32
|
+
this.screenshotStore = new ScreenshotStore(this.config.screenshotsDir);
|
|
33
|
+
this.sessionManager = new SessionManager(this.db);
|
|
34
|
+
this.incidentEngine = new IncidentEngine(this.db, this.screenshotStore);
|
|
35
|
+
this.notesEngine = new NotesEngine(this.db, this.screenshotStore);
|
|
36
|
+
this.verificationEngine = new VerificationEngine(this.db, this.sessionManager, this.screenshotStore);
|
|
37
|
+
this.noteVerificationEngine = new NoteVerificationEngine(this.db, this.sessionManager, this.notesEngine);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public async start(): Promise<void> {
|
|
41
|
+
if (this.isRunning) return;
|
|
42
|
+
|
|
43
|
+
const httpHandler = createHttpHandler(this.db, this.sessionManager, this.config.screenshotsDir);
|
|
44
|
+
this.httpServer = http.createServer(httpHandler);
|
|
45
|
+
|
|
46
|
+
this.wss = new WebSocketServer({ server: this.httpServer });
|
|
47
|
+
setupWebSocketServer(
|
|
48
|
+
this.wss,
|
|
49
|
+
this.db,
|
|
50
|
+
this.sessionManager,
|
|
51
|
+
this.incidentEngine,
|
|
52
|
+
this.notesEngine,
|
|
53
|
+
this.config.maxEventsPerSession,
|
|
54
|
+
this.config.verbose
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
await new Promise<void>((resolve, reject) => {
|
|
58
|
+
this.httpServer!.listen(this.config.port, this.config.host, () => {
|
|
59
|
+
this.isRunning = true;
|
|
60
|
+
if (this.config.verbose) {
|
|
61
|
+
console.log(`[BrowserTrack] Daemon running at http://${this.config.host}:${this.config.port}`);
|
|
62
|
+
}
|
|
63
|
+
resolve();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
this.httpServer!.once('error', (err) => {
|
|
67
|
+
reject(err);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
public async stop(): Promise<void> {
|
|
73
|
+
if (!this.isRunning) return;
|
|
74
|
+
|
|
75
|
+
if (this.wss) {
|
|
76
|
+
for (const client of this.wss.clients) {
|
|
77
|
+
try {
|
|
78
|
+
client.terminate();
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
await new Promise<void>((resolve) => {
|
|
82
|
+
this.wss!.close(() => resolve());
|
|
83
|
+
});
|
|
84
|
+
this.wss = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (this.httpServer) {
|
|
88
|
+
if (typeof (this.httpServer as any).closeAllConnections === 'function') {
|
|
89
|
+
(this.httpServer as any).closeAllConnections();
|
|
90
|
+
}
|
|
91
|
+
await new Promise<void>((resolve) => {
|
|
92
|
+
this.httpServer!.close(() => resolve());
|
|
93
|
+
});
|
|
94
|
+
this.httpServer = null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
this.db.close();
|
|
99
|
+
} catch {}
|
|
100
|
+
this.isRunning = false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
public getStatus(): { isRunning: boolean; host: string; port: number; activeSessions: number; dbPath: string } {
|
|
104
|
+
return {
|
|
105
|
+
isRunning: this.isRunning,
|
|
106
|
+
host: this.config.host,
|
|
107
|
+
port: this.config.port,
|
|
108
|
+
activeSessions: this.sessionManager.getActiveCount(),
|
|
109
|
+
dbPath: this.config.dbPath,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|