@matware/e2e-runner 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.claude-plugin/marketplace.json +21 -0
  2. package/.mcp.json +2 -2
  3. package/.opencode/commands/create-test.md +63 -0
  4. package/.opencode/commands/run.md +50 -0
  5. package/.opencode/commands/verify-issue.md +62 -0
  6. package/.opencode/skills/e2e-testing/SKILL.md +181 -0
  7. package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
  8. package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
  9. package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
  10. package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
  11. package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
  12. package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
  13. package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
  14. package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
  15. package/.opencode/skills/e2e-testing/references/variables.md +41 -0
  16. package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
  17. package/OPENCODE.md +166 -0
  18. package/README.md +581 -55
  19. package/agents/test-creator.md +54 -1
  20. package/agents/test-improver.md +37 -0
  21. package/bin/cli.js +408 -16
  22. package/commands/create-test.md +16 -1
  23. package/opencode.json +11 -0
  24. package/package.json +7 -2
  25. package/scripts/setup-opencode.sh +113 -0
  26. package/skills/e2e-testing/SKILL.md +10 -3
  27. package/skills/e2e-testing/references/action-types.md +48 -5
  28. package/skills/e2e-testing/references/auth-strategies.md +91 -0
  29. package/skills/e2e-testing/references/graphql.md +59 -0
  30. package/skills/e2e-testing/references/issue-verification.md +59 -0
  31. package/skills/e2e-testing/references/multi-pool.md +60 -0
  32. package/skills/e2e-testing/references/network-debugging.md +62 -0
  33. package/skills/e2e-testing/references/test-json-format.md +4 -0
  34. package/skills/e2e-testing/references/troubleshooting.md +44 -2
  35. package/skills/e2e-testing/references/variables.md +41 -0
  36. package/skills/e2e-testing/references/visual-verification.md +89 -0
  37. package/src/actions.js +324 -2
  38. package/src/ai-generate.js +58 -8
  39. package/src/config.js +143 -0
  40. package/src/dashboard.js +145 -13
  41. package/src/db.js +130 -2
  42. package/src/index.js +7 -6
  43. package/src/learner-sqlite.js +304 -0
  44. package/src/learner.js +8 -3
  45. package/src/mcp-tools.js +1121 -43
  46. package/src/module-resolver.js +37 -0
  47. package/src/narrate.js +37 -0
  48. package/src/pool-manager.js +223 -0
  49. package/src/reporter.js +82 -1
  50. package/src/runner.js +157 -28
  51. package/src/sync/auth.js +354 -0
  52. package/src/sync/client.js +572 -0
  53. package/src/sync/hub-routes.js +816 -0
  54. package/src/sync/index.js +68 -0
  55. package/src/sync/middleware.js +347 -0
  56. package/src/sync/queue.js +209 -0
  57. package/src/sync/schema.js +540 -0
  58. package/src/verify.js +10 -7
  59. package/src/watch.js +384 -0
  60. package/templates/build-dashboard.js +47 -6
  61. package/templates/dashboard/js/api.js +60 -0
  62. package/templates/dashboard/js/init.js +13 -0
  63. package/templates/dashboard/js/keyboard.js +46 -0
  64. package/templates/dashboard/js/state.js +40 -0
  65. package/templates/dashboard/js/toast.js +41 -0
  66. package/templates/dashboard/js/utils.js +196 -0
  67. package/templates/dashboard/js/view-live.js +143 -0
  68. package/templates/dashboard/js/view-runs.js +572 -0
  69. package/templates/dashboard/js/view-tests.js +294 -0
  70. package/templates/dashboard/js/view-watch.js +242 -0
  71. package/templates/dashboard/js/websocket.js +110 -0
  72. package/templates/dashboard/styles/base.css +69 -0
  73. package/templates/dashboard/styles/components.css +110 -0
  74. package/templates/dashboard/styles/view-live.css +74 -0
  75. package/templates/dashboard/styles/view-runs.css +207 -0
  76. package/templates/dashboard/styles/view-tests.css +96 -0
  77. package/templates/dashboard/styles/view-watch.css +53 -0
  78. package/templates/dashboard/template.html +165 -99
  79. package/templates/dashboard.html +1596 -541
  80. package/templates/sample-test.json +0 -8
  81. package/templates/dashboard/app.js +0 -1152
  82. package/templates/dashboard/styles.css +0 -413
