browsertrack 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -0
- package/dist/chunk-6A7FFDIB.js +221 -0
- package/dist/chunk-6A7FFDIB.js.map +1 -0
- package/dist/chunk-ANMR5WBH.js +659 -0
- package/dist/chunk-ANMR5WBH.js.map +1 -0
- package/dist/chunk-BSKNG4V7.js +1199 -0
- package/dist/chunk-BSKNG4V7.js.map +1 -0
- package/dist/chunk-X7C5CHBO.js +2080 -0
- package/dist/chunk-X7C5CHBO.js.map +1 -0
- package/dist/chunk-ZLNGOOH2.js +554 -0
- package/dist/chunk-ZLNGOOH2.js.map +1 -0
- package/dist/cli/index.js +2829 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/client/index.cjs +2218 -0
- package/dist/client/index.d.ts +197 -0
- package/dist/client/index.js +20 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client.iife.js +309 -0
- package/dist/core/index.d.ts +78 -0
- package/dist/core/index.js +31 -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-DKloFjvs.d.ts +157 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +50 -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-CN-se8td.d.ts +55 -0
- package/examples/test-app/index.html +299 -0
- package/package.json +52 -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 +943 -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 +179 -0
- package/packages/client/tsconfig.json +8 -0
- package/packages/core/dist/index.d.ts +317 -0
- package/packages/core/dist/index.js +221 -0
- package/packages/core/package.json +23 -0
- package/packages/core/src/fingerprint.ts +120 -0
- package/packages/core/src/index.ts +9 -0
- package/packages/core/src/redaction.ts +139 -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 +175 -0
- package/packages/daemon/src/session/manager.ts +126 -0
- package/packages/daemon/src/storage/db.ts +733 -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 +69 -0
- package/test/core/fingerprint.test.ts +53 -0
- package/test/core/notes.test.ts +66 -0
- package/test/core/redaction.test.ts +48 -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,1199 @@
|
|
|
1
|
+
// packages/daemon/src/config.ts
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
function getDaemonConfig(overrides = {}) {
|
|
5
|
+
const homeDir = os.homedir();
|
|
6
|
+
const dataDir = overrides.dataDir || path.join(homeDir, ".browsertrack");
|
|
7
|
+
const dbPath = overrides.dbPath || path.join(dataDir, "browsertrack.db");
|
|
8
|
+
const screenshotsDir = overrides.screenshotsDir || path.join(dataDir, "projects");
|
|
9
|
+
return {
|
|
10
|
+
host: overrides.host || "127.0.0.1",
|
|
11
|
+
port: overrides.port || 7331,
|
|
12
|
+
dataDir,
|
|
13
|
+
dbPath,
|
|
14
|
+
screenshotsDir,
|
|
15
|
+
maxEventsPerSession: overrides.maxEventsPerSession || 1e3,
|
|
16
|
+
verbose: !!overrides.verbose
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// packages/daemon/src/storage/db.ts
|
|
21
|
+
import Database from "better-sqlite3";
|
|
22
|
+
import fs from "fs";
|
|
23
|
+
import path2 from "path";
|
|
24
|
+
var StorageDB = class {
|
|
25
|
+
db;
|
|
26
|
+
constructor(dbPath) {
|
|
27
|
+
const dir = path2.dirname(dbPath);
|
|
28
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
29
|
+
this.db = new Database(dbPath);
|
|
30
|
+
this.db.pragma("journal_mode = WAL");
|
|
31
|
+
this.db.pragma("synchronous = NORMAL");
|
|
32
|
+
this.initTables();
|
|
33
|
+
}
|
|
34
|
+
initTables() {
|
|
35
|
+
this.db.exec(`
|
|
36
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
37
|
+
id TEXT PRIMARY KEY,
|
|
38
|
+
name TEXT UNIQUE,
|
|
39
|
+
origin TEXT,
|
|
40
|
+
path TEXT,
|
|
41
|
+
created_at TEXT,
|
|
42
|
+
updated_at TEXT
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
46
|
+
id TEXT PRIMARY KEY,
|
|
47
|
+
project_id TEXT,
|
|
48
|
+
origin TEXT,
|
|
49
|
+
url TEXT,
|
|
50
|
+
title TEXT,
|
|
51
|
+
user_agent TEXT,
|
|
52
|
+
connected_at TEXT,
|
|
53
|
+
last_seen_at TEXT,
|
|
54
|
+
active INTEGER DEFAULT 1
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
58
|
+
id TEXT PRIMARY KEY,
|
|
59
|
+
session_id TEXT,
|
|
60
|
+
event_type TEXT,
|
|
61
|
+
payload TEXT,
|
|
62
|
+
timestamp INTEGER,
|
|
63
|
+
route TEXT,
|
|
64
|
+
url TEXT
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
CREATE TABLE IF NOT EXISTS incidents (
|
|
68
|
+
id TEXT PRIMARY KEY,
|
|
69
|
+
project_id TEXT,
|
|
70
|
+
session_id TEXT,
|
|
71
|
+
type TEXT,
|
|
72
|
+
severity TEXT,
|
|
73
|
+
message TEXT,
|
|
74
|
+
source_file TEXT,
|
|
75
|
+
source_line INTEGER,
|
|
76
|
+
source_col INTEGER,
|
|
77
|
+
fingerprint TEXT UNIQUE,
|
|
78
|
+
route TEXT,
|
|
79
|
+
first_seen TEXT,
|
|
80
|
+
last_seen TEXT,
|
|
81
|
+
occurrences INTEGER DEFAULT 1,
|
|
82
|
+
status TEXT DEFAULT 'OPEN',
|
|
83
|
+
stack TEXT,
|
|
84
|
+
breadcrumbs TEXT,
|
|
85
|
+
network_failures TEXT,
|
|
86
|
+
last_element TEXT,
|
|
87
|
+
screenshot_path TEXT
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
CREATE TABLE IF NOT EXISTS incident_occurrences (
|
|
91
|
+
id TEXT PRIMARY KEY,
|
|
92
|
+
incident_id TEXT,
|
|
93
|
+
session_id TEXT,
|
|
94
|
+
timestamp TEXT,
|
|
95
|
+
route TEXT,
|
|
96
|
+
url TEXT,
|
|
97
|
+
stack TEXT,
|
|
98
|
+
breadcrumbs TEXT,
|
|
99
|
+
last_element TEXT
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
CREATE TABLE IF NOT EXISTS screenshots (
|
|
103
|
+
id TEXT PRIMARY KEY,
|
|
104
|
+
incident_id TEXT,
|
|
105
|
+
file_path TEXT,
|
|
106
|
+
format TEXT,
|
|
107
|
+
width INTEGER,
|
|
108
|
+
height INTEGER,
|
|
109
|
+
created_at TEXT
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
CREATE TABLE IF NOT EXISTS verifications (
|
|
113
|
+
id TEXT PRIMARY KEY,
|
|
114
|
+
incident_id TEXT,
|
|
115
|
+
status TEXT,
|
|
116
|
+
checks TEXT,
|
|
117
|
+
before_screenshot TEXT,
|
|
118
|
+
after_screenshot TEXT,
|
|
119
|
+
message TEXT,
|
|
120
|
+
created_at TEXT
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
124
|
+
id TEXT PRIMARY KEY,
|
|
125
|
+
project_id TEXT,
|
|
126
|
+
session_id TEXT,
|
|
127
|
+
type TEXT,
|
|
128
|
+
message TEXT,
|
|
129
|
+
route TEXT,
|
|
130
|
+
url TEXT,
|
|
131
|
+
viewport_json TEXT,
|
|
132
|
+
scroll_json TEXT,
|
|
133
|
+
target_json TEXT,
|
|
134
|
+
element_context_json TEXT,
|
|
135
|
+
region_json TEXT,
|
|
136
|
+
screenshot_path TEXT,
|
|
137
|
+
incident_id TEXT,
|
|
138
|
+
status TEXT DEFAULT 'OPEN',
|
|
139
|
+
created_at TEXT,
|
|
140
|
+
updated_at TEXT,
|
|
141
|
+
resolved_at TEXT
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
CREATE TABLE IF NOT EXISTS note_verifications (
|
|
145
|
+
id TEXT PRIMARY KEY,
|
|
146
|
+
note_id TEXT,
|
|
147
|
+
status TEXT,
|
|
148
|
+
checks TEXT,
|
|
149
|
+
geometry_diff TEXT,
|
|
150
|
+
before_screenshot TEXT,
|
|
151
|
+
after_screenshot TEXT,
|
|
152
|
+
message TEXT,
|
|
153
|
+
created_at TEXT
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id, timestamp);
|
|
157
|
+
CREATE INDEX IF NOT EXISTS idx_incidents_project ON incidents(project_id, status);
|
|
158
|
+
CREATE INDEX IF NOT EXISTS idx_incidents_fp ON incidents(fingerprint);
|
|
159
|
+
CREATE INDEX IF NOT EXISTS idx_notes_project ON notes(project_id, status);
|
|
160
|
+
`);
|
|
161
|
+
}
|
|
162
|
+
// --- PROJECTS ---
|
|
163
|
+
upsertProject(project) {
|
|
164
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
165
|
+
const existing = this.db.prepare("SELECT * FROM projects WHERE name = ? OR origin = ?").get(project.name, project.origin);
|
|
166
|
+
if (existing) {
|
|
167
|
+
this.db.prepare("UPDATE projects SET name = ?, origin = ?, path = COALESCE(?, path), updated_at = ? WHERE id = ?").run(project.name, project.origin, project.path || null, now, existing.id);
|
|
168
|
+
return {
|
|
169
|
+
id: existing.id,
|
|
170
|
+
name: project.name,
|
|
171
|
+
origin: project.origin,
|
|
172
|
+
path: project.path || existing.path,
|
|
173
|
+
createdAt: existing.created_at,
|
|
174
|
+
updatedAt: now
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
this.db.prepare("INSERT INTO projects (id, name, origin, path, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)").run(project.id, project.name, project.origin, project.path || null, now, now);
|
|
178
|
+
return {
|
|
179
|
+
id: project.id,
|
|
180
|
+
name: project.name,
|
|
181
|
+
origin: project.origin,
|
|
182
|
+
path: project.path,
|
|
183
|
+
createdAt: now,
|
|
184
|
+
updatedAt: now
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
getProject(idOrName) {
|
|
188
|
+
const row = this.db.prepare("SELECT * FROM projects WHERE id = ? OR name = ?").get(idOrName, idOrName);
|
|
189
|
+
if (!row) return null;
|
|
190
|
+
return {
|
|
191
|
+
id: row.id,
|
|
192
|
+
name: row.name,
|
|
193
|
+
origin: row.origin,
|
|
194
|
+
path: row.path,
|
|
195
|
+
createdAt: row.created_at,
|
|
196
|
+
updatedAt: row.updated_at
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
getProjectByOrigin(origin) {
|
|
200
|
+
const row = this.db.prepare("SELECT * FROM projects WHERE origin = ?").get(origin);
|
|
201
|
+
if (!row) return null;
|
|
202
|
+
return {
|
|
203
|
+
id: row.id,
|
|
204
|
+
name: row.name,
|
|
205
|
+
origin: row.origin,
|
|
206
|
+
path: row.path,
|
|
207
|
+
createdAt: row.created_at,
|
|
208
|
+
updatedAt: row.updated_at
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
listProjects() {
|
|
212
|
+
const rows = this.db.prepare("SELECT * FROM projects ORDER BY updated_at DESC").all();
|
|
213
|
+
return rows.map((row) => ({
|
|
214
|
+
id: row.id,
|
|
215
|
+
name: row.name,
|
|
216
|
+
origin: row.origin,
|
|
217
|
+
path: row.path,
|
|
218
|
+
createdAt: row.created_at,
|
|
219
|
+
updatedAt: row.updated_at
|
|
220
|
+
}));
|
|
221
|
+
}
|
|
222
|
+
// --- SESSIONS ---
|
|
223
|
+
upsertSession(session) {
|
|
224
|
+
const existing = this.db.prepare("SELECT id FROM sessions WHERE id = ?").get(session.id);
|
|
225
|
+
if (existing) {
|
|
226
|
+
this.db.prepare("UPDATE sessions SET url = ?, title = ?, last_seen_at = ?, active = ? WHERE id = ?").run(session.url, session.title, session.lastSeenAt, session.active ? 1 : 0, session.id);
|
|
227
|
+
} else {
|
|
228
|
+
this.db.prepare(
|
|
229
|
+
"INSERT INTO sessions (id, project_id, origin, url, title, user_agent, connected_at, last_seen_at, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
230
|
+
).run(
|
|
231
|
+
session.id,
|
|
232
|
+
session.projectId,
|
|
233
|
+
session.origin,
|
|
234
|
+
session.url,
|
|
235
|
+
session.title,
|
|
236
|
+
session.userAgent,
|
|
237
|
+
session.connectedAt,
|
|
238
|
+
session.lastSeenAt,
|
|
239
|
+
session.active ? 1 : 0
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
getSession(id) {
|
|
244
|
+
const row = this.db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
|
|
245
|
+
if (!row) return null;
|
|
246
|
+
return {
|
|
247
|
+
id: row.id,
|
|
248
|
+
projectId: row.project_id,
|
|
249
|
+
origin: row.origin,
|
|
250
|
+
url: row.url,
|
|
251
|
+
title: row.title,
|
|
252
|
+
userAgent: row.user_agent,
|
|
253
|
+
connectedAt: row.connected_at,
|
|
254
|
+
lastSeenAt: row.last_seen_at,
|
|
255
|
+
active: row.active === 1
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
listSessions(projectId, activeOnly = false) {
|
|
259
|
+
let sql = "SELECT * FROM sessions WHERE 1=1";
|
|
260
|
+
const params = [];
|
|
261
|
+
if (projectId) {
|
|
262
|
+
sql += " AND project_id = ?";
|
|
263
|
+
params.push(projectId);
|
|
264
|
+
}
|
|
265
|
+
if (activeOnly) {
|
|
266
|
+
sql += " AND active = 1";
|
|
267
|
+
}
|
|
268
|
+
sql += " ORDER BY last_seen_at DESC";
|
|
269
|
+
const rows = this.db.prepare(sql).all(...params);
|
|
270
|
+
return rows.map((row) => ({
|
|
271
|
+
id: row.id,
|
|
272
|
+
projectId: row.project_id,
|
|
273
|
+
origin: row.origin,
|
|
274
|
+
url: row.url,
|
|
275
|
+
title: row.title,
|
|
276
|
+
userAgent: row.user_agent,
|
|
277
|
+
connectedAt: row.connected_at,
|
|
278
|
+
lastSeenAt: row.last_seen_at,
|
|
279
|
+
active: row.active === 1
|
|
280
|
+
}));
|
|
281
|
+
}
|
|
282
|
+
deactivateSession(id) {
|
|
283
|
+
this.db.prepare("UPDATE sessions SET active = 0, last_seen_at = ? WHERE id = ?").run((/* @__PURE__ */ new Date()).toISOString(), id);
|
|
284
|
+
}
|
|
285
|
+
// --- EVENTS & RETENTION ---
|
|
286
|
+
insertEvent(event) {
|
|
287
|
+
this.db.prepare("INSERT INTO events (id, session_id, event_type, payload, timestamp, route, url) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
|
|
288
|
+
event.id,
|
|
289
|
+
event.sessionId,
|
|
290
|
+
event.eventType,
|
|
291
|
+
JSON.stringify(event.payload),
|
|
292
|
+
event.timestamp,
|
|
293
|
+
event.route || "",
|
|
294
|
+
event.url || ""
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
pruneSessionEvents(sessionId, maxEvents = 1e3) {
|
|
298
|
+
this.db.prepare(
|
|
299
|
+
`DELETE FROM events
|
|
300
|
+
WHERE session_id = ? AND id NOT IN (
|
|
301
|
+
SELECT id FROM events WHERE session_id = ? ORDER BY timestamp DESC LIMIT ?
|
|
302
|
+
)`
|
|
303
|
+
).run(sessionId, sessionId, maxEvents);
|
|
304
|
+
}
|
|
305
|
+
getEvents(options) {
|
|
306
|
+
let sql = "SELECT * FROM events WHERE 1=1";
|
|
307
|
+
const params = [];
|
|
308
|
+
if (options.sessionId) {
|
|
309
|
+
sql += " AND session_id = ?";
|
|
310
|
+
params.push(options.sessionId);
|
|
311
|
+
}
|
|
312
|
+
if (options.eventType) {
|
|
313
|
+
sql += " AND event_type = ?";
|
|
314
|
+
params.push(options.eventType);
|
|
315
|
+
}
|
|
316
|
+
sql += " ORDER BY timestamp DESC LIMIT ?";
|
|
317
|
+
params.push(options.limit || 50);
|
|
318
|
+
const rows = this.db.prepare(sql).all(...params);
|
|
319
|
+
return rows.map((r) => ({
|
|
320
|
+
id: r.id,
|
|
321
|
+
sessionId: r.session_id,
|
|
322
|
+
eventType: r.event_type,
|
|
323
|
+
payload: JSON.parse(r.payload),
|
|
324
|
+
timestamp: r.timestamp,
|
|
325
|
+
route: r.route,
|
|
326
|
+
url: r.url
|
|
327
|
+
}));
|
|
328
|
+
}
|
|
329
|
+
// --- INCIDENTS ---
|
|
330
|
+
findIncidentByFingerprint(fingerprint) {
|
|
331
|
+
const row = this.db.prepare("SELECT * FROM incidents WHERE fingerprint = ?").get(fingerprint);
|
|
332
|
+
if (!row) return null;
|
|
333
|
+
return this.mapIncidentRow(row);
|
|
334
|
+
}
|
|
335
|
+
getIncident(id) {
|
|
336
|
+
const row = this.db.prepare("SELECT * FROM incidents WHERE id = ?").get(id);
|
|
337
|
+
if (!row) return null;
|
|
338
|
+
return this.mapIncidentRow(row);
|
|
339
|
+
}
|
|
340
|
+
listIncidents(options = {}) {
|
|
341
|
+
let sql = "SELECT * FROM incidents WHERE 1=1";
|
|
342
|
+
const params = [];
|
|
343
|
+
if (options.projectId) {
|
|
344
|
+
sql += " AND project_id = ?";
|
|
345
|
+
params.push(options.projectId);
|
|
346
|
+
}
|
|
347
|
+
if (options.status) {
|
|
348
|
+
sql += " AND status = ?";
|
|
349
|
+
params.push(options.status);
|
|
350
|
+
}
|
|
351
|
+
if (options.severity) {
|
|
352
|
+
sql += " AND severity = ?";
|
|
353
|
+
params.push(options.severity);
|
|
354
|
+
}
|
|
355
|
+
sql += " ORDER BY last_seen DESC LIMIT ?";
|
|
356
|
+
params.push(options.limit || 50);
|
|
357
|
+
const rows = this.db.prepare(sql).all(...params);
|
|
358
|
+
return rows.map((r) => this.mapIncidentRow(r));
|
|
359
|
+
}
|
|
360
|
+
insertIncident(incident) {
|
|
361
|
+
this.db.prepare(
|
|
362
|
+
`INSERT INTO incidents (
|
|
363
|
+
id, project_id, session_id, type, severity, message, source_file, source_line, source_col,
|
|
364
|
+
fingerprint, route, first_seen, last_seen, occurrences, status, stack, breadcrumbs,
|
|
365
|
+
network_failures, last_element, screenshot_path
|
|
366
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
367
|
+
).run(
|
|
368
|
+
incident.id,
|
|
369
|
+
incident.projectId,
|
|
370
|
+
incident.sessionId,
|
|
371
|
+
incident.type,
|
|
372
|
+
incident.severity,
|
|
373
|
+
incident.message,
|
|
374
|
+
incident.source.file,
|
|
375
|
+
incident.source.line,
|
|
376
|
+
incident.source.column || null,
|
|
377
|
+
incident.fingerprint,
|
|
378
|
+
incident.route,
|
|
379
|
+
incident.firstSeen,
|
|
380
|
+
incident.lastSeen,
|
|
381
|
+
incident.occurrences,
|
|
382
|
+
incident.status,
|
|
383
|
+
incident.stack || null,
|
|
384
|
+
JSON.stringify(incident.breadcrumbs || []),
|
|
385
|
+
JSON.stringify(incident.networkFailures || []),
|
|
386
|
+
incident.lastElement ? JSON.stringify(incident.lastElement) : null,
|
|
387
|
+
incident.screenshots?.error || null
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
updateIncidentOccurrence(incidentId, update) {
|
|
391
|
+
this.db.prepare(
|
|
392
|
+
`UPDATE incidents
|
|
393
|
+
SET session_id = ?, last_seen = ?, occurrences = ?, route = ?, breadcrumbs = ?, last_element = COALESCE(?, last_element), stack = COALESCE(?, stack)
|
|
394
|
+
WHERE id = ?`
|
|
395
|
+
).run(
|
|
396
|
+
update.sessionId,
|
|
397
|
+
update.lastSeen,
|
|
398
|
+
update.occurrences,
|
|
399
|
+
update.route,
|
|
400
|
+
JSON.stringify(update.breadcrumbs || []),
|
|
401
|
+
update.lastElement ? JSON.stringify(update.lastElement) : null,
|
|
402
|
+
update.stack || null,
|
|
403
|
+
incidentId
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
updateIncidentStatus(id, status) {
|
|
407
|
+
this.db.prepare("UPDATE incidents SET status = ? WHERE id = ?").run(status, id);
|
|
408
|
+
}
|
|
409
|
+
insertIncidentOccurrence(occurrence) {
|
|
410
|
+
this.db.prepare(
|
|
411
|
+
`INSERT INTO incident_occurrences (id, incident_id, session_id, timestamp, route, url, stack, breadcrumbs, last_element)
|
|
412
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
413
|
+
).run(
|
|
414
|
+
occurrence.id,
|
|
415
|
+
occurrence.incidentId,
|
|
416
|
+
occurrence.sessionId,
|
|
417
|
+
occurrence.timestamp,
|
|
418
|
+
occurrence.route,
|
|
419
|
+
occurrence.url,
|
|
420
|
+
occurrence.stack || null,
|
|
421
|
+
JSON.stringify(occurrence.breadcrumbs || []),
|
|
422
|
+
occurrence.lastElement ? JSON.stringify(occurrence.lastElement) : null
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
// --- VERIFICATIONS ---
|
|
426
|
+
insertVerification(v) {
|
|
427
|
+
this.db.prepare(
|
|
428
|
+
`INSERT INTO verifications (id, incident_id, status, checks, before_screenshot, after_screenshot, message, created_at)
|
|
429
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
430
|
+
).run(
|
|
431
|
+
v.id,
|
|
432
|
+
v.incidentId,
|
|
433
|
+
v.status,
|
|
434
|
+
JSON.stringify(v.checks),
|
|
435
|
+
v.beforeScreenshot || null,
|
|
436
|
+
v.afterScreenshot || null,
|
|
437
|
+
v.message || null,
|
|
438
|
+
v.createdAt
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
getLatestVerification(incidentId) {
|
|
442
|
+
const row = this.db.prepare("SELECT * FROM verifications WHERE incident_id = ? ORDER BY created_at DESC LIMIT 1").get(incidentId);
|
|
443
|
+
if (!row) return null;
|
|
444
|
+
return {
|
|
445
|
+
incidentId: row.incident_id,
|
|
446
|
+
status: row.status,
|
|
447
|
+
checks: JSON.parse(row.checks || "[]"),
|
|
448
|
+
screenshots: {
|
|
449
|
+
before: row.before_screenshot || void 0,
|
|
450
|
+
after: row.after_screenshot || void 0
|
|
451
|
+
},
|
|
452
|
+
timestamp: row.created_at,
|
|
453
|
+
message: row.message || void 0
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
// --- VISUAL NOTES ---
|
|
457
|
+
insertNote(note) {
|
|
458
|
+
this.db.prepare(
|
|
459
|
+
`INSERT INTO notes (
|
|
460
|
+
id, project_id, session_id, type, message, route, url,
|
|
461
|
+
viewport_json, scroll_json, target_json, element_context_json, region_json,
|
|
462
|
+
screenshot_path, incident_id, status, created_at, updated_at, resolved_at
|
|
463
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
464
|
+
).run(
|
|
465
|
+
note.id,
|
|
466
|
+
note.projectId,
|
|
467
|
+
note.sessionId,
|
|
468
|
+
note.type,
|
|
469
|
+
note.message,
|
|
470
|
+
note.route,
|
|
471
|
+
note.url,
|
|
472
|
+
JSON.stringify(note.viewport),
|
|
473
|
+
JSON.stringify(note.scroll),
|
|
474
|
+
note.target ? JSON.stringify(note.target) : null,
|
|
475
|
+
note.elementContext ? JSON.stringify(note.elementContext) : null,
|
|
476
|
+
note.region ? JSON.stringify(note.region) : null,
|
|
477
|
+
note.screenshots?.original || null,
|
|
478
|
+
note.incidentId || null,
|
|
479
|
+
note.status,
|
|
480
|
+
note.createdAt,
|
|
481
|
+
note.updatedAt,
|
|
482
|
+
note.resolvedAt || null
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
getNote(id) {
|
|
486
|
+
const row = this.db.prepare("SELECT * FROM notes WHERE id = ?").get(id);
|
|
487
|
+
if (!row) return null;
|
|
488
|
+
return this.mapNoteRow(row);
|
|
489
|
+
}
|
|
490
|
+
listNotes(options = {}) {
|
|
491
|
+
let sql = "SELECT * FROM notes WHERE 1=1";
|
|
492
|
+
const params = [];
|
|
493
|
+
if (options.projectId) {
|
|
494
|
+
sql += " AND project_id = ?";
|
|
495
|
+
params.push(options.projectId);
|
|
496
|
+
}
|
|
497
|
+
if (options.status) {
|
|
498
|
+
sql += " AND status = ?";
|
|
499
|
+
params.push(options.status);
|
|
500
|
+
}
|
|
501
|
+
sql += " ORDER BY created_at DESC LIMIT ?";
|
|
502
|
+
params.push(options.limit || 50);
|
|
503
|
+
const rows = this.db.prepare(sql).all(...params);
|
|
504
|
+
return rows.map((r) => this.mapNoteRow(r));
|
|
505
|
+
}
|
|
506
|
+
updateNoteStatus(id, status) {
|
|
507
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
508
|
+
const resolvedAt = status === "RESOLVED" ? now : null;
|
|
509
|
+
this.db.prepare("UPDATE notes SET status = ?, updated_at = ?, resolved_at = COALESCE(?, resolved_at) WHERE id = ?").run(status, now, resolvedAt, id);
|
|
510
|
+
}
|
|
511
|
+
updateNote(id, updates) {
|
|
512
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
513
|
+
const current = this.getNote(id);
|
|
514
|
+
if (!current) return;
|
|
515
|
+
this.db.prepare(
|
|
516
|
+
`UPDATE notes
|
|
517
|
+
SET message = COALESCE(?, message), status = COALESCE(?, status),
|
|
518
|
+
screenshot_path = COALESCE(?, screenshot_path), updated_at = ?
|
|
519
|
+
WHERE id = ?`
|
|
520
|
+
).run(
|
|
521
|
+
updates.message || null,
|
|
522
|
+
updates.status || null,
|
|
523
|
+
updates.screenshots?.original || updates.screenshots?.after || null,
|
|
524
|
+
now,
|
|
525
|
+
id
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
insertNoteVerification(v) {
|
|
529
|
+
this.db.prepare(
|
|
530
|
+
`INSERT INTO note_verifications (
|
|
531
|
+
id, note_id, status, checks, geometry_diff, before_screenshot, after_screenshot, message, created_at
|
|
532
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
533
|
+
).run(
|
|
534
|
+
`nver_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
|
535
|
+
v.noteId,
|
|
536
|
+
v.status,
|
|
537
|
+
JSON.stringify(v.checks),
|
|
538
|
+
v.geometryDiff ? JSON.stringify(v.geometryDiff) : null,
|
|
539
|
+
v.screenshots?.before || null,
|
|
540
|
+
v.screenshots?.after || null,
|
|
541
|
+
v.message || null,
|
|
542
|
+
v.timestamp
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
getLatestNoteVerification(noteId) {
|
|
546
|
+
const row = this.db.prepare("SELECT * FROM note_verifications WHERE note_id = ? ORDER BY created_at DESC LIMIT 1").get(noteId);
|
|
547
|
+
if (!row) return null;
|
|
548
|
+
return {
|
|
549
|
+
noteId: row.note_id,
|
|
550
|
+
status: row.status,
|
|
551
|
+
checks: JSON.parse(row.checks || "[]"),
|
|
552
|
+
geometryDiff: row.geometry_diff ? JSON.parse(row.geometry_diff) : void 0,
|
|
553
|
+
screenshots: {
|
|
554
|
+
before: row.before_screenshot || void 0,
|
|
555
|
+
after: row.after_screenshot || void 0
|
|
556
|
+
},
|
|
557
|
+
timestamp: row.created_at,
|
|
558
|
+
message: row.message || void 0
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
clearAll() {
|
|
562
|
+
this.db.exec(`
|
|
563
|
+
DELETE FROM events;
|
|
564
|
+
DELETE FROM incident_occurrences;
|
|
565
|
+
DELETE FROM incidents;
|
|
566
|
+
DELETE FROM screenshots;
|
|
567
|
+
DELETE FROM verifications;
|
|
568
|
+
DELETE FROM notes;
|
|
569
|
+
DELETE FROM note_verifications;
|
|
570
|
+
DELETE FROM sessions;
|
|
571
|
+
`);
|
|
572
|
+
}
|
|
573
|
+
mapIncidentRow(row) {
|
|
574
|
+
return {
|
|
575
|
+
id: row.id,
|
|
576
|
+
projectId: row.project_id,
|
|
577
|
+
sessionId: row.session_id,
|
|
578
|
+
type: row.type,
|
|
579
|
+
severity: row.severity,
|
|
580
|
+
message: row.message,
|
|
581
|
+
source: {
|
|
582
|
+
file: row.source_file,
|
|
583
|
+
line: row.source_line,
|
|
584
|
+
column: row.source_col || void 0
|
|
585
|
+
},
|
|
586
|
+
fingerprint: row.fingerprint,
|
|
587
|
+
route: row.route,
|
|
588
|
+
firstSeen: row.first_seen,
|
|
589
|
+
lastSeen: row.last_seen,
|
|
590
|
+
occurrences: row.occurrences,
|
|
591
|
+
status: row.status,
|
|
592
|
+
stack: row.stack || void 0,
|
|
593
|
+
breadcrumbs: JSON.parse(row.breadcrumbs || "[]"),
|
|
594
|
+
networkFailures: JSON.parse(row.network_failures || "[]"),
|
|
595
|
+
lastElement: row.last_element ? JSON.parse(row.last_element) : void 0,
|
|
596
|
+
screenshots: row.screenshot_path ? {
|
|
597
|
+
error: row.screenshot_path
|
|
598
|
+
} : void 0
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
mapNoteRow(row) {
|
|
602
|
+
return {
|
|
603
|
+
id: row.id,
|
|
604
|
+
projectId: row.project_id,
|
|
605
|
+
sessionId: row.session_id,
|
|
606
|
+
type: row.type,
|
|
607
|
+
message: row.message,
|
|
608
|
+
route: row.route,
|
|
609
|
+
url: row.url,
|
|
610
|
+
viewport: JSON.parse(row.viewport_json || "{}"),
|
|
611
|
+
scroll: JSON.parse(row.scroll_json || "{}"),
|
|
612
|
+
target: row.target_json ? JSON.parse(row.target_json) : void 0,
|
|
613
|
+
elementContext: row.element_context_json ? JSON.parse(row.element_context_json) : void 0,
|
|
614
|
+
region: row.region_json ? JSON.parse(row.region_json) : void 0,
|
|
615
|
+
status: row.status,
|
|
616
|
+
incidentId: row.incident_id || void 0,
|
|
617
|
+
screenshots: row.screenshot_path ? {
|
|
618
|
+
original: row.screenshot_path
|
|
619
|
+
} : void 0,
|
|
620
|
+
createdAt: row.created_at,
|
|
621
|
+
updatedAt: row.updated_at,
|
|
622
|
+
resolvedAt: row.resolved_at || void 0
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
close() {
|
|
626
|
+
this.db.close();
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
// packages/daemon/src/storage/screenshot-store.ts
|
|
631
|
+
import fs2 from "fs";
|
|
632
|
+
import path3 from "path";
|
|
633
|
+
var ScreenshotStore = class {
|
|
634
|
+
baseDir;
|
|
635
|
+
constructor(baseDir) {
|
|
636
|
+
this.baseDir = baseDir;
|
|
637
|
+
fs2.mkdirSync(this.baseDir, { recursive: true });
|
|
638
|
+
}
|
|
639
|
+
saveScreenshot(projectId, incidentId, name, dataUrl) {
|
|
640
|
+
try {
|
|
641
|
+
if (!dataUrl || !dataUrl.startsWith("data:image/")) return null;
|
|
642
|
+
const match = dataUrl.match(/^data:image\/([a-zA-Z0-9+]+);base64,(.+)$/);
|
|
643
|
+
if (!match) return null;
|
|
644
|
+
let format = match[1].toLowerCase();
|
|
645
|
+
if (format === "jpeg") format = "jpg";
|
|
646
|
+
const base64Data = match[2];
|
|
647
|
+
const buffer = Buffer.from(base64Data, "base64");
|
|
648
|
+
const targetDir = path3.join(this.baseDir, projectId || "default", "incidents", incidentId);
|
|
649
|
+
fs2.mkdirSync(targetDir, { recursive: true });
|
|
650
|
+
const fileName = `${name}.${format}`;
|
|
651
|
+
const filePath = path3.join(targetDir, fileName);
|
|
652
|
+
fs2.writeFileSync(filePath, buffer);
|
|
653
|
+
return { filePath, format };
|
|
654
|
+
} catch {
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
getScreenshotPath(projectId, incidentId, name) {
|
|
659
|
+
const targetDir = path3.join(this.baseDir, projectId || "default", "incidents", incidentId);
|
|
660
|
+
if (!fs2.existsSync(targetDir)) return null;
|
|
661
|
+
const files = fs2.readdirSync(targetDir);
|
|
662
|
+
const match = files.find((f) => f.startsWith(`${name}.`));
|
|
663
|
+
if (match) {
|
|
664
|
+
return path3.join(targetDir, match);
|
|
665
|
+
}
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
// packages/daemon/src/session/manager.ts
|
|
671
|
+
var SessionManager = class {
|
|
672
|
+
activeSockets = /* @__PURE__ */ new Map();
|
|
673
|
+
db;
|
|
674
|
+
constructor(db) {
|
|
675
|
+
this.db = db;
|
|
676
|
+
}
|
|
677
|
+
registerSocket(sessionId, ws, origin, projectId) {
|
|
678
|
+
this.activeSockets.set(sessionId, {
|
|
679
|
+
sessionId,
|
|
680
|
+
ws,
|
|
681
|
+
origin,
|
|
682
|
+
projectId,
|
|
683
|
+
pendingCommands: /* @__PURE__ */ new Map()
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
unregisterSocket(sessionId) {
|
|
687
|
+
const active = this.activeSockets.get(sessionId);
|
|
688
|
+
if (active) {
|
|
689
|
+
for (const pending of active.pendingCommands.values()) {
|
|
690
|
+
clearTimeout(pending.timer);
|
|
691
|
+
pending.reject(new Error("Session disconnected before command completed"));
|
|
692
|
+
}
|
|
693
|
+
active.pendingCommands.clear();
|
|
694
|
+
this.activeSockets.delete(sessionId);
|
|
695
|
+
this.db.deactivateSession(sessionId);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
handleCommandResponse(sessionId, response) {
|
|
699
|
+
const active = this.activeSockets.get(sessionId);
|
|
700
|
+
if (!active) return;
|
|
701
|
+
const pending = active.pendingCommands.get(response.id);
|
|
702
|
+
if (pending) {
|
|
703
|
+
clearTimeout(pending.timer);
|
|
704
|
+
active.pendingCommands.delete(response.id);
|
|
705
|
+
pending.resolve(response);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
async sendCommand(sessionId, command, timeoutMs = 5e3) {
|
|
709
|
+
const active = this.activeSockets.get(sessionId);
|
|
710
|
+
if (!active || active.ws.readyState !== 1) {
|
|
711
|
+
return {
|
|
712
|
+
id: command.id,
|
|
713
|
+
ok: false,
|
|
714
|
+
error: `Session ${sessionId} is not actively connected`
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
return new Promise((resolve, reject) => {
|
|
718
|
+
const timer = setTimeout(() => {
|
|
719
|
+
active.pendingCommands.delete(command.id);
|
|
720
|
+
resolve({
|
|
721
|
+
id: command.id,
|
|
722
|
+
ok: false,
|
|
723
|
+
error: `Command ${command.type} timed out after ${timeoutMs}ms`
|
|
724
|
+
});
|
|
725
|
+
}, timeoutMs);
|
|
726
|
+
active.pendingCommands.set(command.id, {
|
|
727
|
+
resolve,
|
|
728
|
+
reject,
|
|
729
|
+
timer
|
|
730
|
+
});
|
|
731
|
+
try {
|
|
732
|
+
active.ws.send(
|
|
733
|
+
JSON.stringify({
|
|
734
|
+
type: "command",
|
|
735
|
+
command
|
|
736
|
+
})
|
|
737
|
+
);
|
|
738
|
+
} catch (err) {
|
|
739
|
+
clearTimeout(timer);
|
|
740
|
+
active.pendingCommands.delete(command.id);
|
|
741
|
+
resolve({
|
|
742
|
+
id: command.id,
|
|
743
|
+
ok: false,
|
|
744
|
+
error: err?.message || "Failed to send command over WebSocket"
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
getActiveSessionForProject(projectId) {
|
|
750
|
+
for (const active of this.activeSockets.values()) {
|
|
751
|
+
if (active.projectId === projectId) {
|
|
752
|
+
return this.db.getSession(active.sessionId);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return null;
|
|
756
|
+
}
|
|
757
|
+
getAnyActiveSession() {
|
|
758
|
+
const first = this.activeSockets.keys().next().value;
|
|
759
|
+
if (!first) return null;
|
|
760
|
+
return this.db.getSession(first);
|
|
761
|
+
}
|
|
762
|
+
getActiveCount() {
|
|
763
|
+
return this.activeSockets.size;
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
// packages/daemon/src/notes/engine.ts
|
|
768
|
+
import crypto from "crypto";
|
|
769
|
+
import path4 from "path";
|
|
770
|
+
import fs3 from "fs";
|
|
771
|
+
var NotesEngine = class {
|
|
772
|
+
db;
|
|
773
|
+
screenshotStore;
|
|
774
|
+
constructor(db, screenshotStore) {
|
|
775
|
+
this.db = db;
|
|
776
|
+
this.screenshotStore = screenshotStore;
|
|
777
|
+
}
|
|
778
|
+
createNoteFromClient(payload) {
|
|
779
|
+
const session = this.db.getSession(payload.sessionId);
|
|
780
|
+
const projectId = session?.projectId || "default";
|
|
781
|
+
const noteId = `note_${crypto.randomUUID().slice(0, 8)}`;
|
|
782
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
783
|
+
let screenshotPath;
|
|
784
|
+
if (payload.screenshot) {
|
|
785
|
+
const saved = this.saveNoteScreenshot(projectId, noteId, "original", payload.screenshot);
|
|
786
|
+
if (saved) {
|
|
787
|
+
screenshotPath = saved;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
const note = {
|
|
791
|
+
id: noteId,
|
|
792
|
+
projectId,
|
|
793
|
+
sessionId: payload.sessionId,
|
|
794
|
+
type: payload.noteType || "element",
|
|
795
|
+
message: payload.message,
|
|
796
|
+
route: payload.route || "/",
|
|
797
|
+
url: payload.url || "",
|
|
798
|
+
viewport: payload.viewport || { width: 1280, height: 800, devicePixelRatio: 1 },
|
|
799
|
+
scroll: payload.scroll || { scrollX: 0, scrollY: 0 },
|
|
800
|
+
target: payload.target,
|
|
801
|
+
elementContext: payload.elementContext,
|
|
802
|
+
region: payload.region,
|
|
803
|
+
status: "OPEN",
|
|
804
|
+
incidentId: payload.incidentId,
|
|
805
|
+
screenshots: screenshotPath ? { original: screenshotPath } : void 0,
|
|
806
|
+
createdAt: now,
|
|
807
|
+
updatedAt: now
|
|
808
|
+
};
|
|
809
|
+
this.db.insertNote(note);
|
|
810
|
+
return note;
|
|
811
|
+
}
|
|
812
|
+
saveNoteScreenshot(projectId, noteId, name, dataUrl) {
|
|
813
|
+
try {
|
|
814
|
+
if (!dataUrl || !dataUrl.startsWith("data:image/")) return null;
|
|
815
|
+
const match = dataUrl.match(/^data:image\/([a-zA-Z0-9+]+);base64,(.+)$/);
|
|
816
|
+
if (!match) return null;
|
|
817
|
+
let format = match[1].toLowerCase();
|
|
818
|
+
if (format === "jpeg") format = "jpg";
|
|
819
|
+
const base64Data = match[2];
|
|
820
|
+
const buffer = Buffer.from(base64Data, "base64");
|
|
821
|
+
const targetDir = path4.join(this.screenshotStore.baseDir, projectId || "default", "notes", noteId);
|
|
822
|
+
fs3.mkdirSync(targetDir, { recursive: true });
|
|
823
|
+
const fileName = `${name}.${format}`;
|
|
824
|
+
const filePath = path4.join(targetDir, fileName);
|
|
825
|
+
fs3.writeFileSync(filePath, buffer);
|
|
826
|
+
return filePath;
|
|
827
|
+
} catch {
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
// packages/daemon/src/notes/verification.ts
|
|
834
|
+
import crypto2 from "crypto";
|
|
835
|
+
var NoteVerificationEngine = class {
|
|
836
|
+
db;
|
|
837
|
+
sessionManager;
|
|
838
|
+
notesEngine;
|
|
839
|
+
constructor(db, sessionManager, notesEngine) {
|
|
840
|
+
this.db = db;
|
|
841
|
+
this.sessionManager = sessionManager;
|
|
842
|
+
this.notesEngine = notesEngine;
|
|
843
|
+
}
|
|
844
|
+
async verifyNote(noteId, options = {}) {
|
|
845
|
+
const note = this.db.getNote(noteId);
|
|
846
|
+
if (!note) {
|
|
847
|
+
return {
|
|
848
|
+
noteId,
|
|
849
|
+
status: "FAILED",
|
|
850
|
+
checks: [{ type: "note_exists", passed: false, details: `Note ${noteId} not found` }],
|
|
851
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
852
|
+
message: `Note ${noteId} not found in database`
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
let session = this.sessionManager.getActiveSessionForProject(note.projectId);
|
|
856
|
+
if (!session) {
|
|
857
|
+
session = this.sessionManager.getAnyActiveSession();
|
|
858
|
+
}
|
|
859
|
+
if (!session) {
|
|
860
|
+
const result = {
|
|
861
|
+
noteId,
|
|
862
|
+
status: "INCONCLUSIVE",
|
|
863
|
+
checks: [],
|
|
864
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
865
|
+
message: "No active browser session currently connected to verify visual note."
|
|
866
|
+
};
|
|
867
|
+
this.recordNoteVerification(result, note);
|
|
868
|
+
return result;
|
|
869
|
+
}
|
|
870
|
+
this.db.updateNoteStatus(noteId, "VERIFYING");
|
|
871
|
+
const pageStateCmd = await this.sessionManager.sendCommand(session.id, {
|
|
872
|
+
id: `cmd_${crypto2.randomUUID().slice(0, 8)}`,
|
|
873
|
+
type: "get_page_state"
|
|
874
|
+
});
|
|
875
|
+
const currentRoute = pageStateCmd.result?.route || "/";
|
|
876
|
+
const isRouteMatch = currentRoute === note.route || currentRoute.startsWith(note.route);
|
|
877
|
+
if (!isRouteMatch && note.route) {
|
|
878
|
+
await this.sessionManager.sendCommand(session.id, {
|
|
879
|
+
id: `cmd_${crypto2.randomUUID().slice(0, 8)}`,
|
|
880
|
+
type: "navigate",
|
|
881
|
+
params: { url: note.route }
|
|
882
|
+
});
|
|
883
|
+
await new Promise((r) => setTimeout(r, options.observationWindowMs || 1e3));
|
|
884
|
+
}
|
|
885
|
+
const checks = [];
|
|
886
|
+
checks.push({
|
|
887
|
+
type: "route_loaded",
|
|
888
|
+
passed: true,
|
|
889
|
+
details: `Route ${note.route || "/"} loaded`
|
|
890
|
+
});
|
|
891
|
+
const targetSelector = note.target?.selector || (note.type === "page" ? "body" : "body");
|
|
892
|
+
let elementExists = true;
|
|
893
|
+
let elementVisible = true;
|
|
894
|
+
let currentRect;
|
|
895
|
+
let overflowResult;
|
|
896
|
+
if (note.type === "element" && targetSelector) {
|
|
897
|
+
const queryCmd = await this.sessionManager.sendCommand(session.id, {
|
|
898
|
+
id: `cmd_${crypto2.randomUUID().slice(0, 8)}`,
|
|
899
|
+
type: "query_element",
|
|
900
|
+
params: { selector: targetSelector }
|
|
901
|
+
});
|
|
902
|
+
elementExists = queryCmd.ok && !!queryCmd.result?.exists;
|
|
903
|
+
elementVisible = queryCmd.ok && !!queryCmd.result?.visible;
|
|
904
|
+
currentRect = queryCmd.result?.boundingRect;
|
|
905
|
+
checks.push({
|
|
906
|
+
type: "element_exists",
|
|
907
|
+
passed: elementExists,
|
|
908
|
+
details: elementExists ? `Element ${targetSelector} exists in DOM` : `Element ${targetSelector} not found`
|
|
909
|
+
});
|
|
910
|
+
checks.push({
|
|
911
|
+
type: "element_visible",
|
|
912
|
+
passed: elementVisible,
|
|
913
|
+
details: elementVisible ? `Element ${targetSelector} is visible` : `Element ${targetSelector} is hidden`
|
|
914
|
+
});
|
|
915
|
+
const overflowCmd = await this.sessionManager.sendCommand(session.id, {
|
|
916
|
+
id: `cmd_${crypto2.randomUUID().slice(0, 8)}`,
|
|
917
|
+
type: "check_overflow",
|
|
918
|
+
params: { selector: targetSelector }
|
|
919
|
+
});
|
|
920
|
+
if (overflowCmd.ok && overflowCmd.result) {
|
|
921
|
+
const ovf = overflowCmd.result;
|
|
922
|
+
overflowResult = ovf;
|
|
923
|
+
const isOverflowingNow = !!ovf.overflow;
|
|
924
|
+
checks.push({
|
|
925
|
+
type: "no_viewport_overflow",
|
|
926
|
+
passed: !isOverflowingNow,
|
|
927
|
+
details: !isOverflowingNow ? `Element fits within viewport (width: ${ovf.viewportWidth}px, rect right: ${ovf.rect?.right}px)` : `Element overflows viewport by ${ovf.overflowRightPx}px (rect right: ${ovf.rect?.right}px, viewport: ${ovf.viewportWidth}px)`
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
let afterScreenshotPath;
|
|
932
|
+
const captureCmd = await this.sessionManager.sendCommand(session.id, {
|
|
933
|
+
id: `cmd_${crypto2.randomUUID().slice(0, 8)}`,
|
|
934
|
+
type: "capture_element",
|
|
935
|
+
params: { selector: targetSelector }
|
|
936
|
+
});
|
|
937
|
+
if (captureCmd.ok && captureCmd.result?.dataUrl) {
|
|
938
|
+
const saved = this.notesEngine.saveNoteScreenshot(note.projectId, note.id, "after", captureCmd.result.dataUrl);
|
|
939
|
+
if (saved) {
|
|
940
|
+
afterScreenshotPath = saved;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
const beforeRect = note.target?.boundingRect;
|
|
944
|
+
const overflowFixed = overflowResult ? !overflowResult.overflow : void 0;
|
|
945
|
+
const geometryDiff = {
|
|
946
|
+
before: beforeRect,
|
|
947
|
+
current: currentRect || overflowResult?.rect,
|
|
948
|
+
viewportWidth: overflowResult?.viewportWidth || note.viewport.width,
|
|
949
|
+
overflowFixed,
|
|
950
|
+
overflowPx: overflowResult?.overflowRightPx || 0
|
|
951
|
+
};
|
|
952
|
+
const anyFailed = checks.some((c) => !c.passed);
|
|
953
|
+
let status = "VERIFIED";
|
|
954
|
+
if (anyFailed) {
|
|
955
|
+
status = "FAILED";
|
|
956
|
+
} else {
|
|
957
|
+
status = "VERIFIED";
|
|
958
|
+
}
|
|
959
|
+
const verificationResult = {
|
|
960
|
+
noteId,
|
|
961
|
+
status,
|
|
962
|
+
checks,
|
|
963
|
+
geometryDiff,
|
|
964
|
+
screenshots: {
|
|
965
|
+
before: note.screenshots?.original,
|
|
966
|
+
after: afterScreenshotPath
|
|
967
|
+
},
|
|
968
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
969
|
+
message: status === "VERIFIED" ? "Visual note verified: target element is present, visible, and layout checks (including viewport overflow) passed." : "Visual note verification failed: target element missing, hidden, or still overflowing."
|
|
970
|
+
};
|
|
971
|
+
this.recordNoteVerification(verificationResult, note);
|
|
972
|
+
return verificationResult;
|
|
973
|
+
}
|
|
974
|
+
recordNoteVerification(result, note) {
|
|
975
|
+
this.db.updateNoteStatus(note.id, result.status);
|
|
976
|
+
this.db.insertNoteVerification(result);
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
// packages/daemon/src/verification/engine.ts
|
|
981
|
+
import crypto3 from "crypto";
|
|
982
|
+
var VerificationEngine = class {
|
|
983
|
+
db;
|
|
984
|
+
sessionManager;
|
|
985
|
+
screenshotStore;
|
|
986
|
+
constructor(db, sessionManager, screenshotStore) {
|
|
987
|
+
this.db = db;
|
|
988
|
+
this.sessionManager = sessionManager;
|
|
989
|
+
this.screenshotStore = screenshotStore;
|
|
990
|
+
}
|
|
991
|
+
async verifyIncident(incidentId, recipe) {
|
|
992
|
+
const incident = this.db.getIncident(incidentId);
|
|
993
|
+
if (!incident) {
|
|
994
|
+
return {
|
|
995
|
+
incidentId,
|
|
996
|
+
status: "FAILED",
|
|
997
|
+
checks: [{ type: "no_incident", passed: false, details: `Incident ${incidentId} not found` }],
|
|
998
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
999
|
+
message: `Incident ${incidentId} not found in database`
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
let session = this.sessionManager.getActiveSessionForProject(incident.projectId);
|
|
1003
|
+
if (!session) {
|
|
1004
|
+
session = this.sessionManager.getAnyActiveSession();
|
|
1005
|
+
}
|
|
1006
|
+
if (!session) {
|
|
1007
|
+
const result = {
|
|
1008
|
+
incidentId,
|
|
1009
|
+
status: "INCONCLUSIVE",
|
|
1010
|
+
checks: [],
|
|
1011
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1012
|
+
message: "No active browser session currently connected to verify the fix."
|
|
1013
|
+
};
|
|
1014
|
+
this.recordVerification(result, incident);
|
|
1015
|
+
return result;
|
|
1016
|
+
}
|
|
1017
|
+
this.db.updateIncidentStatus(incidentId, "VERIFYING");
|
|
1018
|
+
const baselineOccurrences = incident.occurrences;
|
|
1019
|
+
const targetRoute = recipe?.route || incident.route;
|
|
1020
|
+
const observationMs = recipe?.observationWindowMs || 2e3;
|
|
1021
|
+
if (recipe?.route && recipe.route !== incident.route) {
|
|
1022
|
+
await this.sessionManager.sendCommand(session.id, {
|
|
1023
|
+
id: `cmd_${crypto3.randomUUID().slice(0, 8)}`,
|
|
1024
|
+
type: "navigate",
|
|
1025
|
+
params: { url: recipe.route }
|
|
1026
|
+
});
|
|
1027
|
+
} else {
|
|
1028
|
+
await this.sessionManager.sendCommand(session.id, {
|
|
1029
|
+
id: `cmd_${crypto3.randomUUID().slice(0, 8)}`,
|
|
1030
|
+
type: "reload",
|
|
1031
|
+
params: { force: true }
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
await new Promise((resolve) => setTimeout(resolve, observationMs));
|
|
1035
|
+
const freshIncident = this.db.getIncident(incidentId);
|
|
1036
|
+
const hasReoccurred = freshIncident ? freshIncident.occurrences > baselineOccurrences : false;
|
|
1037
|
+
const checks = [];
|
|
1038
|
+
checks.push({
|
|
1039
|
+
type: "no_incident",
|
|
1040
|
+
passed: !hasReoccurred,
|
|
1041
|
+
details: hasReoccurred ? `Incident reoccurred (${freshIncident?.occurrences} occurrences vs baseline ${baselineOccurrences})` : "No recurring error observed during verification window"
|
|
1042
|
+
});
|
|
1043
|
+
const probes = recipe?.expect || [];
|
|
1044
|
+
for (const probe of probes) {
|
|
1045
|
+
const probeRes = await this.evaluateProbe(session.id, probe);
|
|
1046
|
+
checks.push(probeRes);
|
|
1047
|
+
}
|
|
1048
|
+
let afterScreenshotPath;
|
|
1049
|
+
const targetSelector = recipe?.targetSelector || incident.lastElement?.selector;
|
|
1050
|
+
if (targetSelector) {
|
|
1051
|
+
const captureCmd = await this.sessionManager.sendCommand(session.id, {
|
|
1052
|
+
id: `cmd_${crypto3.randomUUID().slice(0, 8)}`,
|
|
1053
|
+
type: "capture_element",
|
|
1054
|
+
params: { selector: targetSelector }
|
|
1055
|
+
});
|
|
1056
|
+
if (captureCmd.ok && captureCmd.result?.dataUrl) {
|
|
1057
|
+
const saved = this.screenshotStore.saveScreenshot(
|
|
1058
|
+
incident.projectId,
|
|
1059
|
+
incident.id,
|
|
1060
|
+
"verified",
|
|
1061
|
+
captureCmd.result.dataUrl
|
|
1062
|
+
);
|
|
1063
|
+
if (saved) {
|
|
1064
|
+
afterScreenshotPath = saved.filePath;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
let verdict = "VERIFIED";
|
|
1069
|
+
const anyCheckFailed = checks.some((c) => !c.passed);
|
|
1070
|
+
if (anyCheckFailed || hasReoccurred) {
|
|
1071
|
+
verdict = "FAILED";
|
|
1072
|
+
} else {
|
|
1073
|
+
const wasInteractionError = incident.breadcrumbs.some((b) => b.type === "click" || b.type === "submit");
|
|
1074
|
+
if (wasInteractionError && probes.length === 0) {
|
|
1075
|
+
verdict = "INCONCLUSIVE";
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
const verificationResult = {
|
|
1079
|
+
incidentId,
|
|
1080
|
+
status: verdict,
|
|
1081
|
+
checks,
|
|
1082
|
+
screenshots: {
|
|
1083
|
+
before: incident.screenshots?.error,
|
|
1084
|
+
after: afterScreenshotPath
|
|
1085
|
+
},
|
|
1086
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1087
|
+
message: verdict === "VERIFIED" ? "Fix verified: Error did not reoccur and all checks passed." : verdict === "FAILED" ? "Verification failed: Error reoccurred or probe expectation was not met." : "Verification inconclusive: Error did not appear on reload, but specific interaction may need manual verification or custom probes."
|
|
1088
|
+
};
|
|
1089
|
+
this.recordVerification(verificationResult, incident);
|
|
1090
|
+
return verificationResult;
|
|
1091
|
+
}
|
|
1092
|
+
async evaluateProbe(sessionId, probe) {
|
|
1093
|
+
try {
|
|
1094
|
+
switch (probe.type) {
|
|
1095
|
+
case "element_exists": {
|
|
1096
|
+
if (!probe.selector) {
|
|
1097
|
+
return { type: probe.type, passed: false, details: "Missing selector in probe" };
|
|
1098
|
+
}
|
|
1099
|
+
const res = await this.sessionManager.sendCommand(sessionId, {
|
|
1100
|
+
id: `cmd_${crypto3.randomUUID().slice(0, 8)}`,
|
|
1101
|
+
type: "query_element",
|
|
1102
|
+
params: { selector: probe.selector }
|
|
1103
|
+
});
|
|
1104
|
+
const exists = res.ok && !!res.result?.exists;
|
|
1105
|
+
return {
|
|
1106
|
+
type: probe.type,
|
|
1107
|
+
passed: exists,
|
|
1108
|
+
details: exists ? `Element ${probe.selector} exists in DOM` : `Element ${probe.selector} does not exist`
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
case "element_visible": {
|
|
1112
|
+
if (!probe.selector) {
|
|
1113
|
+
return { type: probe.type, passed: false, details: "Missing selector in probe" };
|
|
1114
|
+
}
|
|
1115
|
+
const res = await this.sessionManager.sendCommand(sessionId, {
|
|
1116
|
+
id: `cmd_${crypto3.randomUUID().slice(0, 8)}`,
|
|
1117
|
+
type: "query_element",
|
|
1118
|
+
params: { selector: probe.selector }
|
|
1119
|
+
});
|
|
1120
|
+
const visible = res.ok && !!res.result?.exists && !!res.result?.visible;
|
|
1121
|
+
return {
|
|
1122
|
+
type: probe.type,
|
|
1123
|
+
passed: visible,
|
|
1124
|
+
details: visible ? `Element ${probe.selector} is visible` : `Element ${probe.selector} is not visible`
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
case "text_contains": {
|
|
1128
|
+
if (!probe.selector || !probe.text) {
|
|
1129
|
+
return { type: probe.type, passed: false, details: "Missing selector or text in probe" };
|
|
1130
|
+
}
|
|
1131
|
+
const res = await this.sessionManager.sendCommand(sessionId, {
|
|
1132
|
+
id: `cmd_${crypto3.randomUUID().slice(0, 8)}`,
|
|
1133
|
+
type: "query_element",
|
|
1134
|
+
params: { selector: probe.selector }
|
|
1135
|
+
});
|
|
1136
|
+
const innerText = res.result?.innerText || "";
|
|
1137
|
+
const contains = res.ok && innerText.includes(probe.text);
|
|
1138
|
+
return {
|
|
1139
|
+
type: probe.type,
|
|
1140
|
+
passed: contains,
|
|
1141
|
+
details: contains ? `Element ${probe.selector} contains text "${probe.text}"` : `Element text "${innerText}" did not contain "${probe.text}"`
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
case "route_is": {
|
|
1145
|
+
if (!probe.route) {
|
|
1146
|
+
return { type: probe.type, passed: false, details: "Missing route in probe" };
|
|
1147
|
+
}
|
|
1148
|
+
const res = await this.sessionManager.sendCommand(sessionId, {
|
|
1149
|
+
id: `cmd_${crypto3.randomUUID().slice(0, 8)}`,
|
|
1150
|
+
type: "get_page_state"
|
|
1151
|
+
});
|
|
1152
|
+
const currentRoute = res.result?.route || "";
|
|
1153
|
+
const matches = res.ok && (currentRoute === probe.route || currentRoute.startsWith(probe.route));
|
|
1154
|
+
return {
|
|
1155
|
+
type: probe.type,
|
|
1156
|
+
passed: matches,
|
|
1157
|
+
details: matches ? `Current route matches ${probe.route}` : `Current route is "${currentRoute}", expected "${probe.route}"`
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
default:
|
|
1161
|
+
return {
|
|
1162
|
+
type: probe.type,
|
|
1163
|
+
passed: true,
|
|
1164
|
+
details: `Probe ${probe.type} evaluated`
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
} catch (err) {
|
|
1168
|
+
return {
|
|
1169
|
+
type: probe.type,
|
|
1170
|
+
passed: false,
|
|
1171
|
+
details: err?.message || "Probe execution error"
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
recordVerification(result, incident) {
|
|
1176
|
+
this.db.updateIncidentStatus(incident.id, result.status);
|
|
1177
|
+
this.db.insertVerification({
|
|
1178
|
+
id: `ver_${crypto3.randomUUID().slice(0, 8)}`,
|
|
1179
|
+
incidentId: incident.id,
|
|
1180
|
+
status: result.status,
|
|
1181
|
+
checks: result.checks,
|
|
1182
|
+
beforeScreenshot: result.screenshots?.before,
|
|
1183
|
+
afterScreenshot: result.screenshots?.after,
|
|
1184
|
+
message: result.message,
|
|
1185
|
+
createdAt: result.timestamp
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
};
|
|
1189
|
+
|
|
1190
|
+
export {
|
|
1191
|
+
getDaemonConfig,
|
|
1192
|
+
StorageDB,
|
|
1193
|
+
ScreenshotStore,
|
|
1194
|
+
SessionManager,
|
|
1195
|
+
NotesEngine,
|
|
1196
|
+
NoteVerificationEngine,
|
|
1197
|
+
VerificationEngine
|
|
1198
|
+
};
|
|
1199
|
+
//# sourceMappingURL=chunk-BSKNG4V7.js.map
|