@matware/e2e-runner 1.2.1 → 1.3.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.
Files changed (88) hide show
  1. package/.claude-plugin/marketplace.json +52 -0
  2. package/.claude-plugin/plugin.json +17 -3
  3. package/.mcp.json +2 -2
  4. package/.opencode/commands/create-test.md +63 -0
  5. package/.opencode/commands/run.md +50 -0
  6. package/.opencode/commands/verify-issue.md +62 -0
  7. package/.opencode/skills/e2e-testing/SKILL.md +181 -0
  8. package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
  9. package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
  10. package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
  11. package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
  12. package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
  13. package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
  14. package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
  15. package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
  16. package/.opencode/skills/e2e-testing/references/variables.md +41 -0
  17. package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
  18. package/LICENSE +190 -0
  19. package/OPENCODE.md +166 -0
  20. package/README.md +165 -104
  21. package/agents/test-creator.md +54 -1
  22. package/agents/test-improver.md +37 -0
  23. package/bin/cli.js +409 -16
  24. package/commands/capture.md +45 -0
  25. package/commands/create-test.md +16 -1
  26. package/opencode.json +11 -0
  27. package/package.json +7 -2
  28. package/scripts/setup-opencode.sh +113 -0
  29. package/skills/e2e-testing/SKILL.md +10 -3
  30. package/skills/e2e-testing/references/action-types.md +48 -5
  31. package/skills/e2e-testing/references/auth-strategies.md +91 -0
  32. package/skills/e2e-testing/references/graphql.md +59 -0
  33. package/skills/e2e-testing/references/issue-verification.md +59 -0
  34. package/skills/e2e-testing/references/multi-pool.md +60 -0
  35. package/skills/e2e-testing/references/network-debugging.md +62 -0
  36. package/skills/e2e-testing/references/test-json-format.md +4 -0
  37. package/skills/e2e-testing/references/troubleshooting.md +44 -2
  38. package/skills/e2e-testing/references/variables.md +41 -0
  39. package/skills/e2e-testing/references/visual-verification.md +89 -0
  40. package/src/actions.js +475 -2
  41. package/src/ai-generate.js +139 -8
  42. package/src/app-pool.js +339 -0
  43. package/src/config.js +266 -5
  44. package/src/dashboard.js +216 -17
  45. package/src/db.js +191 -7
  46. package/src/index.js +12 -9
  47. package/src/learner-sqlite.js +458 -0
  48. package/src/learner.js +78 -6
  49. package/src/mcp-tools.js +1348 -51
  50. package/src/module-resolver.js +37 -0
  51. package/src/narrate.js +65 -0
  52. package/src/pool-manager.js +229 -0
  53. package/src/pool.js +301 -31
  54. package/src/reporter.js +86 -2
  55. package/src/runner.js +480 -71
  56. package/src/sync/auth.js +354 -0
  57. package/src/sync/client.js +572 -0
  58. package/src/sync/hub-routes.js +816 -0
  59. package/src/sync/index.js +68 -0
  60. package/src/sync/middleware.js +347 -0
  61. package/src/sync/queue.js +209 -0
  62. package/src/sync/schema.js +540 -0
  63. package/src/verify.js +10 -7
  64. package/src/visual-diff.js +446 -0
  65. package/src/watch.js +384 -0
  66. package/templates/build-dashboard.js +47 -6
  67. package/templates/dashboard/js/api.js +62 -0
  68. package/templates/dashboard/js/init.js +13 -0
  69. package/templates/dashboard/js/keyboard.js +46 -0
  70. package/templates/dashboard/js/state.js +40 -0
  71. package/templates/dashboard/js/toast.js +41 -0
  72. package/templates/dashboard/js/utils.js +216 -0
  73. package/templates/dashboard/js/view-live.js +181 -0
  74. package/templates/dashboard/js/view-runs.js +676 -0
  75. package/templates/dashboard/js/view-tests.js +294 -0
  76. package/templates/dashboard/js/view-watch.js +242 -0
  77. package/templates/dashboard/js/websocket.js +116 -0
  78. package/templates/dashboard/styles/base.css +69 -0
  79. package/templates/dashboard/styles/components.css +117 -0
  80. package/templates/dashboard/styles/view-live.css +97 -0
  81. package/templates/dashboard/styles/view-runs.css +243 -0
  82. package/templates/dashboard/styles/view-tests.css +96 -0
  83. package/templates/dashboard/styles/view-watch.css +53 -0
  84. package/templates/dashboard/template.html +181 -100
  85. package/templates/dashboard.html +1614 -547
  86. package/templates/sample-test.json +0 -8
  87. package/templates/dashboard/app.js +0 -1152
  88. package/templates/dashboard/styles.css +0 -413
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Sync Module - Multi-Instance Synchronization
3
+ *
4
+ * This module enables e2e-runner instances to sync test results across machines.
5
+ *
6
+ * Modes:
7
+ * - standalone: No sync (default)
8
+ * - hub: Accept connections from agents, aggregate results
9
+ * - agent: Connect to a hub, push results, pull from other instances
10
+ *
11
+ * Usage:
12
+ * import { initSync, pushRun, pullRuns } from './sync/index.js';
13
+ * await initSync(config);
14
+ */
15
+
16
+ export * from './auth.js';
17
+ export * from './schema.js';
18
+ export * from './middleware.js';
19
+ export * from './client.js';
20
+ export * from './queue.js';
21
+
22
+ // Re-export commonly used functions with cleaner names
23
+ export {
24
+ generateApiKey,
25
+ generateTotpSecret,
26
+ generateTotpUri,
27
+ generateMasterKey,
28
+ hashApiKey,
29
+ signJwt,
30
+ verifyJwt,
31
+ } from './auth.js';
32
+
33
+ export {
34
+ migrateSyncSchema,
35
+ createInstance,
36
+ getInstance,
37
+ listInstances,
38
+ updateInstanceStatus,
39
+ getHubConnection,
40
+ saveHubConnection,
41
+ enqueueSync,
42
+ getQueuedItems,
43
+ logAudit,
44
+ queryAuditLog,
45
+ } from './schema.js';
46
+
47
+ export {
48
+ createAuthMiddleware,
49
+ createRateLimitMiddleware,
50
+ requirePermission,
51
+ authenticateWithCredentials,
52
+ getJwtSecret,
53
+ getMasterKey,
54
+ } from './middleware.js';
55
+
56
+ export {
57
+ SyncClient,
58
+ getSyncClient,
59
+ pushRun,
60
+ pullRuns,
61
+ } from './client.js';
62
+
63
+ export {
64
+ QueueManager,
65
+ getQueueManager,
66
+ queueRun,
67
+ queueScreenshot,
68
+ } from './queue.js';
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Sync Authentication Middleware
3
+ *
4
+ * Provides request authentication and authorization for sync API endpoints.
5
+ * Supports:
6
+ * - JWT Bearer token authentication
7
+ * - API Key + TOTP authentication
8
+ * - Role-based access control (RBAC)
9
+ * - Rate limiting
10
+ * - Audit logging
11
+ */
12
+
13
+ import { verifyJwt, validateTotp, verifyApiKey, isTimestampValid } from './auth.js';
14
+ import { getInstance, updateInstanceLastSeen, consumeNonce, logAudit } from './schema.js';
15
+ import crypto from 'crypto';
16
+
17
+ // ═══════════════════════════════════════════════════════════════════════════
18
+ // ROLE PERMISSIONS
19
+ // ═══════════════════════════════════════════════════════════════════════════
20
+
21
+ const ROLES = {
22
+ admin: ['sync:*', 'instance:*', 'run:*', 'read:*', 'audit:*'],
23
+ member: ['sync:push', 'sync:pull', 'run:trigger', 'read:*'],
24
+ readonly: ['sync:pull', 'read:*'],
25
+ };
26
+
27
+ /**
28
+ * Check if a role has a specific permission.
29
+ */
30
+ export function hasPermission(role, permission) {
31
+ const perms = ROLES[role] || [];
32
+ return perms.some(p =>
33
+ p === permission ||
34
+ (p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
35
+ );
36
+ }
37
+
38
+ // ═══════════════════════════════════════════════════════════════════════════
39
+ // RATE LIMITING
40
+ // ═══════════════════════════════════════════════════════════════════════════
41
+
42
+ const rateLimitStore = new Map();
43
+ const RATE_LIMITS = {
44
+ '/api/sync/auth': { window: 60000, max: 5 }, // 5 per minute
45
+ '/api/sync/push': { window: 60000, max: 60 }, // 60 per minute
46
+ '/api/sync/pull': { window: 60000, max: 120 }, // 120 per minute
47
+ '/api/sync/screenshots': { window: 60000, max: 100 },
48
+ 'default': { window: 60000, max: 300 },
49
+ };
50
+
51
+ /**
52
+ * Check rate limit for an IP + path combination.
53
+ * Returns { allowed: boolean, remaining: number, resetAt: number }
54
+ */
55
+ export function checkRateLimit(ip, path) {
56
+ const key = `${ip}:${path}`;
57
+ const limit = RATE_LIMITS[path] || RATE_LIMITS.default;
58
+ const now = Date.now();
59
+
60
+ let entry = rateLimitStore.get(key);
61
+
62
+ // Clean up old entry
63
+ if (entry && entry.resetAt <= now) {
64
+ entry = null;
65
+ rateLimitStore.delete(key);
66
+ }
67
+
68
+ if (!entry) {
69
+ entry = {
70
+ count: 0,
71
+ resetAt: now + limit.window,
72
+ };
73
+ rateLimitStore.set(key, entry);
74
+ }
75
+
76
+ entry.count++;
77
+
78
+ return {
79
+ allowed: entry.count <= limit.max,
80
+ remaining: Math.max(0, limit.max - entry.count),
81
+ resetAt: entry.resetAt,
82
+ };
83
+ }
84
+
85
+ // Clean up rate limit entries periodically (unref to not prevent process exit)
86
+ const rateLimitCleanupInterval = setInterval(() => {
87
+ const now = Date.now();
88
+ for (const [key, entry] of rateLimitStore) {
89
+ if (entry.resetAt <= now) {
90
+ rateLimitStore.delete(key);
91
+ }
92
+ }
93
+ }, 60000);
94
+ rateLimitCleanupInterval.unref();
95
+
96
+ // ═══════════════════════════════════════════════════════════════════════════
97
+ // JWT SECRET MANAGEMENT
98
+ // ═══════════════════════════════════════════════════════════════════════════
99
+
100
+ let jwtSecret = null;
101
+
102
+ /**
103
+ * Get or generate JWT secret.
104
+ * In production, this should come from config.sync.hub.jwtSecret or an env var.
105
+ */
106
+ export function getJwtSecret(config) {
107
+ if (jwtSecret) return jwtSecret;
108
+
109
+ // Try to get from config/env
110
+ const fromEnv = process.env.E2E_SYNC_JWT_SECRET;
111
+ if (fromEnv) {
112
+ jwtSecret = fromEnv;
113
+ return jwtSecret;
114
+ }
115
+
116
+ // Generate a random one (persists only for this process)
117
+ // In production, you should set E2E_SYNC_JWT_SECRET
118
+ jwtSecret = crypto.randomBytes(32).toString('hex');
119
+ console.error('[sync] Warning: Generated random JWT secret. Set E2E_SYNC_JWT_SECRET for persistence.');
120
+ return jwtSecret;
121
+ }
122
+
123
+ /**
124
+ * Get master key for encrypting TOTP secrets.
125
+ */
126
+ export function getMasterKey(config) {
127
+ const envVar = config?.sync?.hub?.masterKeyEnv || 'E2E_SYNC_MASTER_KEY';
128
+ const key = process.env[envVar];
129
+
130
+ if (!key) {
131
+ console.error(`[sync] Warning: ${envVar} not set. TOTP secrets will be stored unencrypted.`);
132
+ return null;
133
+ }
134
+
135
+ if (key.length !== 64) {
136
+ console.error(`[sync] Warning: ${envVar} should be 64 hex characters (32 bytes). Current: ${key.length} chars.`);
137
+ return null;
138
+ }
139
+
140
+ return key;
141
+ }
142
+
143
+ // ═══════════════════════════════════════════════════════════════════════════
144
+ // AUTHENTICATION MIDDLEWARE
145
+ // ═══════════════════════════════════════════════════════════════════════════
146
+
147
+ /**
148
+ * Create authentication middleware for sync endpoints.
149
+ * @param {object} config - App config
150
+ * @returns {Function} Middleware function
151
+ */
152
+ export function createAuthMiddleware(config) {
153
+ const jwtSecret = getJwtSecret(config);
154
+
155
+ return function authMiddleware(req, res, next) {
156
+ const path = req.url.split('?')[0];
157
+
158
+ // Skip auth for auth endpoint itself
159
+ if (path === '/api/sync/auth' || path === '/api/sync/register') {
160
+ return next();
161
+ }
162
+
163
+ // Check for Bearer token
164
+ const authHeader = req.headers.authorization;
165
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
166
+ return sendError(res, 401, 'Missing or invalid Authorization header');
167
+ }
168
+
169
+ const token = authHeader.slice(7);
170
+
171
+ try {
172
+ const payload = verifyJwt(token, jwtSecret);
173
+
174
+ // Attach auth info to request
175
+ req.auth = {
176
+ instanceId: payload.sub,
177
+ role: payload.role || 'member',
178
+ instanceDbId: payload.dbId,
179
+ };
180
+
181
+ // Update last seen
182
+ updateInstanceLastSeen(payload.sub, getClientIp(req));
183
+
184
+ next();
185
+ } catch (err) {
186
+ logAudit({
187
+ instanceId: 'unknown',
188
+ action: 'auth.verify',
189
+ status: 'denied',
190
+ ipAddress: getClientIp(req),
191
+ details: { error: err.message },
192
+ });
193
+
194
+ return sendError(res, 401, `Authentication failed: ${err.message}`);
195
+ }
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Create authorization middleware for specific permission.
201
+ * @param {string} permission - Required permission
202
+ * @returns {Function} Middleware function
203
+ */
204
+ export function requirePermission(permission) {
205
+ return function(req, res, next) {
206
+ if (!req.auth) {
207
+ return sendError(res, 401, 'Not authenticated');
208
+ }
209
+
210
+ if (!hasPermission(req.auth.role, permission)) {
211
+ logAudit({
212
+ instanceId: req.auth.instanceId,
213
+ action: 'auth.authorize',
214
+ status: 'denied',
215
+ ipAddress: getClientIp(req),
216
+ details: { required: permission, role: req.auth.role },
217
+ });
218
+
219
+ return sendError(res, 403, `Permission denied: requires ${permission}`);
220
+ }
221
+
222
+ next();
223
+ };
224
+ }
225
+
226
+ /**
227
+ * Create rate limit middleware.
228
+ */
229
+ export function createRateLimitMiddleware() {
230
+ return function rateLimitMiddleware(req, res, next) {
231
+ const ip = getClientIp(req);
232
+ const path = req.url.split('?')[0];
233
+
234
+ const { allowed, remaining, resetAt } = checkRateLimit(ip, path);
235
+
236
+ res.setHeader('X-RateLimit-Remaining', remaining);
237
+ res.setHeader('X-RateLimit-Reset', Math.ceil(resetAt / 1000));
238
+
239
+ if (!allowed) {
240
+ logAudit({
241
+ instanceId: req.auth?.instanceId || 'unknown',
242
+ action: 'ratelimit.exceeded',
243
+ status: 'denied',
244
+ ipAddress: ip,
245
+ details: { path },
246
+ });
247
+
248
+ return sendError(res, 429, 'Too many requests');
249
+ }
250
+
251
+ next();
252
+ };
253
+ }
254
+
255
+ // ═══════════════════════════════════════════════════════════════════════════
256
+ // API KEY + TOTP AUTHENTICATION
257
+ // ═══════════════════════════════════════════════════════════════════════════
258
+
259
+ /**
260
+ * Authenticate with API key + TOTP.
261
+ * @param {object} credentials - { instanceId, apiKey, totpCode, timestamp, nonce }
262
+ * @param {object} config - App config
263
+ * @returns {object} { success, instance, error }
264
+ */
265
+ export function authenticateWithCredentials(credentials, config) {
266
+ const { instanceId, apiKey, totpCode, timestamp, nonce } = credentials;
267
+
268
+ // Validate timestamp (±30 seconds)
269
+ if (!timestamp || !isTimestampValid(timestamp)) {
270
+ return { success: false, error: 'Invalid or expired timestamp' };
271
+ }
272
+
273
+ // Check nonce hasn't been used (replay prevention)
274
+ if (nonce && !consumeNonce(nonce, instanceId)) {
275
+ return { success: false, error: 'Nonce already used (possible replay attack)' };
276
+ }
277
+
278
+ // Get instance from database
279
+ const instance = getInstance(instanceId);
280
+ if (!instance) {
281
+ return { success: false, error: 'Instance not found' };
282
+ }
283
+
284
+ // Check instance status
285
+ if (instance.status !== 'active') {
286
+ return { success: false, error: `Instance status is ${instance.status}` };
287
+ }
288
+
289
+ // Verify API key
290
+ if (!verifyApiKey(apiKey, instance.api_key_hash)) {
291
+ return { success: false, error: 'Invalid API key' };
292
+ }
293
+
294
+ // Decrypt TOTP secret if encrypted
295
+ let totpSecret = instance.totp_secret;
296
+ const masterKey = getMasterKey(config);
297
+ if (masterKey && totpSecret.includes(':')) {
298
+ try {
299
+ const { decrypt } = require('./auth.js');
300
+ totpSecret = decrypt(totpSecret, masterKey);
301
+ } catch (err) {
302
+ return { success: false, error: 'Failed to decrypt TOTP secret' };
303
+ }
304
+ }
305
+
306
+ // Verify TOTP
307
+ if (!validateTotp(totpSecret, totpCode)) {
308
+ return { success: false, error: 'Invalid TOTP code' };
309
+ }
310
+
311
+ return { success: true, instance };
312
+ }
313
+
314
+ // ═══════════════════════════════════════════════════════════════════════════
315
+ // HELPERS
316
+ // ═══════════════════════════════════════════════════════════════════════════
317
+
318
+ /**
319
+ * Get client IP from request.
320
+ */
321
+ export function getClientIp(req) {
322
+ return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
323
+ req.headers['x-real-ip'] ||
324
+ req.socket?.remoteAddress ||
325
+ 'unknown';
326
+ }
327
+
328
+ /**
329
+ * Send JSON error response.
330
+ */
331
+ function sendError(res, status, message) {
332
+ res.writeHead(status, { 'Content-Type': 'application/json' });
333
+ res.end(JSON.stringify({ error: message }));
334
+ }
335
+
336
+ /**
337
+ * Generate request ID for tracing.
338
+ */
339
+ export function generateRequestId() {
340
+ return crypto.randomBytes(8).toString('hex');
341
+ }
342
+
343
+ // ═══════════════════════════════════════════════════════════════════════════
344
+ // EXPORTS
345
+ // ═══════════════════════════════════════════════════════════════════════════
346
+
347
+ export { ROLES };
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Sync Queue Manager
3
+ *
4
+ * Manages the offline sync queue for when the hub is unreachable.
5
+ * Features:
6
+ * - Persistent queue in SQLite
7
+ * - Exponential backoff retry
8
+ * - Priority-based processing
9
+ * - Queue statistics and monitoring
10
+ */
11
+
12
+ import {
13
+ enqueueSync,
14
+ getQueuedItems,
15
+ completeQueueItem,
16
+ failQueueItem,
17
+ cleanupQueue,
18
+ getQueueStats,
19
+ } from './schema.js';
20
+
21
+ // ═══════════════════════════════════════════════════════════════════════════
22
+ // QUEUE MANAGER CLASS
23
+ // ═══════════════════════════════════════════════════════════════════════════
24
+
25
+ export class QueueManager {
26
+ constructor(options = {}) {
27
+ this.retryInterval = options.retryInterval || 60000; // 1 minute
28
+ this.maxBatchSize = options.maxBatchSize || 10;
29
+ this.cleanupDays = options.cleanupDays || 7;
30
+ this.processor = options.processor || null;
31
+
32
+ this.isProcessing = false;
33
+ this.intervalId = null;
34
+ }
35
+
36
+ /**
37
+ * Start the queue processor.
38
+ */
39
+ start() {
40
+ if (this.intervalId) return;
41
+
42
+ this.intervalId = setInterval(() => {
43
+ this.process().catch(err => {
44
+ console.error('[queue] Processing error:', err.message);
45
+ });
46
+ }, this.retryInterval);
47
+
48
+ // Process immediately
49
+ this.process().catch(() => {});
50
+
51
+ // Cleanup old items periodically (once per hour)
52
+ setInterval(() => {
53
+ this.cleanup();
54
+ }, 3600000);
55
+ }
56
+
57
+ /**
58
+ * Stop the queue processor.
59
+ */
60
+ stop() {
61
+ if (this.intervalId) {
62
+ clearInterval(this.intervalId);
63
+ this.intervalId = null;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Add an item to the queue.
69
+ */
70
+ enqueue(operation, resourceType, resourceId, payload, priority = 0) {
71
+ return enqueueSync({
72
+ operation,
73
+ resourceType,
74
+ resourceId,
75
+ payload,
76
+ priority,
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Process pending queue items.
82
+ */
83
+ async process() {
84
+ if (this.isProcessing || !this.processor) return;
85
+
86
+ this.isProcessing = true;
87
+ let processed = 0;
88
+ let failed = 0;
89
+
90
+ try {
91
+ const items = getQueuedItems(this.maxBatchSize);
92
+
93
+ for (const item of items) {
94
+ try {
95
+ const payload = JSON.parse(item.payload);
96
+ await this.processor(item.operation, payload, item);
97
+ completeQueueItem(item.id);
98
+ processed++;
99
+ } catch (err) {
100
+ failQueueItem(item.id, err.message);
101
+ failed++;
102
+ }
103
+ }
104
+
105
+ if (processed > 0 || failed > 0) {
106
+ console.log(`[queue] Processed: ${processed} succeeded, ${failed} failed`);
107
+ }
108
+ } finally {
109
+ this.isProcessing = false;
110
+ }
111
+
112
+ return { processed, failed };
113
+ }
114
+
115
+ /**
116
+ * Clean up old completed/failed items.
117
+ */
118
+ cleanup() {
119
+ const removed = cleanupQueue(this.cleanupDays);
120
+ if (removed > 0) {
121
+ console.log(`[queue] Cleaned up ${removed} old items`);
122
+ }
123
+ return removed;
124
+ }
125
+
126
+ /**
127
+ * Get queue statistics.
128
+ */
129
+ getStats() {
130
+ const rows = getQueueStats();
131
+ const stats = {
132
+ pending: 0,
133
+ completed: 0,
134
+ failed: 0,
135
+ total: 0,
136
+ };
137
+
138
+ for (const row of rows) {
139
+ stats[row.status] = row.count;
140
+ stats.total += row.count;
141
+ }
142
+
143
+ return stats;
144
+ }
145
+
146
+ /**
147
+ * Get pending items count.
148
+ */
149
+ getPendingCount() {
150
+ const stats = this.getStats();
151
+ return stats.pending;
152
+ }
153
+ }
154
+
155
+ // ═══════════════════════════════════════════════════════════════════════════
156
+ // SINGLETON INSTANCE
157
+ // ═══════════════════════════════════════════════════════════════════════════
158
+
159
+ let queueManager = null;
160
+
161
+ /**
162
+ * Get or create the queue manager singleton.
163
+ */
164
+ export function getQueueManager(options = {}) {
165
+ if (!queueManager) {
166
+ queueManager = new QueueManager(options);
167
+ }
168
+ return queueManager;
169
+ }
170
+
171
+ /**
172
+ * Reset the queue manager (for testing).
173
+ */
174
+ export function resetQueueManager() {
175
+ if (queueManager) {
176
+ queueManager.stop();
177
+ queueManager = null;
178
+ }
179
+ }
180
+
181
+ // ═══════════════════════════════════════════════════════════════════════════
182
+ // CONVENIENCE FUNCTIONS
183
+ // ═══════════════════════════════════════════════════════════════════════════
184
+
185
+ /**
186
+ * Queue a run for sync.
187
+ */
188
+ export function queueRun(project, run, testResults, screenshots = []) {
189
+ return enqueueSync({
190
+ operation: 'push_run',
191
+ resourceType: 'run',
192
+ resourceId: run.runId,
193
+ payload: { project, run, testResults, screenshots },
194
+ priority: 0,
195
+ });
196
+ }
197
+
198
+ /**
199
+ * Queue a screenshot for upload.
200
+ */
201
+ export function queueScreenshot(hash, filePath) {
202
+ return enqueueSync({
203
+ operation: 'push_screenshot',
204
+ resourceType: 'screenshot',
205
+ resourceId: hash,
206
+ payload: { hash, filePath },
207
+ priority: -1, // Lower priority than runs
208
+ });
209
+ }