@@ -0,0 +1,540 @@
1
+ /**
2
+ * Sync Schema Module
3
+ *
4
+ * SQLite migrations for multi-instance sync functionality.
5
+ * Adds tables for:
6
+ * - sync_instances: Registered agents (for hub mode)
7
+ * - sync_instance_projects: Instance ↔ Project mapping
8
+ * - sync_hub_connection: Hub connection state (for agent mode)
9
+ * - sync_queue: Offline queue for pending syncs
10
+ * - sync_audit_log: Security audit trail
11
+ */
12
+
13
+ import crypto from 'crypto';
14
+ import { getDb } from '../db.js';
15
+
16
+ /**
17
+ * Run all sync-related migrations.
18
+ * Safe to call multiple times (uses IF NOT EXISTS).
19
+ */
20
+ export function migrateSyncSchema() {
21
+ const db = getDb();
22
+
23
+ // ═══════════════════════════════════════════════════════════════════════════
24
+ // HUB MODE TABLES
25
+ // ═══════════════════════════════════════════════════════════════════════════
26
+
27
+ // Registered instances (agents connecting to this hub)
28
+ db.exec(`
29
+ CREATE TABLE IF NOT EXISTS sync_instances (
30
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
31
+ instance_id TEXT NOT NULL UNIQUE,
32
+ display_name TEXT NOT NULL,
33
+ hostname TEXT,
34
+ environment TEXT DEFAULT 'development',
35
+ api_key_hash TEXT NOT NULL,
36
+ totp_secret TEXT NOT NULL,
37
+ role TEXT DEFAULT 'member',
38
+ status TEXT DEFAULT 'pending',
39
+ last_seen TEXT,
40
+ last_ip TEXT,
41
+ created_at TEXT DEFAULT (datetime('now')),
42
+ approved_at TEXT,
43
+ approved_by INTEGER REFERENCES sync_instances(id)
44
+ );
45
+
46
+ CREATE INDEX IF NOT EXISTS idx_sync_inst_status ON sync_instances(status);
47
+ `);
48
+
49
+ // Instance ↔ Project mapping
50
+ db.exec(`
51
+ CREATE TABLE IF NOT EXISTS sync_instance_projects (
52
+ instance_id INTEGER NOT NULL REFERENCES sync_instances(id) ON DELETE CASCADE,
53
+ project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
54
+ local_cwd TEXT,
55
+ sync_enabled INTEGER DEFAULT 1,
56
+ last_push TEXT,
57
+ last_pull TEXT,
58
+ PRIMARY KEY (instance_id, project_id)
59
+ );
60
+ `);
61
+
62
+ // Add sync columns to runs table if not present
63
+ try {
64
+ db.prepare('SELECT sync_instance_id FROM runs LIMIT 0').run();
65
+ } catch {
66
+ db.exec('ALTER TABLE runs ADD COLUMN sync_instance_id INTEGER REFERENCES sync_instances(id)');
67
+ }
68
+
69
+ try {
70
+ db.prepare('SELECT sync_origin FROM runs LIMIT 0').run();
71
+ } catch {
72
+ db.exec("ALTER TABLE runs ADD COLUMN sync_origin TEXT DEFAULT 'local'");
73
+ }
74
+
75
+ try {
76
+ db.prepare('SELECT synced_at FROM runs LIMIT 0').run();
77
+ } catch {
78
+ db.exec('ALTER TABLE runs ADD COLUMN synced_at TEXT');
79
+ }
80
+
81
+ // Remote screenshots reference
82
+ db.exec(`
83
+ CREATE TABLE IF NOT EXISTS sync_screenshots (
84
+ hash TEXT PRIMARY KEY,
85
+ instance_id INTEGER REFERENCES sync_instances(id),
86
+ storage_type TEXT DEFAULT 'remote',
87
+ cached_path TEXT,
88
+ size_bytes INTEGER,
89
+ created_at TEXT DEFAULT (datetime('now'))
90
+ );
91
+ `);
92
+
93
+ // ═══════════════════════════════════════════════════════════════════════════
94
+ // AGENT MODE TABLES
95
+ // ═══════════════════════════════════════════════════════════════════════════
96
+
97
+ // Hub connection state (single row)
98
+ db.exec(`
99
+ CREATE TABLE IF NOT EXISTS sync_hub_connection (
100
+ id INTEGER PRIMARY KEY CHECK (id = 1),
101
+ hub_url TEXT NOT NULL,
102
+ instance_id TEXT NOT NULL,
103
+ display_name TEXT,
104
+ jwt_token TEXT,
105
+ refresh_token TEXT,
106
+ token_expires TEXT,
107
+ last_push TEXT,
108
+ last_pull TEXT,
109
+ last_error TEXT,
110
+ status TEXT DEFAULT 'disconnected',
111
+ created_at TEXT DEFAULT (datetime('now')),
112
+ updated_at TEXT DEFAULT (datetime('now'))
113
+ );
114
+ `);
115
+
116
+ // Offline sync queue
117
+ db.exec(`
118
+ CREATE TABLE IF NOT EXISTS sync_queue (
119
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
120
+ operation TEXT NOT NULL,
121
+ resource_type TEXT NOT NULL,
122
+ resource_id INTEGER,
123
+ payload TEXT NOT NULL,
124
+ priority INTEGER DEFAULT 0,
125
+ created_at TEXT DEFAULT (datetime('now')),
126
+ attempts INTEGER DEFAULT 0,
127
+ max_attempts INTEGER DEFAULT 5,
128
+ last_attempt TEXT,
129
+ next_attempt TEXT,
130
+ error TEXT,
131
+ status TEXT DEFAULT 'pending'
132
+ );
133
+
134
+ CREATE INDEX IF NOT EXISTS idx_sync_queue_status ON sync_queue(status, next_attempt);
135
+ CREATE INDEX IF NOT EXISTS idx_sync_queue_priority ON sync_queue(priority DESC, created_at);
136
+ `);
137
+
138
+ // ═══════════════════════════════════════════════════════════════════════════
139
+ // AUDIT LOG
140
+ // ═══════════════════════════════════════════════════════════════════════════
141
+
142
+ db.exec(`
143
+ CREATE TABLE IF NOT EXISTS sync_audit_log (
144
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
145
+ timestamp TEXT DEFAULT (datetime('now')),
146
+ instance_id TEXT,
147
+ action TEXT NOT NULL,
148
+ resource_type TEXT,
149
+ resource_id TEXT,
150
+ status TEXT NOT NULL,
151
+ ip_address TEXT,
152
+ user_agent TEXT,
153
+ request_id TEXT,
154
+ details TEXT,
155
+ signature TEXT
156
+ );
157
+
158
+ CREATE INDEX IF NOT EXISTS idx_audit_instance ON sync_audit_log(instance_id, timestamp);
159
+ CREATE INDEX IF NOT EXISTS idx_audit_action ON sync_audit_log(action, timestamp);
160
+ `);
161
+
162
+ // ═══════════════════════════════════════════════════════════════════════════
163
+ // USED NONCES (for replay attack prevention)
164
+ // ═══════════════════════════════════════════════════════════════════════════
165
+
166
+ db.exec(`
167
+ CREATE TABLE IF NOT EXISTS sync_nonces (
168
+ nonce TEXT PRIMARY KEY,
169
+ instance_id TEXT NOT NULL,
170
+ used_at TEXT DEFAULT (datetime('now'))
171
+ );
172
+
173
+ CREATE INDEX IF NOT EXISTS idx_nonces_used ON sync_nonces(used_at);
174
+ `);
175
+ }
176
+
177
+ // ═══════════════════════════════════════════════════════════════════════════
178
+ // INSTANCE MANAGEMENT (HUB MODE)
179
+ // ═══════════════════════════════════════════════════════════════════════════
180
+
181
+ /**
182
+ * Create a new instance registration.
183
+ */
184
+ export function createInstance({ instanceId, displayName, hostname, environment, apiKeyHash, totpSecret, role = 'member', status = 'pending' }) {
185
+ const db = getDb();
186
+ const stmt = db.prepare(`
187
+ INSERT INTO sync_instances (instance_id, display_name, hostname, environment, api_key_hash, totp_secret, role, status)
188
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
189
+ `);
190
+
191
+ const result = stmt.run(instanceId, displayName, hostname, environment, apiKeyHash, totpSecret, role, status);
192
+ return result.lastInsertRowid;
193
+ }
194
+
195
+ /**
196
+ * Get instance by instance_id.
197
+ */
198
+ export function getInstance(instanceId) {
199
+ const db = getDb();
200
+ return db.prepare('SELECT * FROM sync_instances WHERE instance_id = ?').get(instanceId);
201
+ }
202
+
203
+ /**
204
+ * Get instance by database ID.
205
+ */
206
+ export function getInstanceById(id) {
207
+ const db = getDb();
208
+ return db.prepare('SELECT * FROM sync_instances WHERE id = ?').get(id);
209
+ }
210
+
211
+ /**
212
+ * List all instances.
213
+ */
214
+ export function listInstances(status = null) {
215
+ const db = getDb();
216
+ if (status) {
217
+ return db.prepare('SELECT * FROM sync_instances WHERE status = ? ORDER BY created_at DESC').all(status);
218
+ }
219
+ return db.prepare('SELECT * FROM sync_instances ORDER BY created_at DESC').all();
220
+ }
221
+
222
+ /**
223
+ * Update instance status.
224
+ */
225
+ export function updateInstanceStatus(instanceId, status, approvedBy = null) {
226
+ const db = getDb();
227
+ const updates = ['status = ?', 'approved_at = datetime("now")'];
228
+ const params = [status];
229
+
230
+ if (approvedBy) {
231
+ updates.push('approved_by = ?');
232
+ params.push(approvedBy);
233
+ }
234
+
235
+ params.push(instanceId);
236
+ db.prepare(`UPDATE sync_instances SET ${updates.join(', ')} WHERE instance_id = ?`).run(...params);
237
+ }
238
+
239
+ /**
240
+ * Update instance last seen.
241
+ */
242
+ export function updateInstanceLastSeen(instanceId, ip = null) {
243
+ const db = getDb();
244
+ db.prepare(`
245
+ UPDATE sync_instances
246
+ SET last_seen = datetime('now'), last_ip = COALESCE(?, last_ip)
247
+ WHERE instance_id = ?
248
+ `).run(ip, instanceId);
249
+ }
250
+
251
+ /**
252
+ * Delete an instance.
253
+ */
254
+ export function deleteInstance(instanceId) {
255
+ const db = getDb();
256
+ return db.prepare('DELETE FROM sync_instances WHERE instance_id = ?').run(instanceId).changes > 0;
257
+ }
258
+
259
+ // ═══════════════════════════════════════════════════════════════════════════
260
+ // HUB CONNECTION (AGENT MODE)
261
+ // ═══════════════════════════════════════════════════════════════════════════
262
+
263
+ /**
264
+ * Save hub connection state.
265
+ */
266
+ export function saveHubConnection({ hubUrl, instanceId, displayName, jwtToken, refreshToken, tokenExpires, status }) {
267
+ const db = getDb();
268
+ db.prepare(`
269
+ INSERT INTO sync_hub_connection (id, hub_url, instance_id, display_name, jwt_token, refresh_token, token_expires, status, updated_at)
270
+ VALUES (1, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
271
+ ON CONFLICT(id) DO UPDATE SET
272
+ hub_url = excluded.hub_url,
273
+ instance_id = excluded.instance_id,
274
+ display_name = COALESCE(excluded.display_name, display_name),
275
+ jwt_token = excluded.jwt_token,
276
+ refresh_token = COALESCE(excluded.refresh_token, refresh_token),
277
+ token_expires = excluded.token_expires,
278
+ status = excluded.status,
279
+ updated_at = datetime('now')
280
+ `).run(hubUrl, instanceId, displayName, jwtToken, refreshToken, tokenExpires, status);
281
+ }
282
+
283
+ /**
284
+ * Get current hub connection.
285
+ */
286
+ export function getHubConnection() {
287
+ const db = getDb();
288
+ return db.prepare('SELECT * FROM sync_hub_connection WHERE id = 1').get();
289
+ }
290
+
291
+ /**
292
+ * Update hub connection status.
293
+ */
294
+ export function updateHubConnectionStatus(status, error = null) {
295
+ const db = getDb();
296
+ db.prepare(`
297
+ UPDATE sync_hub_connection
298
+ SET status = ?, last_error = ?, updated_at = datetime('now')
299
+ WHERE id = 1
300
+ `).run(status, error);
301
+ }
302
+
303
+ /**
304
+ * Update last push timestamp.
305
+ */
306
+ export function updateLastPush() {
307
+ const db = getDb();
308
+ db.prepare(`UPDATE sync_hub_connection SET last_push = datetime('now'), updated_at = datetime('now') WHERE id = 1`).run();
309
+ }
310
+
311
+ /**
312
+ * Update last pull timestamp.
313
+ */
314
+ export function updateLastPull() {
315
+ const db = getDb();
316
+ db.prepare(`UPDATE sync_hub_connection SET last_pull = datetime('now'), updated_at = datetime('now') WHERE id = 1`).run();
317
+ }
318
+
319
+ // ═══════════════════════════════════════════════════════════════════════════
320
+ // SYNC QUEUE (OFFLINE SUPPORT)
321
+ // ═══════════════════════════════════════════════════════════════════════════
322
+
323
+ /**
324
+ * Add item to sync queue.
325
+ */
326
+ export function enqueueSync({ operation, resourceType, resourceId, payload, priority = 0 }) {
327
+ const db = getDb();
328
+ const stmt = db.prepare(`
329
+ INSERT INTO sync_queue (operation, resource_type, resource_id, payload, priority, next_attempt)
330
+ VALUES (?, ?, ?, ?, ?, datetime('now'))
331
+ `);
332
+
333
+ const result = stmt.run(operation, resourceType, resourceId, JSON.stringify(payload), priority);
334
+ return result.lastInsertRowid;
335
+ }
336
+
337
+ /**
338
+ * Get next pending items from queue.
339
+ */
340
+ export function getQueuedItems(limit = 10) {
341
+ const db = getDb();
342
+ return db.prepare(`
343
+ SELECT * FROM sync_queue
344
+ WHERE status = 'pending' AND next_attempt <= datetime('now')
345
+ ORDER BY priority DESC, created_at
346
+ LIMIT ?
347
+ `).all(limit);
348
+ }
349
+
350
+ /**
351
+ * Mark queue item as completed.
352
+ */
353
+ export function completeQueueItem(id) {
354
+ const db = getDb();
355
+ db.prepare(`UPDATE sync_queue SET status = 'completed' WHERE id = ?`).run(id);
356
+ }
357
+
358
+ /**
359
+ * Mark queue item as failed and schedule retry.
360
+ */
361
+ export function failQueueItem(id, error) {
362
+ const db = getDb();
363
+ const item = db.prepare('SELECT attempts, max_attempts FROM sync_queue WHERE id = ?').get(id);
364
+
365
+ if (!item) return;
366
+
367
+ const newAttempts = item.attempts + 1;
368
+ const status = newAttempts >= item.max_attempts ? 'failed' : 'pending';
369
+
370
+ // Exponential backoff: 1min, 2min, 4min, 8min, 16min
371
+ const delayMinutes = Math.pow(2, newAttempts - 1);
372
+
373
+ db.prepare(`
374
+ UPDATE sync_queue
375
+ SET status = ?, error = ?, attempts = ?, last_attempt = datetime('now'),
376
+ next_attempt = datetime('now', '+' || ? || ' minutes')
377
+ WHERE id = ?
378
+ `).run(status, error, newAttempts, delayMinutes, id);
379
+ }
380
+
381
+ /**
382
+ * Clear completed items older than N days.
383
+ */
384
+ export function cleanupQueue(days = 7) {
385
+ const db = getDb();
386
+ return db.prepare(`
387
+ DELETE FROM sync_queue
388
+ WHERE status IN ('completed', 'failed')
389
+ AND created_at < datetime('now', '-' || ? || ' days')
390
+ `).run(days).changes;
391
+ }
392
+
393
+ /**
394
+ * Get queue statistics.
395
+ */
396
+ export function getQueueStats() {
397
+ const db = getDb();
398
+ return db.prepare(`
399
+ SELECT
400
+ status,
401
+ COUNT(*) as count,
402
+ MIN(created_at) as oldest
403
+ FROM sync_queue
404
+ GROUP BY status
405
+ `).all();
406
+ }
407
+
408
+ // ═══════════════════════════════════════════════════════════════════════════
409
+ // AUDIT LOG
410
+ // ═══════════════════════════════════════════════════════════════════════════
411
+
412
+ /**
413
+ * Log an audit event.
414
+ */
415
+ export function logAudit({ instanceId, action, resourceType, resourceId, status, ipAddress, userAgent, requestId, details }) {
416
+ const db = getDb();
417
+
418
+ // Generate HMAC signature for tamper detection
419
+ const data = JSON.stringify({ instanceId, action, resourceType, resourceId, status, details });
420
+ const signature = crypto.createHmac('sha256', 'audit-integrity-key').update(data).digest('hex');
421
+
422
+ db.prepare(`
423
+ INSERT INTO sync_audit_log (instance_id, action, resource_type, resource_id, status, ip_address, user_agent, request_id, details, signature)
424
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
425
+ `).run(instanceId, action, resourceType, resourceId, status, ipAddress, userAgent, requestId, JSON.stringify(details), signature);
426
+ }
427
+
428
+ /**
429
+ * Query audit log.
430
+ */
431
+ export function queryAuditLog({ instanceId, action, status, since, until, limit = 100 }) {
432
+ const db = getDb();
433
+ const conditions = [];
434
+ const params = [];
435
+
436
+ if (instanceId) {
437
+ conditions.push('instance_id = ?');
438
+ params.push(instanceId);
439
+ }
440
+ if (action) {
441
+ conditions.push('action = ?');
442
+ params.push(action);
443
+ }
444
+ if (status) {
445
+ conditions.push('status = ?');
446
+ params.push(status);
447
+ }
448
+ if (since) {
449
+ conditions.push('timestamp >= ?');
450
+ params.push(since);
451
+ }
452
+ if (until) {
453
+ conditions.push('timestamp <= ?');
454
+ params.push(until);
455
+ }
456
+
457
+ const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
458
+ params.push(limit);
459
+
460
+ return db.prepare(`
461
+ SELECT * FROM sync_audit_log ${where}
462
+ ORDER BY timestamp DESC
463
+ LIMIT ?
464
+ `).all(...params);
465
+ }
466
+
467
+ // ═══════════════════════════════════════════════════════════════════════════
468
+ // NONCE MANAGEMENT (REPLAY PREVENTION)
469
+ // ═══════════════════════════════════════════════════════════════════════════
470
+
471
+ /**
472
+ * Check and consume a nonce.
473
+ * Returns true if nonce is valid (not used before), false otherwise.
474
+ */
475
+ export function consumeNonce(nonce, instanceId) {
476
+ const db = getDb();
477
+
478
+ try {
479
+ db.prepare(`INSERT INTO sync_nonces (nonce, instance_id) VALUES (?, ?)`).run(nonce, instanceId);
480
+ return true;
481
+ } catch (err) {
482
+ // Unique constraint violation = nonce already used
483
+ if (err.code === 'SQLITE_CONSTRAINT_PRIMARYKEY' || err.message.includes('UNIQUE constraint')) {
484
+ return false;
485
+ }
486
+ throw err;
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Clean up old nonces (older than 5 minutes).
492
+ */
493
+ export function cleanupNonces() {
494
+ const db = getDb();
495
+ return db.prepare(`DELETE FROM sync_nonces WHERE used_at < datetime('now', '-5 minutes')`).run().changes;
496
+ }
497
+
498
+ // ═══════════════════════════════════════════════════════════════════════════
499
+ // SYNCED RUNS
500
+ // ═══════════════════════════════════════════════════════════════════════════
501
+
502
+ /**
503
+ * Mark a run as synced from remote instance.
504
+ */
505
+ export function markRunSynced(runId, instanceId) {
506
+ const db = getDb();
507
+ db.prepare(`
508
+ UPDATE runs
509
+ SET sync_instance_id = ?, sync_origin = 'remote', synced_at = datetime('now')
510
+ WHERE id = ?
511
+ `).run(instanceId, runId);
512
+ }
513
+
514
+ /**
515
+ * Get runs from other instances.
516
+ */
517
+ export function getRemoteRuns(projectId, limit = 50) {
518
+ const db = getDb();
519
+ return db.prepare(`
520
+ SELECT r.*, si.instance_id as source_instance, si.display_name as source_display_name
521
+ FROM runs r
522
+ JOIN sync_instances si ON r.sync_instance_id = si.id
523
+ WHERE r.project_id = ? AND r.sync_origin = 'remote'
524
+ ORDER BY r.generated_at DESC
525
+ LIMIT ?
526
+ `).all(projectId, limit);
527
+ }
528
+
529
+ /**
530
+ * Check if a run already exists (by instance + local run_id).
531
+ */
532
+ export function runExists(instanceDbId, localRunId) {
533
+ const db = getDb();
534
+ const result = db.prepare(`
535
+ SELECT id FROM runs
536
+ WHERE sync_instance_id = ? AND run_id = ?
537
+ `).get(instanceDbId, localRunId);
538
+
539
+ return !!result;
540
+ }
package/src/verify.js CHANGED
@@ -9,7 +9,7 @@ import fs from 'fs';
9
9
  import path from 'path';
10
10
  import { fetchIssue } from './issues.js';
11
11
  import { generateTests } from './ai-generate.js';
12
- import { waitForPool } from './pool.js';
12
+ import { waitForAnyPool, getPoolUrls } from './pool-manager.js';
13
13
  import { runTestsParallel } from './runner.js';
14
14
  import { generateReport, saveReport, persistRun } from './reporter.js';
15
15
 
@@ -25,7 +25,8 @@ export async function verifyIssue(url, config) {
25
25
  const issue = fetchIssue(url);
26
26
 
27
27
  // 2. Generate tests via Claude API
28
- const { tests, suiteName } = await generateTests(issue, config, config.testType || 'e2e');
28
+ const generated = await generateTests(issue, config, config.testType || 'e2e');
29
+ const { tests, suiteName } = generated;
29
30
 
30
31
  // 3. Save tests to a temp file (underscore prefix for cleanup identification)
31
32
  const testFile = path.join(config.testsDir, `_verify-${suiteName}.json`);
@@ -35,26 +36,28 @@ export async function verifyIssue(url, config) {
35
36
  fs.writeFileSync(testFile, JSON.stringify(tests, null, 2));
36
37
 
37
38
  try {
38
- // 4. Build hooks (inject auth if provided)
39
- const hooks = {};
39
+ // 4. Build hooks merge AI-generated hooks with auth injection if provided
40
+ const hooks = generated.hooks ? { ...generated.hooks } : {};
40
41
  if (config.authToken) {
41
42
  const esc = s => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
42
43
  const storageKey = esc(config.authStorageKey || 'accessToken');
43
44
  const token = esc(config.authToken);
44
- hooks.beforeEach = [
45
+ const authActions = [
45
46
  { type: 'goto', value: '/' },
46
47
  { type: 'evaluate', value: `localStorage.setItem('${storageKey}', '${token}')` },
47
48
  { type: 'goto', value: '/' },
48
49
  { type: 'wait', value: '1000' },
49
50
  ];
51
+ // Prepend auth actions to any existing AI-generated beforeEach
52
+ hooks.beforeEach = [...authActions, ...(hooks.beforeEach || [])];
50
53
  }
51
54
 
52
55
  // 5. Wait for pool and run
53
- await waitForPool(config.poolUrl);
56
+ await waitForAnyPool(getPoolUrls(config));
54
57
  const results = await runTestsParallel(tests, config, hooks);
55
58
  const report = generateReport(results);
56
59
  saveReport(report, config.screenshotsDir, config);
57
- persistRun(report, config, suiteName);
60
+ await persistRun(report, config, suiteName);
58
61
 
59
62
  // 6. Interpret results
60
63
  const bugConfirmed = report.summary.failed > 0;