@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.
- package/.claude-plugin/marketplace.json +21 -0
- package/.mcp.json +2 -2
- package/.opencode/commands/create-test.md +63 -0
- package/.opencode/commands/run.md +50 -0
- package/.opencode/commands/verify-issue.md +62 -0
- package/.opencode/skills/e2e-testing/SKILL.md +181 -0
- package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
- package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
- package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
- package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
- package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
- package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
- package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
- package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
- package/.opencode/skills/e2e-testing/references/variables.md +41 -0
- package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
- package/OPENCODE.md +166 -0
- package/README.md +581 -55
- package/agents/test-creator.md +54 -1
- package/agents/test-improver.md +37 -0
- package/bin/cli.js +408 -16
- package/commands/create-test.md +16 -1
- package/opencode.json +11 -0
- package/package.json +7 -2
- package/scripts/setup-opencode.sh +113 -0
- package/skills/e2e-testing/SKILL.md +10 -3
- package/skills/e2e-testing/references/action-types.md +48 -5
- package/skills/e2e-testing/references/auth-strategies.md +91 -0
- package/skills/e2e-testing/references/graphql.md +59 -0
- package/skills/e2e-testing/references/issue-verification.md +59 -0
- package/skills/e2e-testing/references/multi-pool.md +60 -0
- package/skills/e2e-testing/references/network-debugging.md +62 -0
- package/skills/e2e-testing/references/test-json-format.md +4 -0
- package/skills/e2e-testing/references/troubleshooting.md +44 -2
- package/skills/e2e-testing/references/variables.md +41 -0
- package/skills/e2e-testing/references/visual-verification.md +89 -0
- package/src/actions.js +324 -2
- package/src/ai-generate.js +58 -8
- package/src/config.js +143 -0
- package/src/dashboard.js +145 -13
- package/src/db.js +130 -2
- package/src/index.js +7 -6
- package/src/learner-sqlite.js +304 -0
- package/src/learner.js +8 -3
- package/src/mcp-tools.js +1121 -43
- package/src/module-resolver.js +37 -0
- package/src/narrate.js +37 -0
- package/src/pool-manager.js +223 -0
- package/src/reporter.js +82 -1
- package/src/runner.js +157 -28
- package/src/sync/auth.js +354 -0
- package/src/sync/client.js +572 -0
- package/src/sync/hub-routes.js +816 -0
- package/src/sync/index.js +68 -0
- package/src/sync/middleware.js +347 -0
- package/src/sync/queue.js +209 -0
- package/src/sync/schema.js +540 -0
- package/src/verify.js +10 -7
- package/src/watch.js +384 -0
- package/templates/build-dashboard.js +47 -6
- package/templates/dashboard/js/api.js +60 -0
- package/templates/dashboard/js/init.js +13 -0
- package/templates/dashboard/js/keyboard.js +46 -0
- package/templates/dashboard/js/state.js +40 -0
- package/templates/dashboard/js/toast.js +41 -0
- package/templates/dashboard/js/utils.js +196 -0
- package/templates/dashboard/js/view-live.js +143 -0
- package/templates/dashboard/js/view-runs.js +572 -0
- package/templates/dashboard/js/view-tests.js +294 -0
- package/templates/dashboard/js/view-watch.js +242 -0
- package/templates/dashboard/js/websocket.js +110 -0
- package/templates/dashboard/styles/base.css +69 -0
- package/templates/dashboard/styles/components.css +110 -0
- package/templates/dashboard/styles/view-live.css +74 -0
- package/templates/dashboard/styles/view-runs.css +207 -0
- package/templates/dashboard/styles/view-tests.css +96 -0
- package/templates/dashboard/styles/view-watch.css +53 -0
- package/templates/dashboard/template.html +165 -99
- package/templates/dashboard.html +1596 -541
- package/templates/sample-test.json +0 -8
- package/templates/dashboard/app.js +0 -1152
- 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
|
+
}
|