@matware/e2e-runner 1.1.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/.claude-plugin/plugin.json +9 -0
- package/.mcp.json +9 -0
- 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 +990 -296
- package/agents/test-analyzer.md +81 -0
- package/agents/test-creator.md +155 -0
- package/agents/test-improver.md +177 -0
- package/bin/cli.js +602 -22
- package/commands/create-test.md +65 -0
- package/commands/run.md +49 -0
- package/commands/verify-issue.md +63 -0
- package/opencode.json +11 -0
- package/package.json +15 -2
- package/scripts/setup-opencode.sh +113 -0
- package/skills/e2e-testing/SKILL.md +173 -0
- package/skills/e2e-testing/references/action-types.md +143 -0
- 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 +163 -0
- package/skills/e2e-testing/references/troubleshooting.md +224 -0
- package/skills/e2e-testing/references/variables.md +41 -0
- package/skills/e2e-testing/references/visual-verification.md +89 -0
- package/src/actions.js +597 -20
- package/src/ai-generate.js +142 -12
- package/src/config.js +171 -0
- package/src/dashboard.js +299 -17
- package/src/db.js +335 -13
- package/src/index.js +15 -8
- package/src/learner-markdown.js +177 -0
- package/src/learner-neo4j.js +255 -0
- package/src/learner-sqlite.js +658 -0
- package/src/learner.js +418 -0
- package/src/mcp-tools.js +1558 -50
- package/src/module-resolver.js +310 -0
- package/src/narrate.js +262 -0
- package/src/neo4j-pool.js +124 -0
- package/src/pool-manager.js +223 -0
- package/src/reporter.js +117 -3
- package/src/runner.js +274 -71
- 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 +14 -9
- package/src/watch.js +384 -0
- package/templates/build-dashboard.js +69 -0
- 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 +267 -0
- package/templates/dashboard.html +2171 -530
- package/templates/docker-compose-neo4j.yml +19 -0
- package/templates/e2e.config.js +3 -0
- package/templates/sample-test.json +0 -8
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub Routes - Sync API Endpoints
|
|
3
|
+
*
|
|
4
|
+
* Provides REST endpoints for multi-instance sync when running in hub mode.
|
|
5
|
+
*
|
|
6
|
+
* Endpoints:
|
|
7
|
+
* - POST /api/sync/register - Register new agent
|
|
8
|
+
* - POST /api/sync/auth - Authenticate and get JWT
|
|
9
|
+
* - GET /api/sync/status - Get sync status
|
|
10
|
+
* - POST /api/sync/push - Push runs from agent
|
|
11
|
+
* - GET /api/sync/pull - Pull runs from other instances
|
|
12
|
+
* - GET /api/sync/instances - List instances (admin)
|
|
13
|
+
* - PATCH /api/sync/instances/:id - Update instance (admin)
|
|
14
|
+
* - GET /api/sync/screenshots/:hash - Get screenshot
|
|
15
|
+
* - POST /api/sync/screenshots - Upload screenshot
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
generateApiKey,
|
|
20
|
+
generateTotpSecret,
|
|
21
|
+
generateTotpUri,
|
|
22
|
+
hashApiKey,
|
|
23
|
+
signJwt,
|
|
24
|
+
encrypt,
|
|
25
|
+
} from './auth.js';
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
migrateSyncSchema,
|
|
29
|
+
createInstance,
|
|
30
|
+
getInstance,
|
|
31
|
+
getInstanceById,
|
|
32
|
+
listInstances,
|
|
33
|
+
updateInstanceStatus,
|
|
34
|
+
updateInstanceLastSeen,
|
|
35
|
+
deleteInstance,
|
|
36
|
+
logAudit,
|
|
37
|
+
queryAuditLog,
|
|
38
|
+
runExists,
|
|
39
|
+
getRemoteRuns,
|
|
40
|
+
cleanupNonces,
|
|
41
|
+
} from './schema.js';
|
|
42
|
+
|
|
43
|
+
import {
|
|
44
|
+
createAuthMiddleware,
|
|
45
|
+
createRateLimitMiddleware,
|
|
46
|
+
requirePermission,
|
|
47
|
+
authenticateWithCredentials,
|
|
48
|
+
getJwtSecret,
|
|
49
|
+
getMasterKey,
|
|
50
|
+
getClientIp,
|
|
51
|
+
generateRequestId,
|
|
52
|
+
} from './middleware.js';
|
|
53
|
+
|
|
54
|
+
import { getDb, ensureProject, persistRunFromSync } from '../db.js';
|
|
55
|
+
import fs from 'fs';
|
|
56
|
+
import path from 'path';
|
|
57
|
+
import crypto from 'crypto';
|
|
58
|
+
|
|
59
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
60
|
+
// ROUTE HANDLER
|
|
61
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Handle sync API requests.
|
|
65
|
+
* @param {object} req - HTTP request
|
|
66
|
+
* @param {object} res - HTTP response
|
|
67
|
+
* @param {object} config - App config
|
|
68
|
+
* @param {string} pathname - URL pathname
|
|
69
|
+
* @returns {boolean} - true if handled, false if not a sync route
|
|
70
|
+
*/
|
|
71
|
+
export async function handleSyncRoutes(req, res, config, pathname) {
|
|
72
|
+
// Only handle /api/sync/* routes
|
|
73
|
+
if (!pathname.startsWith('/api/sync')) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const method = req.method;
|
|
78
|
+
const requestId = generateRequestId();
|
|
79
|
+
res.setHeader('X-Request-Id', requestId);
|
|
80
|
+
|
|
81
|
+
// Ensure schema is migrated
|
|
82
|
+
migrateSyncSchema();
|
|
83
|
+
|
|
84
|
+
// Apply rate limiting
|
|
85
|
+
const rateLimitMiddleware = createRateLimitMiddleware();
|
|
86
|
+
const rateLimitResult = await new Promise(resolve => {
|
|
87
|
+
rateLimitMiddleware(req, res, () => resolve(true));
|
|
88
|
+
// If middleware sent response, resolve will never be called
|
|
89
|
+
setTimeout(() => resolve(false), 0);
|
|
90
|
+
});
|
|
91
|
+
if (!rateLimitResult && res.writableEnded) return true;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
// ─── Public endpoints (no auth required) ───────────────────────────────
|
|
95
|
+
|
|
96
|
+
if (pathname === '/api/sync/register' && method === 'POST') {
|
|
97
|
+
return await handleRegister(req, res, config, requestId);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (pathname === '/api/sync/auth' && method === 'POST') {
|
|
101
|
+
return await handleAuth(req, res, config, requestId);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Protected endpoints (auth required) ───────────────────────────────
|
|
105
|
+
|
|
106
|
+
// Apply auth middleware
|
|
107
|
+
const authMiddleware = createAuthMiddleware(config);
|
|
108
|
+
const authResult = await new Promise(resolve => {
|
|
109
|
+
authMiddleware(req, res, () => resolve(true));
|
|
110
|
+
setTimeout(() => resolve(false), 0);
|
|
111
|
+
});
|
|
112
|
+
if (!authResult && res.writableEnded) return true;
|
|
113
|
+
|
|
114
|
+
// Route to handlers
|
|
115
|
+
if (pathname === '/api/sync/status' && method === 'GET') {
|
|
116
|
+
return await handleStatus(req, res, config);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (pathname === '/api/sync/push' && method === 'POST') {
|
|
120
|
+
return await handlePush(req, res, config, requestId);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (pathname === '/api/sync/pull' && method === 'GET') {
|
|
124
|
+
return await handlePull(req, res, config);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (pathname === '/api/sync/instances' && method === 'GET') {
|
|
128
|
+
// Require admin permission
|
|
129
|
+
if (!requirePermissionSync(req, res, 'instance:read')) return true;
|
|
130
|
+
return await handleListInstances(req, res, config);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const instanceMatch = pathname.match(/^\/api\/sync\/instances\/([^/]+)$/);
|
|
134
|
+
if (instanceMatch && method === 'PATCH') {
|
|
135
|
+
if (!requirePermissionSync(req, res, 'instance:write')) return true;
|
|
136
|
+
return await handleUpdateInstance(req, res, config, instanceMatch[1]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (instanceMatch && method === 'DELETE') {
|
|
140
|
+
if (!requirePermissionSync(req, res, 'instance:write')) return true;
|
|
141
|
+
return await handleDeleteInstance(req, res, config, instanceMatch[1]);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const screenshotMatch = pathname.match(/^\/api\/sync\/screenshots\/([a-f0-9]+)$/);
|
|
145
|
+
if (screenshotMatch && method === 'GET') {
|
|
146
|
+
return await handleGetScreenshot(req, res, config, screenshotMatch[1]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (pathname === '/api/sync/screenshots' && method === 'POST') {
|
|
150
|
+
return await handleUploadScreenshot(req, res, config);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (pathname === '/api/sync/audit' && method === 'GET') {
|
|
154
|
+
if (!requirePermissionSync(req, res, 'audit:read')) return true;
|
|
155
|
+
return await handleAuditLog(req, res, config);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Not found
|
|
159
|
+
jsonResponse(res, { error: 'Not found' }, 404);
|
|
160
|
+
return true;
|
|
161
|
+
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.error('[sync] Route error:', err);
|
|
164
|
+
logAudit({
|
|
165
|
+
instanceId: req.auth?.instanceId || 'unknown',
|
|
166
|
+
action: pathname,
|
|
167
|
+
status: 'error',
|
|
168
|
+
ipAddress: getClientIp(req),
|
|
169
|
+
requestId,
|
|
170
|
+
details: { error: err.message },
|
|
171
|
+
});
|
|
172
|
+
jsonResponse(res, { error: 'Internal server error' }, 500);
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
178
|
+
// ENDPOINT HANDLERS
|
|
179
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* POST /api/sync/register
|
|
183
|
+
* Register a new agent instance.
|
|
184
|
+
*/
|
|
185
|
+
async function handleRegister(req, res, config, requestId) {
|
|
186
|
+
const body = await parseJsonBody(req);
|
|
187
|
+
|
|
188
|
+
if (!body.instanceId || !body.displayName) {
|
|
189
|
+
return jsonResponse(res, { error: 'Missing instanceId or displayName' }, 400);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Check if registration is allowed
|
|
193
|
+
if (!config.sync?.hub?.allowRegistration) {
|
|
194
|
+
return jsonResponse(res, { error: 'Registration is disabled' }, 403);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Check if instance already exists
|
|
198
|
+
if (getInstance(body.instanceId)) {
|
|
199
|
+
return jsonResponse(res, { error: 'Instance ID already registered' }, 409);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Generate credentials
|
|
203
|
+
const apiKey = generateApiKey();
|
|
204
|
+
const totpSecret = generateTotpSecret();
|
|
205
|
+
|
|
206
|
+
// Encrypt TOTP secret if master key is available
|
|
207
|
+
const masterKey = getMasterKey(config);
|
|
208
|
+
const storedTotpSecret = masterKey ? encrypt(totpSecret, masterKey) : totpSecret;
|
|
209
|
+
|
|
210
|
+
// Determine initial status
|
|
211
|
+
const status = config.sync?.hub?.requireApproval ? 'pending' : 'active';
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const id = createInstance({
|
|
215
|
+
instanceId: body.instanceId,
|
|
216
|
+
displayName: body.displayName,
|
|
217
|
+
hostname: body.hostname || null,
|
|
218
|
+
environment: body.environment || 'development',
|
|
219
|
+
apiKeyHash: hashApiKey(apiKey),
|
|
220
|
+
totpSecret: storedTotpSecret,
|
|
221
|
+
role: body.role || 'member',
|
|
222
|
+
status,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
logAudit({
|
|
226
|
+
instanceId: body.instanceId,
|
|
227
|
+
action: 'instance.register',
|
|
228
|
+
status: 'success',
|
|
229
|
+
ipAddress: getClientIp(req),
|
|
230
|
+
requestId,
|
|
231
|
+
details: { displayName: body.displayName, initialStatus: status },
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Return credentials (only shown once!)
|
|
235
|
+
jsonResponse(res, {
|
|
236
|
+
success: true,
|
|
237
|
+
instance: {
|
|
238
|
+
id,
|
|
239
|
+
instanceId: body.instanceId,
|
|
240
|
+
displayName: body.displayName,
|
|
241
|
+
status,
|
|
242
|
+
},
|
|
243
|
+
credentials: {
|
|
244
|
+
apiKey,
|
|
245
|
+
totpSecret,
|
|
246
|
+
totpUri: generateTotpUri(totpSecret, body.instanceId),
|
|
247
|
+
},
|
|
248
|
+
message: status === 'pending'
|
|
249
|
+
? 'Instance registered. Waiting for admin approval.'
|
|
250
|
+
: 'Instance registered and active.',
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
} catch (err) {
|
|
254
|
+
console.error('[sync] Registration error:', err);
|
|
255
|
+
return jsonResponse(res, { error: 'Failed to register instance' }, 500);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* POST /api/sync/auth
|
|
263
|
+
* Authenticate with API key + TOTP, receive JWT.
|
|
264
|
+
*/
|
|
265
|
+
async function handleAuth(req, res, config, requestId) {
|
|
266
|
+
const body = await parseJsonBody(req);
|
|
267
|
+
|
|
268
|
+
const { instanceId, apiKey, totpCode, timestamp, nonce } = body;
|
|
269
|
+
|
|
270
|
+
if (!instanceId || !apiKey || !totpCode) {
|
|
271
|
+
return jsonResponse(res, { error: 'Missing instanceId, apiKey, or totpCode' }, 400);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const result = authenticateWithCredentials({
|
|
275
|
+
instanceId,
|
|
276
|
+
apiKey,
|
|
277
|
+
totpCode,
|
|
278
|
+
timestamp: timestamp || Date.now(),
|
|
279
|
+
nonce,
|
|
280
|
+
}, config);
|
|
281
|
+
|
|
282
|
+
if (!result.success) {
|
|
283
|
+
logAudit({
|
|
284
|
+
instanceId,
|
|
285
|
+
action: 'auth.login',
|
|
286
|
+
status: 'denied',
|
|
287
|
+
ipAddress: getClientIp(req),
|
|
288
|
+
requestId,
|
|
289
|
+
details: { error: result.error },
|
|
290
|
+
});
|
|
291
|
+
return jsonResponse(res, { error: result.error }, 401);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const instance = result.instance;
|
|
295
|
+
const jwtSecret = getJwtSecret(config);
|
|
296
|
+
|
|
297
|
+
// Generate tokens
|
|
298
|
+
const accessToken = signJwt({
|
|
299
|
+
sub: instance.instance_id,
|
|
300
|
+
role: instance.role,
|
|
301
|
+
dbId: instance.id,
|
|
302
|
+
}, jwtSecret, 3600); // 1 hour
|
|
303
|
+
|
|
304
|
+
const refreshToken = signJwt({
|
|
305
|
+
sub: instance.instance_id,
|
|
306
|
+
type: 'refresh',
|
|
307
|
+
dbId: instance.id,
|
|
308
|
+
}, jwtSecret, 86400 * 7); // 7 days
|
|
309
|
+
|
|
310
|
+
logAudit({
|
|
311
|
+
instanceId,
|
|
312
|
+
action: 'auth.login',
|
|
313
|
+
status: 'success',
|
|
314
|
+
ipAddress: getClientIp(req),
|
|
315
|
+
requestId,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// Update last seen
|
|
319
|
+
updateInstanceLastSeen(instanceId, getClientIp(req));
|
|
320
|
+
|
|
321
|
+
jsonResponse(res, {
|
|
322
|
+
accessToken,
|
|
323
|
+
refreshToken,
|
|
324
|
+
expiresIn: 3600,
|
|
325
|
+
tokenType: 'Bearer',
|
|
326
|
+
instance: {
|
|
327
|
+
instanceId: instance.instance_id,
|
|
328
|
+
displayName: instance.display_name,
|
|
329
|
+
role: instance.role,
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* GET /api/sync/status
|
|
338
|
+
* Get sync hub status.
|
|
339
|
+
*/
|
|
340
|
+
async function handleStatus(req, res, config) {
|
|
341
|
+
const instances = listInstances();
|
|
342
|
+
const activeCount = instances.filter(i => i.status === 'active').length;
|
|
343
|
+
const onlineCount = instances.filter(i => {
|
|
344
|
+
if (!i.last_seen) return false;
|
|
345
|
+
const lastSeen = new Date(i.last_seen + 'Z').getTime();
|
|
346
|
+
return Date.now() - lastSeen < 5 * 60 * 1000; // 5 minutes
|
|
347
|
+
}).length;
|
|
348
|
+
|
|
349
|
+
jsonResponse(res, {
|
|
350
|
+
mode: 'hub',
|
|
351
|
+
instances: {
|
|
352
|
+
total: instances.length,
|
|
353
|
+
active: activeCount,
|
|
354
|
+
online: onlineCount,
|
|
355
|
+
},
|
|
356
|
+
caller: {
|
|
357
|
+
instanceId: req.auth.instanceId,
|
|
358
|
+
role: req.auth.role,
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* POST /api/sync/push
|
|
367
|
+
* Push runs from an agent.
|
|
368
|
+
*/
|
|
369
|
+
async function handlePush(req, res, config, requestId) {
|
|
370
|
+
const body = await parseJsonBody(req);
|
|
371
|
+
|
|
372
|
+
const { project, runs, testResults, screenshots } = body;
|
|
373
|
+
|
|
374
|
+
if (!project || !runs || !Array.isArray(runs)) {
|
|
375
|
+
return jsonResponse(res, { error: 'Missing project or runs' }, 400);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const instanceDbId = req.auth.instanceDbId;
|
|
379
|
+
const db = getDb();
|
|
380
|
+
const syncedRuns = [];
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
// Ensure project exists
|
|
384
|
+
const projectId = ensureProject(
|
|
385
|
+
`sync:${req.auth.instanceId}:${project.slug || project.name}`,
|
|
386
|
+
project.name,
|
|
387
|
+
null, // screenshotsDir
|
|
388
|
+
null // testsDir
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
// Process runs
|
|
392
|
+
for (const run of runs) {
|
|
393
|
+
// Check for duplicates
|
|
394
|
+
if (runExists(instanceDbId, run.runId)) {
|
|
395
|
+
continue; // Skip duplicate
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Insert run
|
|
399
|
+
const runDbId = persistRunFromSync({
|
|
400
|
+
projectId,
|
|
401
|
+
runId: run.runId,
|
|
402
|
+
total: run.total,
|
|
403
|
+
passed: run.passed,
|
|
404
|
+
failed: run.failed,
|
|
405
|
+
passRate: run.passRate,
|
|
406
|
+
duration: run.duration,
|
|
407
|
+
generatedAt: run.generatedAt,
|
|
408
|
+
suiteName: run.suiteName,
|
|
409
|
+
triggeredBy: run.triggeredBy,
|
|
410
|
+
syncInstanceId: instanceDbId,
|
|
411
|
+
syncOrigin: 'remote',
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// Insert test results
|
|
415
|
+
if (testResults && Array.isArray(testResults)) {
|
|
416
|
+
const runResults = testResults.filter(tr => tr.runId === run.runId);
|
|
417
|
+
for (const result of runResults) {
|
|
418
|
+
db.prepare(`
|
|
419
|
+
INSERT INTO test_results (run_id, name, success, error, duration_ms, attempt, max_attempts, error_screenshot, console_logs, network_errors)
|
|
420
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
421
|
+
`).run(
|
|
422
|
+
runDbId,
|
|
423
|
+
result.name,
|
|
424
|
+
result.success ? 1 : 0,
|
|
425
|
+
result.error,
|
|
426
|
+
result.durationMs,
|
|
427
|
+
result.attempt || 1,
|
|
428
|
+
result.maxAttempts || 1,
|
|
429
|
+
result.errorScreenshot,
|
|
430
|
+
result.consoleLogs ? JSON.stringify(result.consoleLogs) : null,
|
|
431
|
+
result.networkErrors ? JSON.stringify(result.networkErrors) : null
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
syncedRuns.push({ runId: run.runId, dbId: runDbId });
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Handle screenshots
|
|
440
|
+
if (screenshots && Array.isArray(screenshots)) {
|
|
441
|
+
for (const ss of screenshots) {
|
|
442
|
+
if (ss.hash && ss.data) {
|
|
443
|
+
// Store screenshot
|
|
444
|
+
const screenshotsDir = config.screenshotsDir || path.join(process.env.HOME, '.e2e-runner', 'screenshots');
|
|
445
|
+
if (!fs.existsSync(screenshotsDir)) {
|
|
446
|
+
fs.mkdirSync(screenshotsDir, { recursive: true });
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const ssPath = path.join(screenshotsDir, `${ss.hash}.png`);
|
|
450
|
+
if (!fs.existsSync(ssPath)) {
|
|
451
|
+
const buffer = Buffer.from(ss.data, 'base64');
|
|
452
|
+
fs.writeFileSync(ssPath, buffer);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Record in sync_screenshots
|
|
456
|
+
db.prepare(`
|
|
457
|
+
INSERT OR IGNORE INTO sync_screenshots (hash, instance_id, storage_type, cached_path, size_bytes)
|
|
458
|
+
VALUES (?, ?, 'cached', ?, ?)
|
|
459
|
+
`).run(ss.hash, instanceDbId, ssPath, ss.data.length);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
logAudit({
|
|
465
|
+
instanceId: req.auth.instanceId,
|
|
466
|
+
action: 'sync.push',
|
|
467
|
+
status: 'success',
|
|
468
|
+
ipAddress: getClientIp(req),
|
|
469
|
+
requestId,
|
|
470
|
+
details: { runsCount: syncedRuns.length, project: project.name },
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
jsonResponse(res, {
|
|
474
|
+
success: true,
|
|
475
|
+
synced: syncedRuns.length,
|
|
476
|
+
runs: syncedRuns,
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
} catch (err) {
|
|
480
|
+
console.error('[sync] Push error:', err);
|
|
481
|
+
logAudit({
|
|
482
|
+
instanceId: req.auth.instanceId,
|
|
483
|
+
action: 'sync.push',
|
|
484
|
+
status: 'error',
|
|
485
|
+
ipAddress: getClientIp(req),
|
|
486
|
+
requestId,
|
|
487
|
+
details: { error: err.message },
|
|
488
|
+
});
|
|
489
|
+
return jsonResponse(res, { error: 'Push failed: ' + err.message }, 500);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* GET /api/sync/pull
|
|
497
|
+
* Pull runs from other instances.
|
|
498
|
+
*/
|
|
499
|
+
async function handlePull(req, res, config) {
|
|
500
|
+
const url = new URL(req.url, 'http://localhost');
|
|
501
|
+
const since = url.searchParams.get('since');
|
|
502
|
+
const projectSlug = url.searchParams.get('project');
|
|
503
|
+
const limit = parseInt(url.searchParams.get('limit') || '50');
|
|
504
|
+
|
|
505
|
+
const db = getDb();
|
|
506
|
+
let query = `
|
|
507
|
+
SELECT r.*, p.name as project_name, si.instance_id as source_instance, si.display_name as source_display_name
|
|
508
|
+
FROM runs r
|
|
509
|
+
JOIN projects p ON r.project_id = p.id
|
|
510
|
+
LEFT JOIN sync_instances si ON r.sync_instance_id = si.id
|
|
511
|
+
WHERE r.sync_instance_id != ? OR r.sync_instance_id IS NULL
|
|
512
|
+
`;
|
|
513
|
+
const params = [req.auth.instanceDbId];
|
|
514
|
+
|
|
515
|
+
if (since) {
|
|
516
|
+
query += ` AND r.generated_at > ?`;
|
|
517
|
+
params.push(since);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (projectSlug) {
|
|
521
|
+
query += ` AND p.name LIKE ?`;
|
|
522
|
+
params.push(`%${projectSlug}%`);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
query += ` ORDER BY r.generated_at DESC LIMIT ?`;
|
|
526
|
+
params.push(limit);
|
|
527
|
+
|
|
528
|
+
const runs = db.prepare(query).all(...params);
|
|
529
|
+
|
|
530
|
+
// Get test results for each run
|
|
531
|
+
const runsWithResults = runs.map(run => {
|
|
532
|
+
const testResults = db.prepare(`
|
|
533
|
+
SELECT * FROM test_results WHERE run_id = ?
|
|
534
|
+
`).all(run.id);
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
...run,
|
|
538
|
+
testResults,
|
|
539
|
+
};
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
jsonResponse(res, {
|
|
543
|
+
runs: runsWithResults,
|
|
544
|
+
count: runsWithResults.length,
|
|
545
|
+
since,
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
return true;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* GET /api/sync/instances
|
|
553
|
+
* List all registered instances.
|
|
554
|
+
*/
|
|
555
|
+
async function handleListInstances(req, res, config) {
|
|
556
|
+
const url = new URL(req.url, 'http://localhost');
|
|
557
|
+
const status = url.searchParams.get('status');
|
|
558
|
+
|
|
559
|
+
const instances = listInstances(status).map(i => ({
|
|
560
|
+
id: i.id,
|
|
561
|
+
instanceId: i.instance_id,
|
|
562
|
+
displayName: i.display_name,
|
|
563
|
+
hostname: i.hostname,
|
|
564
|
+
environment: i.environment,
|
|
565
|
+
role: i.role,
|
|
566
|
+
status: i.status,
|
|
567
|
+
lastSeen: i.last_seen,
|
|
568
|
+
lastIp: i.last_ip,
|
|
569
|
+
createdAt: i.created_at,
|
|
570
|
+
approvedAt: i.approved_at,
|
|
571
|
+
}));
|
|
572
|
+
|
|
573
|
+
jsonResponse(res, { instances });
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* PATCH /api/sync/instances/:id
|
|
579
|
+
* Update instance status/role.
|
|
580
|
+
*/
|
|
581
|
+
async function handleUpdateInstance(req, res, config, instanceId) {
|
|
582
|
+
const body = await parseJsonBody(req);
|
|
583
|
+
const instance = getInstance(instanceId);
|
|
584
|
+
|
|
585
|
+
if (!instance) {
|
|
586
|
+
return jsonResponse(res, { error: 'Instance not found' }, 404);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const db = getDb();
|
|
590
|
+
const updates = [];
|
|
591
|
+
const params = [];
|
|
592
|
+
|
|
593
|
+
if (body.status && ['pending', 'active', 'suspended'].includes(body.status)) {
|
|
594
|
+
updates.push('status = ?');
|
|
595
|
+
params.push(body.status);
|
|
596
|
+
|
|
597
|
+
if (body.status === 'active' && instance.status === 'pending') {
|
|
598
|
+
updates.push('approved_at = datetime("now")');
|
|
599
|
+
updates.push('approved_by = ?');
|
|
600
|
+
params.push(req.auth.instanceDbId);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (body.role && ['admin', 'member', 'readonly'].includes(body.role)) {
|
|
605
|
+
updates.push('role = ?');
|
|
606
|
+
params.push(body.role);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (body.displayName) {
|
|
610
|
+
updates.push('display_name = ?');
|
|
611
|
+
params.push(body.displayName);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (updates.length === 0) {
|
|
615
|
+
return jsonResponse(res, { error: 'No valid updates provided' }, 400);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
params.push(instanceId);
|
|
619
|
+
db.prepare(`UPDATE sync_instances SET ${updates.join(', ')} WHERE instance_id = ?`).run(...params);
|
|
620
|
+
|
|
621
|
+
logAudit({
|
|
622
|
+
instanceId: req.auth.instanceId,
|
|
623
|
+
action: 'instance.update',
|
|
624
|
+
resourceType: 'instance',
|
|
625
|
+
resourceId: instanceId,
|
|
626
|
+
status: 'success',
|
|
627
|
+
ipAddress: getClientIp(req),
|
|
628
|
+
details: body,
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
jsonResponse(res, { success: true, updated: instanceId });
|
|
632
|
+
return true;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* DELETE /api/sync/instances/:id
|
|
637
|
+
* Delete an instance.
|
|
638
|
+
*/
|
|
639
|
+
async function handleDeleteInstance(req, res, config, instanceId) {
|
|
640
|
+
if (instanceId === req.auth.instanceId) {
|
|
641
|
+
return jsonResponse(res, { error: 'Cannot delete yourself' }, 400);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const deleted = deleteInstance(instanceId);
|
|
645
|
+
|
|
646
|
+
if (!deleted) {
|
|
647
|
+
return jsonResponse(res, { error: 'Instance not found' }, 404);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
logAudit({
|
|
651
|
+
instanceId: req.auth.instanceId,
|
|
652
|
+
action: 'instance.delete',
|
|
653
|
+
resourceType: 'instance',
|
|
654
|
+
resourceId: instanceId,
|
|
655
|
+
status: 'success',
|
|
656
|
+
ipAddress: getClientIp(req),
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
jsonResponse(res, { success: true, deleted: instanceId });
|
|
660
|
+
return true;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* GET /api/sync/screenshots/:hash
|
|
665
|
+
* Get a screenshot by hash.
|
|
666
|
+
*/
|
|
667
|
+
async function handleGetScreenshot(req, res, config, hash) {
|
|
668
|
+
const db = getDb();
|
|
669
|
+
|
|
670
|
+
// Check sync_screenshots first
|
|
671
|
+
const syncSs = db.prepare('SELECT * FROM sync_screenshots WHERE hash = ?').get(hash);
|
|
672
|
+
if (syncSs && syncSs.cached_path && fs.existsSync(syncSs.cached_path)) {
|
|
673
|
+
res.writeHead(200, { 'Content-Type': 'image/png' });
|
|
674
|
+
fs.createReadStream(syncSs.cached_path).pipe(res);
|
|
675
|
+
return true;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Check local screenshot_hashes
|
|
679
|
+
const localSs = db.prepare('SELECT * FROM screenshot_hashes WHERE hash = ?').get(hash);
|
|
680
|
+
if (localSs && fs.existsSync(localSs.file_path)) {
|
|
681
|
+
res.writeHead(200, { 'Content-Type': 'image/png' });
|
|
682
|
+
fs.createReadStream(localSs.file_path).pipe(res);
|
|
683
|
+
return true;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
jsonResponse(res, { error: 'Screenshot not found' }, 404);
|
|
687
|
+
return true;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* POST /api/sync/screenshots
|
|
692
|
+
* Upload a screenshot.
|
|
693
|
+
*/
|
|
694
|
+
async function handleUploadScreenshot(req, res, config) {
|
|
695
|
+
const body = await parseJsonBody(req);
|
|
696
|
+
|
|
697
|
+
if (!body.hash || !body.data) {
|
|
698
|
+
return jsonResponse(res, { error: 'Missing hash or data' }, 400);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const screenshotsDir = config.screenshotsDir || path.join(process.env.HOME, '.e2e-runner', 'screenshots');
|
|
702
|
+
if (!fs.existsSync(screenshotsDir)) {
|
|
703
|
+
fs.mkdirSync(screenshotsDir, { recursive: true });
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const ssPath = path.join(screenshotsDir, `${body.hash}.png`);
|
|
707
|
+
const buffer = Buffer.from(body.data, 'base64');
|
|
708
|
+
|
|
709
|
+
// Verify hash
|
|
710
|
+
const actualHash = crypto.createHash('sha256').update(buffer).digest('hex').slice(0, 8);
|
|
711
|
+
if (actualHash !== body.hash) {
|
|
712
|
+
return jsonResponse(res, { error: 'Hash mismatch' }, 400);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
fs.writeFileSync(ssPath, buffer);
|
|
716
|
+
|
|
717
|
+
const db = getDb();
|
|
718
|
+
db.prepare(`
|
|
719
|
+
INSERT OR IGNORE INTO sync_screenshots (hash, instance_id, storage_type, cached_path, size_bytes)
|
|
720
|
+
VALUES (?, ?, 'cached', ?, ?)
|
|
721
|
+
`).run(body.hash, req.auth.instanceDbId, ssPath, buffer.length);
|
|
722
|
+
|
|
723
|
+
jsonResponse(res, { success: true, hash: body.hash });
|
|
724
|
+
return true;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* GET /api/sync/audit
|
|
729
|
+
* Query audit log.
|
|
730
|
+
*/
|
|
731
|
+
async function handleAuditLog(req, res, config) {
|
|
732
|
+
const url = new URL(req.url, 'http://localhost');
|
|
733
|
+
|
|
734
|
+
const logs = queryAuditLog({
|
|
735
|
+
instanceId: url.searchParams.get('instance'),
|
|
736
|
+
action: url.searchParams.get('action'),
|
|
737
|
+
status: url.searchParams.get('status'),
|
|
738
|
+
since: url.searchParams.get('since'),
|
|
739
|
+
until: url.searchParams.get('until'),
|
|
740
|
+
limit: parseInt(url.searchParams.get('limit') || '100'),
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
jsonResponse(res, { logs });
|
|
744
|
+
return true;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
748
|
+
// HELPERS
|
|
749
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Parse JSON body from request.
|
|
753
|
+
*/
|
|
754
|
+
function parseJsonBody(req) {
|
|
755
|
+
return new Promise((resolve, reject) => {
|
|
756
|
+
let body = '';
|
|
757
|
+
req.on('data', chunk => {
|
|
758
|
+
body += chunk;
|
|
759
|
+
if (body.length > 10 * 1024 * 1024) { // 10MB limit
|
|
760
|
+
reject(new Error('Body too large'));
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
req.on('end', () => {
|
|
764
|
+
try {
|
|
765
|
+
resolve(body ? JSON.parse(body) : {});
|
|
766
|
+
} catch (err) {
|
|
767
|
+
reject(new Error('Invalid JSON'));
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
req.on('error', reject);
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Send JSON response.
|
|
776
|
+
*/
|
|
777
|
+
function jsonResponse(res, data, status = 200) {
|
|
778
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
779
|
+
res.end(JSON.stringify(data));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Synchronous permission check.
|
|
784
|
+
*/
|
|
785
|
+
function requirePermissionSync(req, res, permission) {
|
|
786
|
+
const { hasPermission } = require('./middleware.js');
|
|
787
|
+
|
|
788
|
+
if (!req.auth) {
|
|
789
|
+
jsonResponse(res, { error: 'Not authenticated' }, 401);
|
|
790
|
+
return false;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (!hasPermission(req.auth.role, permission)) {
|
|
794
|
+
logAudit({
|
|
795
|
+
instanceId: req.auth.instanceId,
|
|
796
|
+
action: 'auth.authorize',
|
|
797
|
+
status: 'denied',
|
|
798
|
+
ipAddress: getClientIp(req),
|
|
799
|
+
details: { required: permission, role: req.auth.role },
|
|
800
|
+
});
|
|
801
|
+
jsonResponse(res, { error: `Permission denied: requires ${permission}` }, 403);
|
|
802
|
+
return false;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
return true;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Periodically clean up nonces (unref to not prevent process exit)
|
|
809
|
+
const nonceCleanupInterval = setInterval(() => {
|
|
810
|
+
try {
|
|
811
|
+
cleanupNonces();
|
|
812
|
+
} catch {
|
|
813
|
+
// Ignore errors during cleanup
|
|
814
|
+
}
|
|
815
|
+
}, 60000);
|
|
816
|
+
nonceCleanupInterval.unref();
|