skopix 2.0.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.
@@ -0,0 +1,3524 @@
1
+ import chalk from 'chalk';
2
+ import http from 'http';
3
+ import path from 'path';
4
+ import fs from 'fs-extra';
5
+ import yaml from 'yaml';
6
+ import { spawn } from 'child_process';
7
+ import { fileURLToPath } from 'url';
8
+ import open from 'open';
9
+ import os from 'os';
10
+ import crypto from 'crypto';
11
+ import { loadIssueStore, saveIssueStore } from '../../core/tracker.js';
12
+
13
+ // Team mode imports - loaded lazily (only used when SKOPIX_TEAM_MODE=true)
14
+ let teamMode = null; // populated below if team mode is active
15
+
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ const SAVED_TESTS_FILE = '_saved.suite.yaml';
18
+
19
+ export async function dashboardCommand(options) {
20
+ const port = parseInt(options.port) || 9000;
21
+ const host = options.host || process.env.SKOPIX_HOST || '127.0.0.1';
22
+ const reportsDir = path.resolve(options.dir || './skopix-reports');
23
+ const webRoot = path.resolve(__dirname, '..', '..', 'web');
24
+ const suitesDir = path.resolve(process.cwd());
25
+ const suiteRunsDir = path.join(reportsDir, '.suite-runs');
26
+
27
+ await fs.ensureDir(reportsDir);
28
+ await fs.ensureDir(suiteRunsDir);
29
+ const activeRuns = new Map();
30
+ const activeRecordings = new Map();
31
+
32
+ // ── AGENT REGISTRY ────────────────────────────────────────────────────────
33
+ // Tracks connected skopix agents (teammates' machines that do browser work)
34
+ const agents = new Map(); // agentId -> { id, name, machine, userId, ws, status, currentJob, connectedAt }
35
+
36
+ function getAvailableAgents() {
37
+ return [...agents.values()].filter(a => a.status === 'idle' && a.ws.readyState === 1);
38
+ }
39
+
40
+ function getLeastBusyAgent(preferUserId) {
41
+ const available = getAvailableAgents();
42
+ if (!available.length) return null;
43
+ // Prefer the agent belonging to the requesting user
44
+ const mine = available.find(a => a.userId === preferUserId);
45
+ if (mine) return mine;
46
+ return available[0];
47
+ }
48
+
49
+ const agentSseListeners = new Set(); // SSE clients listening for agent updates
50
+
51
+ function broadcastAgentList() {
52
+ const list = [...agents.values()].map(a => ({
53
+ id: a.id, name: a.name, machine: a.machine, userId: a.userId,
54
+ status: a.status, currentJob: a.currentJob || null, connectedAt: a.connectedAt,
55
+ }));
56
+ agentSseListeners.forEach(fn => { try { fn(list); } catch {} });
57
+ }
58
+
59
+ function sendToAgent(agent, msg) {
60
+ try { agent.ws.send(JSON.stringify(msg)); return true; } catch { return false; }
61
+ }
62
+
63
+ // ─── TEAM MODE INIT (opt-in via env var or flag) ────────────────────────
64
+ // Single-user mode (default): skip all DB/auth setup, behave exactly as before.
65
+ // Team mode: initialise SQLite, enable /setup wizard and /api/auth routes.
66
+ const isTeamMode = options.team === true || process.env.SKOPIX_TEAM_MODE === 'true' || process.env.SKOPIX_TEAM_MODE === '1';
67
+ if (isTeamMode) {
68
+ try {
69
+ const dbModule = await import('../../core/db.js');
70
+ const authModule = await import('../../core/auth.js');
71
+ await dbModule.initDb();
72
+ teamMode = { db: dbModule, auth: authModule };
73
+ console.log(chalk.cyan(' ◆ Team mode enabled'));
74
+ } catch (err) {
75
+ console.error(chalk.red(' ✖ Failed to enable team mode: ') + err.message);
76
+ console.error(chalk.dim(' Falling back to single-user mode'));
77
+ teamMode = null;
78
+ }
79
+ }
80
+
81
+ const server = http.createServer(async (req, res) => {
82
+ try {
83
+ const url = new URL(req.url, `http://localhost:${port}`);
84
+ const pathname = url.pathname;
85
+ const method = req.method;
86
+
87
+ res.setHeader('Access-Control-Allow-Origin', '*');
88
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
89
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
90
+
91
+ if (method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
92
+
93
+ // ─── TEAM MODE: SETUP WIZARD ───────────────────────────────────────
94
+ // Only active when team mode is on. In single-user mode these routes
95
+ // simply don't match and execution falls through to existing logic.
96
+ if (teamMode) {
97
+ // Status endpoint - tells the frontend if setup is needed
98
+ if (pathname === '/api/team/status' && method === 'GET') {
99
+ sendJSON(res, 200, {
100
+ teamMode: true,
101
+ needsSetup: !teamMode.db.hasAnyAdmin(),
102
+ });
103
+ return;
104
+ }
105
+
106
+ // First-run setup: creates the initial admin
107
+ if (pathname === '/api/setup' && method === 'POST') {
108
+ if (teamMode.db.hasAnyAdmin()) {
109
+ sendJSON(res, 403, { error: 'Setup already complete' });
110
+ return;
111
+ }
112
+ try {
113
+ const body = await readBody(req);
114
+ const { email, name, password } = JSON.parse(body);
115
+ if (!teamMode.auth.isValidEmail(email)) {
116
+ sendJSON(res, 400, { error: 'Invalid email address' });
117
+ return;
118
+ }
119
+ if (!name || typeof name !== 'string' || name.trim().length === 0) {
120
+ sendJSON(res, 400, { error: 'Name is required' });
121
+ return;
122
+ }
123
+ if (typeof password !== 'string' || password.length < 8) {
124
+ sendJSON(res, 400, { error: 'Password must be at least 8 characters' });
125
+ return;
126
+ }
127
+ const passwordHash = await teamMode.auth.hashPassword(password);
128
+ const userId = teamMode.auth.generateUserId();
129
+ const user = teamMode.db.createUser({
130
+ id: userId,
131
+ email: email.trim().toLowerCase(),
132
+ name: name.trim(),
133
+ passwordHash,
134
+ role: 'admin',
135
+ });
136
+ teamMode.db.logAudit({
137
+ userId: user.id,
138
+ action: 'user.created',
139
+ targetType: 'user',
140
+ targetId: user.id,
141
+ metadata: { role: 'admin', via: 'setup-wizard' },
142
+ });
143
+ sendJSON(res, 200, {
144
+ ok: true,
145
+ user: { id: user.id, email: user.email, name: user.name, role: user.role },
146
+ });
147
+ } catch (err) {
148
+ sendJSON(res, 500, { error: err.message });
149
+ }
150
+ return;
151
+ }
152
+
153
+ // ─── AUTH: LOGIN ─────────────────────────────────────────────────
154
+ if (pathname === '/api/auth/login' && method === 'POST') {
155
+ try {
156
+ const body = await readBody(req);
157
+ const { email, password } = JSON.parse(body);
158
+ if (typeof email !== 'string' || typeof password !== 'string') {
159
+ sendJSON(res, 400, { error: 'Email and password are required' });
160
+ return;
161
+ }
162
+ const user = teamMode.db.getUserByEmail(email.trim().toLowerCase());
163
+ // Use generic error to avoid leaking which emails are registered
164
+ const fail = () => sendJSON(res, 401, { error: 'Invalid email or password' });
165
+ if (!user) { fail(); return; }
166
+ if (user.status !== 'active') {
167
+ sendJSON(res, 403, { error: 'Account is disabled. Contact your admin.' });
168
+ return;
169
+ }
170
+ const ok = await teamMode.auth.verifyPassword(password, user.password_hash);
171
+ if (!ok) { fail(); return; }
172
+
173
+ // Create session
174
+ const token = teamMode.auth.generateSessionToken();
175
+ const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); // 30 days
176
+ teamMode.db.createWebSession({
177
+ token,
178
+ userId: user.id,
179
+ expiresAt,
180
+ ipAddress: req.socket.remoteAddress,
181
+ userAgent: req.headers['user-agent'] || null,
182
+ });
183
+ teamMode.db.updateUserLastLogin(user.id);
184
+ teamMode.db.logAudit({
185
+ userId: user.id,
186
+ action: 'user.login',
187
+ targetType: 'user',
188
+ targetId: user.id,
189
+ });
190
+
191
+ // Set HTTP-only cookie
192
+ // Note: SameSite=Lax allows the cookie to flow on same-site navigations.
193
+ // We don't set Secure unless the request was HTTPS (so localhost still works).
194
+ const isHttps = req.headers['x-forwarded-proto'] === 'https' || req.connection.encrypted === true;
195
+ const cookieAttrs = [
196
+ `skopix_session=${token}`,
197
+ 'Path=/',
198
+ 'HttpOnly',
199
+ 'SameSite=Lax',
200
+ `Max-Age=${30 * 24 * 60 * 60}`,
201
+ ...(isHttps ? ['Secure'] : []),
202
+ ];
203
+ res.setHeader('Set-Cookie', cookieAttrs.join('; '));
204
+ sendJSON(res, 200, {
205
+ ok: true,
206
+ user: { id: user.id, email: user.email, name: user.name, role: user.role },
207
+ });
208
+ } catch (err) {
209
+ sendJSON(res, 500, { error: err.message });
210
+ }
211
+ return;
212
+ }
213
+
214
+ // ─── AUTH: LOGOUT ────────────────────────────────────────────────
215
+ if (pathname === '/api/auth/logout' && method === 'POST') {
216
+ const token = parseCookie(req.headers.cookie || '', 'skopix_session');
217
+ if (token) {
218
+ const session = teamMode.db.getWebSession(token);
219
+ if (session) {
220
+ teamMode.db.logAudit({
221
+ userId: session.user_id,
222
+ action: 'user.logout',
223
+ targetType: 'user',
224
+ targetId: session.user_id,
225
+ });
226
+ }
227
+ teamMode.db.deleteWebSession(token);
228
+ }
229
+ // Clear the cookie
230
+ res.setHeader('Set-Cookie', 'skopix_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
231
+ sendJSON(res, 200, { ok: true });
232
+ return;
233
+ }
234
+
235
+ // ─── AUTH: ME (whoami) ───────────────────────────────────────────
236
+ if (pathname === '/api/auth/me' && method === 'GET') {
237
+ const token = parseCookie(req.headers.cookie || '', 'skopix_session');
238
+ if (!token) {
239
+ sendJSON(res, 401, { error: 'Not authenticated' });
240
+ return;
241
+ }
242
+ const session = teamMode.db.getWebSession(token);
243
+ if (!session) {
244
+ // Token invalid or expired - clear cookie too
245
+ res.setHeader('Set-Cookie', 'skopix_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
246
+ sendJSON(res, 401, { error: 'Session expired' });
247
+ return;
248
+ }
249
+ const user = teamMode.db.getUserById(session.user_id);
250
+ if (!user || user.status !== 'active') {
251
+ teamMode.db.deleteWebSession(token);
252
+ res.setHeader('Set-Cookie', 'skopix_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
253
+ sendJSON(res, 401, { error: 'Account no longer active' });
254
+ return;
255
+ }
256
+ // Refresh last_used_at so active sessions don't expire prematurely
257
+ teamMode.db.touchWebSession(token);
258
+ sendJSON(res, 200, {
259
+ user: { id: user.id, email: user.email, name: user.name, role: user.role },
260
+ });
261
+ return;
262
+ }
263
+ }
264
+
265
+ // ─── AUTH GATE (team mode only) ────────────────────────────────────
266
+ // In team mode, every non-public route requires a valid session.
267
+ // Public routes (login, setup, status, static pages) are allowed through.
268
+ // In single-user mode this check is skipped entirely.
269
+ let currentUser = null;
270
+ if (teamMode) {
271
+ const resolved = resolveCurrentUser(req, teamMode);
272
+ if (resolved) currentUser = resolved.user;
273
+
274
+ // Special: the /app/ dashboard requires auth at the server level.
275
+ // If not authenticated, redirect to /login so the user never sees the dashboard.
276
+ // (The JS-level check is still there as a defence-in-depth fallback.)
277
+ const isAppPage = (pathname === '/app' || pathname === '/app/' || pathname === '/app/index.html');
278
+ if (isAppPage && !currentUser) {
279
+ res.writeHead(302, { Location: '/login' });
280
+ res.end();
281
+ return;
282
+ }
283
+
284
+ // For protected API routes (everything not in the public whitelist), 401.
285
+ if (!isPublicPath(pathname) && !currentUser) {
286
+ sendJSON(res, 401, { error: 'Authentication required' });
287
+ return;
288
+ }
289
+
290
+ // ─── ROLE-BASED WRITE BLOCK (viewers are read-only) ──────────────
291
+ // Block any write method for viewers. The few endpoints that viewers
292
+ // legitimately need to call (logout, change own password, etc) are
293
+ // explicitly whitelisted below.
294
+ if (currentUser && currentUser.role === 'viewer') {
295
+ const isWriteMethod = method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE';
296
+ // Allowed writes for viewers - things they need for basic account use
297
+ const viewerAllowedWrites = (
298
+ pathname === '/api/auth/logout' || // can log themselves out
299
+ pathname === '/api/user/password' || // can change own password
300
+ pathname.startsWith('/api/user/secrets') // can manage own API keys
301
+ );
302
+ if (isWriteMethod && !viewerAllowedWrites) {
303
+ sendJSON(res, 403, { error: 'Your role is read-only. Ask an admin to upgrade you to Editor to make changes.' });
304
+ return;
305
+ }
306
+ }
307
+ }
308
+
309
+ // ─── TEAM MODE: USERS & INVITES (admin only) ───────────────────────
310
+ // All these endpoints require admin role. currentUser is set by the auth gate above.
311
+ if (teamMode) {
312
+ // List all users
313
+ if (pathname === '/api/users' && method === 'GET') {
314
+ if (!currentUser || !teamMode.auth.canManageUsers(currentUser.role)) {
315
+ sendJSON(res, 403, { error: 'Admin access required' });
316
+ return;
317
+ }
318
+ // Defence in depth: sanitise output (never expose password_hash)
319
+ const users = teamMode.db.listUsers().map(u => ({
320
+ id: u.id, email: u.email, name: u.name,
321
+ role: u.role, status: u.status,
322
+ created_at: u.created_at, last_login_at: u.last_login_at,
323
+ }));
324
+ sendJSON(res, 200, users);
325
+ return;
326
+ }
327
+
328
+ // Update a user (change role or status)
329
+ if (pathname.match(/^\/api\/users\/[^/]+$/) && method === 'PATCH') {
330
+ if (!currentUser || !teamMode.auth.canManageUsers(currentUser.role)) {
331
+ sendJSON(res, 403, { error: 'Admin access required' });
332
+ return;
333
+ }
334
+ const userId = pathname.split('/')[3];
335
+ try {
336
+ const body = await readBody(req);
337
+ const { role, status } = JSON.parse(body);
338
+
339
+ const target = teamMode.db.getUserById(userId);
340
+ if (!target) { sendJSON(res, 404, { error: 'User not found' }); return; }
341
+
342
+ // Safety: can't downgrade your own role
343
+ if (role && target.id === currentUser.id && role !== currentUser.role) {
344
+ sendJSON(res, 400, { error: "You can't change your own role. Ask another admin." });
345
+ return;
346
+ }
347
+ // Safety: can't disable yourself
348
+ if (status === 'disabled' && target.id === currentUser.id) {
349
+ sendJSON(res, 400, { error: "You can't disable your own account." });
350
+ return;
351
+ }
352
+ // Safety: can't demote/disable the last active admin
353
+ if ((role && role !== 'admin' && target.role === 'admin') ||
354
+ (status === 'disabled' && target.role === 'admin')) {
355
+ if (teamMode.db.countAdmins() <= 1) {
356
+ sendJSON(res, 400, { error: 'Cannot remove the last admin. Promote someone else first.' });
357
+ return;
358
+ }
359
+ }
360
+
361
+ let updated = target;
362
+ if (role && teamMode.auth.isValidRole(role)) {
363
+ updated = teamMode.db.updateUserRole(userId, role);
364
+ teamMode.db.logAudit({
365
+ userId: currentUser.id, action: 'user.role_changed',
366
+ targetType: 'user', targetId: userId,
367
+ metadata: { from: target.role, to: role },
368
+ });
369
+ }
370
+ if (status && (status === 'active' || status === 'disabled')) {
371
+ updated = teamMode.db.updateUserStatus(userId, status);
372
+ teamMode.db.logAudit({
373
+ userId: currentUser.id, action: status === 'disabled' ? 'user.disabled' : 'user.enabled',
374
+ targetType: 'user', targetId: userId,
375
+ });
376
+ // If disabling, kill all their sessions
377
+ if (status === 'disabled') {
378
+ teamMode.db.getDb().prepare('DELETE FROM web_sessions WHERE user_id = ?').run(userId);
379
+ }
380
+ }
381
+ sendJSON(res, 200, { id: updated.id, email: updated.email, name: updated.name, role: updated.role, status: updated.status });
382
+ } catch (err) {
383
+ sendJSON(res, 500, { error: err.message });
384
+ }
385
+ return;
386
+ }
387
+
388
+ // Delete a user
389
+ if (pathname.match(/^\/api\/users\/[^/]+$/) && method === 'DELETE') {
390
+ if (!currentUser || !teamMode.auth.canManageUsers(currentUser.role)) {
391
+ sendJSON(res, 403, { error: 'Admin access required' });
392
+ return;
393
+ }
394
+ const userId = pathname.split('/')[3];
395
+ const target = teamMode.db.getUserById(userId);
396
+ if (!target) { sendJSON(res, 404, { error: 'User not found' }); return; }
397
+
398
+ // Safety rails
399
+ if (target.id === currentUser.id) {
400
+ sendJSON(res, 400, { error: "You can't remove yourself. Ask another admin." });
401
+ return;
402
+ }
403
+ if (target.role === 'admin' && teamMode.db.countAdmins() <= 1) {
404
+ sendJSON(res, 400, { error: 'Cannot remove the last admin.' });
405
+ return;
406
+ }
407
+ teamMode.db.deleteUser(userId);
408
+ teamMode.db.logAudit({
409
+ userId: currentUser.id, action: 'user.deleted',
410
+ targetType: 'user', targetId: userId,
411
+ metadata: { email: target.email, name: target.name },
412
+ });
413
+ sendJSON(res, 200, { deleted: true });
414
+ return;
415
+ }
416
+
417
+ // List pending invites
418
+ if (pathname === '/api/invites' && method === 'GET') {
419
+ if (!currentUser || !teamMode.auth.canManageUsers(currentUser.role)) {
420
+ sendJSON(res, 403, { error: 'Admin access required' });
421
+ return;
422
+ }
423
+ // Prune expired first so the list is clean
424
+ teamMode.db.pruneExpiredInvites();
425
+ sendJSON(res, 200, teamMode.db.listInvites());
426
+ return;
427
+ }
428
+
429
+ // Create an invite
430
+ if (pathname === '/api/invites' && method === 'POST') {
431
+ if (!currentUser || !teamMode.auth.canManageUsers(currentUser.role)) {
432
+ sendJSON(res, 403, { error: 'Admin access required' });
433
+ return;
434
+ }
435
+ try {
436
+ const body = await readBody(req);
437
+ const { email, role } = JSON.parse(body);
438
+ const trimmedEmail = (email || '').trim().toLowerCase();
439
+ if (!teamMode.auth.isValidEmail(trimmedEmail)) {
440
+ sendJSON(res, 400, { error: 'Invalid email address' });
441
+ return;
442
+ }
443
+ if (!teamMode.auth.isValidRole(role)) {
444
+ sendJSON(res, 400, { error: 'Invalid role. Choose admin, editor, or viewer.' });
445
+ return;
446
+ }
447
+ // Reject if a user already exists with this email
448
+ if (teamMode.db.getUserByEmail(trimmedEmail)) {
449
+ sendJSON(res, 400, { error: 'A user with that email already exists.' });
450
+ return;
451
+ }
452
+ const token = teamMode.auth.generateInviteToken();
453
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); // 7 days
454
+ const invite = teamMode.db.createInvite({
455
+ token, email: trimmedEmail, role, invitedBy: currentUser.id, expiresAt,
456
+ });
457
+ teamMode.db.logAudit({
458
+ userId: currentUser.id, action: 'invite.created',
459
+ targetType: 'invite', targetId: token,
460
+ metadata: { email: trimmedEmail, role },
461
+ });
462
+ sendJSON(res, 200, {
463
+ token: invite.token, email: invite.email, role: invite.role, expiresAt: invite.expires_at,
464
+ });
465
+ } catch (err) {
466
+ sendJSON(res, 500, { error: err.message });
467
+ }
468
+ return;
469
+ }
470
+
471
+ // Revoke an invite
472
+ if (pathname.match(/^\/api\/invites\/[^/]+$/) && method === 'DELETE') {
473
+ if (!currentUser || !teamMode.auth.canManageUsers(currentUser.role)) {
474
+ sendJSON(res, 403, { error: 'Admin access required' });
475
+ return;
476
+ }
477
+ const token = pathname.split('/')[3];
478
+ const deleted = teamMode.db.deleteInvite(token);
479
+ if (deleted) {
480
+ teamMode.db.logAudit({
481
+ userId: currentUser.id, action: 'invite.revoked',
482
+ targetType: 'invite', targetId: token,
483
+ });
484
+ }
485
+ sendJSON(res, 200, { deleted });
486
+ return;
487
+ }
488
+
489
+ // ─── AUDIT LOG (admin only) ──────────────────────────────────────
490
+ if (pathname === '/api/audit-log' && method === 'GET') {
491
+ if (!currentUser || !teamMode.auth.canManageUsers(currentUser.role)) {
492
+ sendJSON(res, 403, { error: 'Admin access required' });
493
+ return;
494
+ }
495
+ const url = new URL(req.url, `http://localhost:${port}`);
496
+ const filters = {
497
+ userId: url.searchParams.get('userId') || undefined,
498
+ action: url.searchParams.get('action') || undefined,
499
+ since: url.searchParams.get('since') || undefined,
500
+ limit: parseInt(url.searchParams.get('limit') || '100'),
501
+ };
502
+ sendJSON(res, 200, teamMode.db.listAuditLog(filters));
503
+ return;
504
+ }
505
+
506
+ // ─── ACTIVE SESSIONS (admin only) ────────────────────────────────
507
+ if (pathname === '/api/sessions/active' && method === 'GET') {
508
+ if (!currentUser || !teamMode.auth.canManageUsers(currentUser.role)) {
509
+ sendJSON(res, 403, { error: 'Admin access required' });
510
+ return;
511
+ }
512
+ const sessions = teamMode.db.listActiveSessions().map(s => ({
513
+ // Hash the token before sending so the actual session cookie isn't leaked
514
+ // even to admins. We only need the token to revoke - keep a short id instead.
515
+ shortId: s.token.slice(0, 8) + '...' + s.token.slice(-4),
516
+ tokenHash: crypto.createHash('sha256').update(s.token).digest('hex').slice(0, 16),
517
+ userId: s.userId, userName: s.userName, userEmail: s.userEmail, userRole: s.userRole,
518
+ createdAt: s.createdAt, expiresAt: s.expiresAt, lastUsedAt: s.lastUsedAt,
519
+ ipAddress: s.ipAddress, userAgent: s.userAgent,
520
+ isCurrent: s.userId === currentUser.id,
521
+ }));
522
+ sendJSON(res, 200, sessions);
523
+ return;
524
+ }
525
+
526
+ // Force logout: revoke all sessions for a user (admin only)
527
+ if (pathname.match(/^\/api\/users\/[^/]+\/sessions$/) && method === 'DELETE') {
528
+ if (!currentUser || !teamMode.auth.canManageUsers(currentUser.role)) {
529
+ sendJSON(res, 403, { error: 'Admin access required' });
530
+ return;
531
+ }
532
+ const userId = pathname.split('/')[3];
533
+ const target = teamMode.db.getUserById(userId);
534
+ if (!target) { sendJSON(res, 404, { error: 'User not found' }); return; }
535
+ if (target.id === currentUser.id) {
536
+ sendJSON(res, 400, { error: "You can't force-logout yourself. Use the logout button instead." });
537
+ return;
538
+ }
539
+ const revoked = teamMode.db.revokeAllUserSessions(userId);
540
+ teamMode.db.logAudit({
541
+ userId: currentUser.id, action: 'user.sessions_revoked',
542
+ targetType: 'user', targetId: userId,
543
+ metadata: { sessionsRevoked: revoked },
544
+ });
545
+ sendJSON(res, 200, { ok: true, revoked });
546
+ return;
547
+ }
548
+
549
+ // ─── ADMIN-GENERATED PASSWORD RESET LINK ─────────────────────────
550
+ if (pathname.match(/^\/api\/users\/[^/]+\/reset-password$/) && method === 'POST') {
551
+ if (!currentUser || !teamMode.auth.canManageUsers(currentUser.role)) {
552
+ sendJSON(res, 403, { error: 'Admin access required' });
553
+ return;
554
+ }
555
+ const userId = pathname.split('/')[3];
556
+ const target = teamMode.db.getUserById(userId);
557
+ if (!target) { sendJSON(res, 404, { error: 'User not found' }); return; }
558
+ const token = teamMode.auth.generateInviteToken();
559
+ const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); // 24h
560
+ teamMode.db.createPasswordReset({ token, userId, expiresAt });
561
+ teamMode.db.logAudit({
562
+ userId: currentUser.id, action: 'user.reset_generated',
563
+ targetType: 'user', targetId: userId,
564
+ metadata: { email: target.email },
565
+ });
566
+ sendJSON(res, 200, { token, expiresAt });
567
+ return;
568
+ }
569
+
570
+ // ─── PUBLIC INVITE ENDPOINTS (no auth needed) ──────────────────
571
+ // Get invite details (for the accept page to show context)
572
+ if (pathname.match(/^\/api\/invites\/[^/]+$/) && method === 'GET') {
573
+ const token = pathname.split('/')[3];
574
+ const invite = teamMode.db.getInvite(token);
575
+ if (!invite) { sendJSON(res, 404, { error: 'Invalid or expired invite' }); return; }
576
+ if (new Date(invite.expires_at) < new Date()) {
577
+ sendJSON(res, 410, { error: 'This invite has expired' });
578
+ return;
579
+ }
580
+ if (invite.accepted_at) {
581
+ sendJSON(res, 410, { error: 'This invite has already been used' });
582
+ return;
583
+ }
584
+ const inviter = teamMode.db.getUserById(invite.invited_by);
585
+ sendJSON(res, 200, {
586
+ email: invite.email,
587
+ role: invite.role,
588
+ invitedByName: inviter?.name || 'an admin',
589
+ expiresAt: invite.expires_at,
590
+ });
591
+ return;
592
+ }
593
+
594
+ // Accept an invite + create the account
595
+ if (pathname.match(/^\/api\/invites\/[^/]+\/accept$/) && method === 'POST') {
596
+ const token = pathname.split('/')[3];
597
+ const invite = teamMode.db.getInvite(token);
598
+ if (!invite) { sendJSON(res, 404, { error: 'Invalid invite' }); return; }
599
+ if (new Date(invite.expires_at) < new Date()) {
600
+ sendJSON(res, 410, { error: 'This invite has expired' });
601
+ return;
602
+ }
603
+ if (invite.accepted_at) {
604
+ sendJSON(res, 410, { error: 'This invite has already been used' });
605
+ return;
606
+ }
607
+ try {
608
+ const body = await readBody(req);
609
+ const { name, password } = JSON.parse(body);
610
+ if (!name || typeof name !== 'string' || name.trim().length === 0) {
611
+ sendJSON(res, 400, { error: 'Name is required' });
612
+ return;
613
+ }
614
+ if (typeof password !== 'string' || password.length < 8) {
615
+ sendJSON(res, 400, { error: 'Password must be at least 8 characters' });
616
+ return;
617
+ }
618
+ // Double-check email isn't taken (in case admin manually created since invite)
619
+ if (teamMode.db.getUserByEmail(invite.email)) {
620
+ sendJSON(res, 400, { error: 'A user with that email already exists' });
621
+ return;
622
+ }
623
+ const passwordHash = await teamMode.auth.hashPassword(password);
624
+ const userId = teamMode.auth.generateUserId();
625
+ const user = teamMode.db.createUser({
626
+ id: userId,
627
+ email: invite.email,
628
+ name: name.trim(),
629
+ passwordHash,
630
+ role: invite.role,
631
+ });
632
+ teamMode.db.markInviteAccepted(token);
633
+ teamMode.db.logAudit({
634
+ userId: user.id, action: 'user.created',
635
+ targetType: 'user', targetId: user.id,
636
+ metadata: { role: invite.role, via: 'invite', invitedBy: invite.invited_by },
637
+ });
638
+
639
+ // Auto-login: create session + cookie
640
+ const sessionToken = teamMode.auth.generateSessionToken();
641
+ const sessionExpires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
642
+ teamMode.db.createWebSession({
643
+ token: sessionToken,
644
+ userId: user.id,
645
+ expiresAt: sessionExpires,
646
+ ipAddress: req.socket.remoteAddress,
647
+ userAgent: req.headers['user-agent'] || null,
648
+ });
649
+ teamMode.db.updateUserLastLogin(user.id);
650
+
651
+ const isHttps = req.headers['x-forwarded-proto'] === 'https' || req.connection.encrypted === true;
652
+ const cookieAttrs = [
653
+ `skopix_session=${sessionToken}`,
654
+ 'Path=/', 'HttpOnly', 'SameSite=Lax',
655
+ `Max-Age=${30 * 24 * 60 * 60}`,
656
+ ...(isHttps ? ['Secure'] : []),
657
+ ];
658
+ res.setHeader('Set-Cookie', cookieAttrs.join('; '));
659
+ sendJSON(res, 200, {
660
+ ok: true,
661
+ user: { id: user.id, email: user.email, name: user.name, role: user.role },
662
+ });
663
+ } catch (err) {
664
+ sendJSON(res, 500, { error: err.message });
665
+ }
666
+ return;
667
+ }
668
+
669
+ // GET password reset details (public)
670
+ if (pathname.match(/^\/api\/password-reset\/[^/]+$/) && method === 'GET') {
671
+ const token = pathname.split('/')[3];
672
+ const reset = teamMode.db.getPasswordReset(token);
673
+ if (!reset) { sendJSON(res, 404, { error: 'Invalid or expired link' }); return; }
674
+ const user = teamMode.db.getUserById(reset.user_id);
675
+ if (!user) { sendJSON(res, 404, { error: 'User no longer exists' }); return; }
676
+ sendJSON(res, 200, {
677
+ email: user.email,
678
+ name: user.name,
679
+ expiresAt: reset.expires_at,
680
+ });
681
+ return;
682
+ }
683
+
684
+ // Use password reset link (public)
685
+ if (pathname.match(/^\/api\/password-reset\/[^/]+$/) && method === 'POST') {
686
+ const token = pathname.split('/')[3];
687
+ const reset = teamMode.db.getPasswordReset(token);
688
+ if (!reset) { sendJSON(res, 404, { error: 'Invalid or expired link' }); return; }
689
+ try {
690
+ const body = await readBody(req);
691
+ const { newPassword } = JSON.parse(body);
692
+ if (typeof newPassword !== 'string' || newPassword.length < 8) {
693
+ sendJSON(res, 400, { error: 'Password must be at least 8 characters' });
694
+ return;
695
+ }
696
+ const user = teamMode.db.getUserById(reset.user_id);
697
+ if (!user) { sendJSON(res, 404, { error: 'User no longer exists' }); return; }
698
+ const newHash = await teamMode.auth.hashPassword(newPassword);
699
+ teamMode.db.updateUserPassword(user.id, newHash);
700
+ teamMode.db.markPasswordResetUsed(token);
701
+ // Revoke all existing sessions so old logins force a re-login
702
+ teamMode.db.revokeAllUserSessions(user.id);
703
+ teamMode.db.logAudit({
704
+ userId: user.id, action: 'user.password_reset',
705
+ targetType: 'user', targetId: user.id,
706
+ metadata: { via: 'admin-link' },
707
+ });
708
+ sendJSON(res, 200, { ok: true });
709
+ } catch (err) {
710
+ sendJSON(res, 500, { error: err.message });
711
+ }
712
+ return;
713
+ }
714
+
715
+ // ─── USER SECRETS (per-user encrypted tokens) ────────────────────
716
+ // List secret keys this user has set (returns keys only, never values).
717
+ if (pathname === '/api/user/secrets' && method === 'GET') {
718
+ if (!currentUser) { sendJSON(res, 401, { error: 'Auth required' }); return; }
719
+ const keys = teamMode.db.getUserSecretKeys(currentUser.id);
720
+ sendJSON(res, 200, keys);
721
+ return;
722
+ }
723
+
724
+ // Set a single secret. Body: { value: '...' }. Empty value = delete.
725
+ if (pathname.match(/^\/api\/user\/secrets\/[A-Z_]+$/) && method === 'PUT') {
726
+ if (!currentUser) { sendJSON(res, 401, { error: 'Auth required' }); return; }
727
+ // Note: viewers ARE allowed to set their own secrets (they manage their own account).
728
+ // The viewer write-block above is overridden here because this endpoint only affects the user themselves.
729
+ const key = pathname.split('/').pop();
730
+ if (!teamMode.auth.isValidSecretKey(key)) {
731
+ const extraAllowedKeys = ['SKOPIX_PROVIDER', 'OLLAMA_BASE_URL', 'OLLAMA_MODEL', 'OLLAMA_HOST'];
732
+ if (!teamMode.auth.USER_SECRET_KEYS.includes(key) && !extraAllowedKeys.includes(key)) {
733
+ sendJSON(res, 400, { error: 'Unknown secret key. Allowed: ' + [...teamMode.auth.USER_SECRET_KEYS, ...extraAllowedKeys].join(', ') }); return;
734
+ }
735
+ return;
736
+ }
737
+ try {
738
+ const body = await readBody(req);
739
+ const { value } = JSON.parse(body);
740
+ if (typeof value !== 'string') { sendJSON(res, 400, { error: 'Value must be a string' }); return; }
741
+
742
+ // Empty string = delete the secret
743
+ if (value.trim() === '') {
744
+ teamMode.db.deleteUserSecret(currentUser.id, key);
745
+ teamMode.db.logAudit({
746
+ userId: currentUser.id, action: 'user.secret_deleted',
747
+ targetType: 'user_secret', targetId: key,
748
+ });
749
+ sendJSON(res, 200, { ok: true, deleted: true });
750
+ return;
751
+ }
752
+
753
+ // Encrypt and store
754
+ const encrypted = teamMode.auth.encryptSecret(value);
755
+ teamMode.db.setUserSecret(currentUser.id, key, encrypted);
756
+ teamMode.db.logAudit({
757
+ userId: currentUser.id, action: 'user.secret_set',
758
+ targetType: 'user_secret', targetId: key,
759
+ });
760
+ sendJSON(res, 200, { ok: true, key });
761
+ } catch (err) {
762
+ sendJSON(res, 500, { error: err.message });
763
+ }
764
+ return;
765
+ }
766
+
767
+ // Delete a single secret explicitly.
768
+ if (pathname.match(/^\/api\/user\/secrets\/[A-Z_]+$/) && method === 'DELETE') {
769
+ if (!currentUser) { sendJSON(res, 401, { error: 'Auth required' }); return; }
770
+ const key = pathname.split('/').pop();
771
+ if (!teamMode.auth.isValidSecretKey(key)) {
772
+ sendJSON(res, 400, { error: 'Unknown secret key' });
773
+ return;
774
+ }
775
+ teamMode.db.deleteUserSecret(currentUser.id, key);
776
+ teamMode.db.logAudit({
777
+ userId: currentUser.id, action: 'user.secret_deleted',
778
+ targetType: 'user_secret', targetId: key,
779
+ });
780
+ sendJSON(res, 200, { ok: true });
781
+ return;
782
+ }
783
+
784
+ // ─── CHANGE OWN PASSWORD ─────────────────────────────────────────
785
+ if (pathname === '/api/user/password' && method === 'POST') {
786
+ if (!currentUser) { sendJSON(res, 401, { error: 'Auth required' }); return; }
787
+ try {
788
+ const body = await readBody(req);
789
+ const { currentPassword, newPassword } = JSON.parse(body);
790
+ if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
791
+ sendJSON(res, 400, { error: 'currentPassword and newPassword are required' });
792
+ return;
793
+ }
794
+ if (newPassword.length < 8) {
795
+ sendJSON(res, 400, { error: 'New password must be at least 8 characters' });
796
+ return;
797
+ }
798
+ // Verify current password
799
+ const fresh = teamMode.db.getUserById(currentUser.id);
800
+ const ok = await teamMode.auth.verifyPassword(currentPassword, fresh.password_hash);
801
+ if (!ok) {
802
+ sendJSON(res, 401, { error: 'Current password is incorrect' });
803
+ return;
804
+ }
805
+ const newHash = await teamMode.auth.hashPassword(newPassword);
806
+ teamMode.db.updateUserPassword(currentUser.id, newHash);
807
+ teamMode.db.logAudit({
808
+ userId: currentUser.id, action: 'user.password_changed',
809
+ targetType: 'user', targetId: currentUser.id,
810
+ });
811
+ sendJSON(res, 200, { ok: true });
812
+ } catch (err) {
813
+ sendJSON(res, 500, { error: err.message });
814
+ }
815
+ return;
816
+ }
817
+ }
818
+
819
+ // ─── SESSIONS ──────────────────────────────────────────────────────
820
+ if (pathname === '/api/sessions' && method === 'GET') {
821
+ sendJSON(res, 200, await listSessions(reportsDir));
822
+ return;
823
+ }
824
+ if (pathname === '/api/stats' && method === 'GET') {
825
+ const sessions = await listSessions(reportsDir);
826
+ sendJSON(res, 200, computeStats(sessions));
827
+ return;
828
+ }
829
+ if (pathname.startsWith('/api/session/') && method === 'GET') {
830
+ const id = pathname.split('/')[3];
831
+ const data = await getSession(reportsDir, id);
832
+ if (!data) sendJSON(res, 404, { error: 'Not found' });
833
+ else sendJSON(res, 200, data);
834
+ return;
835
+ }
836
+ if (pathname.startsWith('/api/session/') && method === 'DELETE') {
837
+ const id = pathname.split('/')[3];
838
+ await deleteSession(reportsDir, id);
839
+ sendJSON(res, 200, { deleted: true });
840
+ return;
841
+ }
842
+ if (pathname === '/api/sessions' && method === 'DELETE') {
843
+ // Delete all sessions
844
+ await deleteAllSessions(reportsDir);
845
+ sendJSON(res, 200, { deleted: true });
846
+ return;
847
+ }
848
+ if (pathname === '/api/config' && method === 'GET') {
849
+ sendJSON(res, 200, await getConfig());
850
+ return;
851
+ }
852
+
853
+ // ─── RUN ───────────────────────────────────────────────────────────
854
+ // ─── RECORDER ENDPOINTS ──────────────────────────────────────────────────
855
+ // POST /api/agent/auth — agents call this to identify themselves by email/password
856
+ // Returns { userId, name } so the agent can register with the correct userId
857
+ // Uses same secret key as header auth (already validated above)
858
+ if (pathname === '/api/agent/auth' && method === 'POST') {
859
+ if (!teamMode) { sendJSON(res, 200, { userId: null, name: 'local' }); return; }
860
+ const body = await readBody(req);
861
+ let agentAuthBody = {};
862
+ try { agentAuthBody = JSON.parse(body); } catch {}
863
+ const { email, password } = agentAuthBody;
864
+ if (!email || !password) { sendJSON(res, 400, { error: 'email and password required' }); return; }
865
+ const user = teamMode.db.getUserByEmail(email.trim().toLowerCase());
866
+ if (!user) { sendJSON(res, 401, { error: 'Invalid credentials' }); return; }
867
+ const ok = await teamMode.auth.verifyPassword(password, user.password_hash);
868
+ if (!ok) { sendJSON(res, 401, { error: 'Invalid credentials' }); return; }
869
+ sendJSON(res, 200, { userId: user.id, name: user.name, email: user.email });
870
+ return;
871
+ }
872
+
873
+ // GET /api/agents — list connected agents
874
+ if (pathname === '/api/agents' && method === 'GET') {
875
+ const list = [...agents.values()].map(a => ({
876
+ id: a.id, name: a.name, machine: a.machine, userId: a.userId,
877
+ status: a.status, currentJob: a.currentJob || null, connectedAt: a.connectedAt,
878
+ }));
879
+ sendJSON(res, 200, list);
880
+ return;
881
+ }
882
+
883
+ // GET /api/agents/stream — SSE stream for live agent status updates
884
+ if (pathname === '/api/agents/stream' && method === 'GET') {
885
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*' });
886
+ // Send current list immediately
887
+ const currentList = [...agents.values()].map(a => ({ id: a.id, name: a.name, machine: a.machine, userId: a.userId, status: a.status, currentJob: a.currentJob || null, connectedAt: a.connectedAt }));
888
+ try { res.write('data: ' + JSON.stringify(currentList) + '\n\n'); } catch {}
889
+ const fn = (list) => { try { res.write('data: ' + JSON.stringify(list) + '\n\n'); } catch {} };
890
+ agentSseListeners.add(fn);
891
+ req.on('close', () => agentSseListeners.delete(fn));
892
+ return;
893
+ }
894
+
895
+ if (pathname === '/api/record/start' && method === 'POST') {
896
+ const body = await readBody(req);
897
+ const config = JSON.parse(body);
898
+ if (!config.url) { sendJSON(res, 400, { error: 'url is required' }); return; }
899
+
900
+ // In team mode, dispatch to an agent if available
901
+ const userId = currentUser?.id || null;
902
+ const agent = teamMode ? getLeastBusyAgent(userId) : null;
903
+
904
+ if (teamMode && !agent) {
905
+ sendJSON(res, 503, { error: 'No agent connected. Run "skopix agent --server ' + (req.headers.host || 'localhost:9000') + '" on your machine first.' });
906
+ return;
907
+ }
908
+
909
+ const recordingId = Math.random().toString(36).slice(2, 10);
910
+ const screenshotDir = path.join(reportsDir, '_recordings', recordingId);
911
+ await fs.ensureDir(screenshotDir);
912
+ const recording = { id: recordingId, url: config.url, status: 'recording', steps: [], output: [], listeners: [], screenshotDir, process: null };
913
+ activeRecordings.set(recordingId, recording);
914
+
915
+ if (agent) {
916
+ // Dispatch to agent — agent will open browser on teammate's machine
917
+ agent.status = 'recording';
918
+ agent.currentJob = { type: 'record', recordingId, url: config.url };
919
+ broadcastAgentList();
920
+ sendToAgent(agent, { type: 'record', recordingId, url: config.url, screenshotDir });
921
+ sendJSON(res, 200, { recordingId, agent: { id: agent.id, name: agent.name } });
922
+ return;
923
+ }
924
+ activeRecordings.set(recordingId, recording);
925
+ const broadcast = (line) => { recording.output.push(line); recording.listeners.forEach(l => l(line)); };
926
+ const recorderPath = path.resolve(__dirname, '..', '..', 'core', 'recorder.js');
927
+ const child = spawn('node', [recorderPath, config.url, recordingId, screenshotDir], { cwd: process.cwd(), env: { ...process.env }, stdio: ['pipe', 'pipe', 'pipe'] });
928
+ recording.process = child;
929
+ child.stdout.on('data', (chunk) => {
930
+ chunk.toString().split('\n').filter(Boolean).forEach(line => {
931
+ try {
932
+ const msg = JSON.parse(line);
933
+ broadcast(msg);
934
+ if (msg.type === 'step') recording.steps.push(msg.step);
935
+ else if (msg.type === 'done') { recording.steps = msg.steps; recording.status = 'stopped'; broadcast({ type: 'stopped' }); }
936
+ } catch {}
937
+ });
938
+ });
939
+ let stderrBuf = '';
940
+ child.stderr.on('data', (chunk) => {
941
+ const txt = chunk.toString();
942
+ process.stderr.write('[recorder] ' + txt);
943
+ stderrBuf += txt;
944
+ broadcast({ type: 'error', message: txt.trim().slice(0, 300) });
945
+ });
946
+ child.on('error', (err) => { broadcast({ type: 'error', message: 'Failed to start: ' + err.message }); recording.status = 'stopped'; });
947
+ child.on('close', (code) => {
948
+ if (recording.status === 'recording') recording.status = 'stopped';
949
+ broadcast({ type: 'stopped' });
950
+ setTimeout(() => activeRecordings.delete(recordingId), 300000);
951
+ });
952
+ sendJSON(res, 200, { recordingId });
953
+ return;
954
+ }
955
+
956
+ if (pathname.match(/^\/api\/record\/[^/]+\/stop$/) && method === 'POST') {
957
+ const recordingId = pathname.split('/')[3];
958
+ const recording = activeRecordings.get(recordingId);
959
+ if (!recording) { sendJSON(res, 404, { error: 'Recording not found' }); return; }
960
+ try { if (recording.process && recording.process.stdin) recording.process.stdin.write('stop\n'); } catch {}
961
+ await new Promise(resolve => {
962
+ if (recording.status === 'stopped') return resolve();
963
+ const timeout = setTimeout(resolve, 5000);
964
+ const check = setInterval(() => { if (recording.status === 'stopped') { clearInterval(check); clearTimeout(timeout); resolve(); } }, 200);
965
+ });
966
+ sendJSON(res, 200, { steps: recording.steps });
967
+ return;
968
+ }
969
+
970
+ if (pathname.match(/^\/api\/record\/[^/]+\/stream$/) && method === 'GET') {
971
+ const recordingId = pathname.split('/')[3];
972
+ const recording = activeRecordings.get(recordingId);
973
+ if (!recording) { sendJSON(res, 404, { error: 'Recording not found' }); return; }
974
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*' });
975
+ recording.output.forEach(msg => res.write('data: ' + JSON.stringify(msg) + '\n\n'));
976
+ if (recording.status === 'stopped') { res.write('data: ' + JSON.stringify({ type: 'stopped' }) + '\n\n'); res.end(); return; }
977
+ let streamEnded = false;
978
+ const listener = (msg) => {
979
+ if (streamEnded) return;
980
+ try { res.write('data: ' + JSON.stringify(msg) + '\n\n'); } catch {}
981
+ if (msg.type === 'stopped' || msg.type === 'done') { streamEnded = true; try { res.end(); } catch {} }
982
+ };
983
+ recording.listeners.push(listener);
984
+ req.on('close', () => { streamEnded = true; recording.listeners = recording.listeners.filter(l => l !== listener); });
985
+ return;
986
+ }
987
+
988
+ if (pathname === '/api/record/process' && method === 'POST') {
989
+ const body = await readBody(req);
990
+ const { steps, testName, url, provider } = JSON.parse(body);
991
+ if (!steps || !steps.length) { sendJSON(res, 400, { error: 'steps are required' }); return; }
992
+ try {
993
+ const { processRecording } = await import('../../core/llm.js');
994
+ const userEnv = await resolveUserSecretsEnv(currentUser && currentUser.id, teamMode);
995
+ Object.assign(process.env, userEnv || {});
996
+ const result = await processRecording({ steps, testName, url, provider: provider || 'gemini' });
997
+ sendJSON(res, 200, result);
998
+ } catch (err) { sendJSON(res, 500, { error: err.message }); }
999
+ return;
1000
+ }
1001
+
1002
+ if (pathname === '/api/record/save' && method === 'POST') {
1003
+ const body = await readBody(req);
1004
+ const { scope, name, url, steps, playwrightJs, playwrightTs, tags } = JSON.parse(body);
1005
+ if (!name) { sendJSON(res, 400, { error: 'name is required' }); return; }
1006
+ try {
1007
+ const testData = { name, type: 'recorded', url: url || '', steps: steps || [], playwrightJs: playwrightJs || '', playwrightTs: playwrightTs || '', tags: tags || [] };
1008
+ const result = await createTest(suitesDir, scope || 'saved', testData);
1009
+ if (teamMode && currentUser) { teamMode.db.logAudit({ userId: currentUser.id, action: 'test.created', targetType: 'test', targetId: result.id, metadata: { scope: scope || 'saved', type: 'recorded' } }); }
1010
+ sendJSON(res, 200, result);
1011
+ } catch (err) { sendJSON(res, 400, { error: err.message }); }
1012
+ return;
1013
+ }
1014
+
1015
+ // GET /api/reusable-tests - list all tests marked as reusable (for setup dropdown)
1016
+ if (pathname === '/api/reusable-tests' && method === 'GET') {
1017
+ const all = await listAllTests(suitesDir);
1018
+ const reusable = all.filter(t => t.reusable && t.type === 'recorded');
1019
+ sendJSON(res, 200, reusable.map(t => ({ id: t.id, name: t.name, scope: t.scope, stepCount: (t.steps||[]).length })));
1020
+ return;
1021
+ }
1022
+
1023
+ // POST /api/test/:scope/:id/record-from/:stepIndex
1024
+ // Replays steps 0..stepIndex-1 in a real browser, then starts recording.
1025
+ // Returns { sessionId } for streaming replay progress, then switches to
1026
+ // a recordingId for the recording phase.
1027
+ if (pathname.match(/^\/api\/test\/[^/]+\/[^/]+\/record-from\/\d+$/) && method === 'POST') {
1028
+ const parts = pathname.split('/');
1029
+ const scope = decodeURIComponent(parts[3]);
1030
+ const testId = decodeURIComponent(parts[4]);
1031
+ const stepIndex = parseInt(parts[6], 10);
1032
+ const test = await getTest(suitesDir, scope, testId);
1033
+ if (!test) { sendJSON(res, 404, { error: 'Test not found' }); return; }
1034
+ if (!test.steps || !test.steps.length) { sendJSON(res, 400, { error: 'No steps to replay' }); return; }
1035
+
1036
+ // Load setup test if this test has one — must run first to get app into right state
1037
+ let setupStepsForDebug = [];
1038
+ if (test.setup) {
1039
+ try {
1040
+ const all = await listAllTests(suitesDir);
1041
+ const setupRef = all.find(t => t.id === test.setup || t.name === test.setup);
1042
+ if (setupRef) {
1043
+ const setupTest = await getTest(suitesDir, setupRef.scope, setupRef.id);
1044
+ if (setupTest && setupTest.steps) {
1045
+ setupStepsForDebug = setupTest.steps;
1046
+ process.stderr.write('[debug] loaded setup "' + setupRef.name + '" with ' + setupStepsForDebug.length + ' steps\n');
1047
+ }
1048
+ }
1049
+ } catch (e) { process.stderr.write('[debug] setup load error: ' + e.message + '\n'); }
1050
+ }
1051
+
1052
+ // Steps to replay: setup steps first, then main test steps 0..stepIndex-1
1053
+ const replaySteps = [...setupStepsForDebug, ...test.steps.slice(0, stepIndex)];
1054
+ process.stderr.write('[debug] record-from stepIndex=' + stepIndex + ' setupSteps=' + setupStepsForDebug.length + ' testSteps=' + test.steps.slice(0, stepIndex).length + ' total=' + replaySteps.length + '\n');
1055
+ const userEnv = await resolveUserSecretsEnv(currentUser && currentUser.id, teamMode);
1056
+ const env = { ...process.env, ...(userEnv || {}) };
1057
+
1058
+ // We run this as a special replay that opens a HEADED browser,
1059
+ // replays the steps, then keeps the browser open and starts recording.
1060
+ const runId = Math.random().toString(36).slice(2, 10);
1061
+ const recordingId = Math.random().toString(36).slice(2, 10);
1062
+ const run = { id: runId, type: 'debug-replay', status: 'running', output: [], listeners: [] };
1063
+ activeRuns.set(runId, run);
1064
+ const recording = { id: recordingId, url: test.url, status: 'waiting', steps: [], output: [], listeners: [], screenshotDir: path.join(reportsDir, '_recordings', recordingId), process: null };
1065
+ activeRecordings.set(recordingId, recording);
1066
+
1067
+ const broadcast = (line) => { run.output.push(line); run.listeners.forEach(l => l(line)); };
1068
+ const broadcastRec = (line) => { recording.output.push(line); recording.listeners.forEach(l => l(line)); };
1069
+
1070
+ // Start the debug session asynchronously
1071
+ (async () => {
1072
+ const sessionDir = path.join(reportsDir, runId);
1073
+ await fs.ensureDir(sessionDir);
1074
+ await fs.ensureDir(recording.screenshotDir);
1075
+
1076
+ broadcast({ type: 'stdout', text: '' });
1077
+ broadcast({ type: 'stdout', text: ' Replaying ' + replaySteps.length + ' steps to reach debug point' + (setupStepsForDebug.length > 0 ? ' (including ' + setupStepsForDebug.length + ' setup steps)' : '') + '...' });
1078
+ broadcast({ type: 'stdout', text: '━'.repeat(60) });
1079
+
1080
+ let chromiumBrowser = null;
1081
+ let page = null;
1082
+ let ctx = null;
1083
+
1084
+ try {
1085
+ const { chromium } = await import('playwright');
1086
+
1087
+ // Always headed — user needs to see and interact with the browser
1088
+ chromiumBrowser = await chromium.launch({
1089
+ headless: false,
1090
+ args: ['--no-sandbox', '--disable-blink-features=AutomationControlled', '--allow-insecure-localhost'],
1091
+ });
1092
+
1093
+ ctx = await chromiumBrowser.newContext({
1094
+ viewport: { width: 1280, height: 800 },
1095
+ });
1096
+ page = await ctx.newPage();
1097
+
1098
+ if (test.url) {
1099
+ await page.goto(test.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
1100
+ await page.waitForTimeout(1000);
1101
+ }
1102
+
1103
+ broadcast({ type: 'stdout', text: ' Replaying ' + replaySteps.length + ' steps...' });
1104
+
1105
+ // Replay each step up to the debug point
1106
+ let stepNum = 0;
1107
+ let browserClosed = false;
1108
+
1109
+ for (const step of replaySteps) {
1110
+ if (browserClosed) break;
1111
+ stepNum++;
1112
+ const sel = sanitiseSelector(step.stableSelector || step.selector);
1113
+ const desc = step.description || (step.action + ' ' + (sel || ''));
1114
+ broadcast({ type: 'stdout', text: ' [' + stepNum + '/' + replaySteps.length + '] ' + step.action.toUpperCase() + ' — ' + desc });
1115
+
1116
+ try {
1117
+ if (step.action === 'navigate') {
1118
+ let navUrl = step.url || step.value;
1119
+ if (test.url && navUrl && navUrl.startsWith('http')) {
1120
+ try { const ro = new URL(navUrl).origin; const to = new URL(test.url).origin; if (ro !== to) navUrl = navUrl.replace(ro, to); } catch {}
1121
+ }
1122
+ if (page.url() !== navUrl) {
1123
+ try { await page.goto(navUrl, { waitUntil: 'domcontentloaded', timeout: 15000 }); await page.waitForTimeout(800); }
1124
+ catch { /* hash nav ok */ }
1125
+ }
1126
+ } else if (step.action === 'click') {
1127
+ await page.waitForTimeout(200);
1128
+ let clicked = false;
1129
+ const selectors = [step.stableSelector, step.selector].filter(Boolean);
1130
+ if (!clicked && (step.elementX || step.clickX)) {
1131
+ const tx = step.elementX||step.clickX, ty = step.elementY||step.clickY;
1132
+ for (const s of selectors) {
1133
+ if (clicked) break;
1134
+ try {
1135
+ const count = await page.locator(s).count();
1136
+ if (count > 1) {
1137
+ let bi = 0, bd = Infinity;
1138
+ for (let i = 0; i < count; i++) { try { const box = await page.locator(s).nth(i).boundingBox({timeout:2000}); if(!box) continue; const d=Math.sqrt(Math.pow(box.x+box.width/2-tx,2)+Math.pow(box.y+box.height/2-ty,2)); if(d<bd){bd=d;bi=i;} } catch {} }
1139
+ await page.locator(s).nth(bi).click({timeout:5000}); clicked=true;
1140
+ } else if (count===1) { await page.locator(s).first().waitFor({state:'visible',timeout:3000}); await page.locator(s).first().click({timeout:5000}); clicked=true; }
1141
+ } catch {}
1142
+ }
1143
+ }
1144
+ if (!clicked) { for (const s of selectors) { if(clicked) break; try { await page.locator(s).first().click({timeout:5000}); clicked=true; } catch {} } }
1145
+ if (!clicked) { for (const s of selectors) { if(clicked) break; try { await page.locator(s).first().click({force:true,timeout:5000}); clicked=true; } catch {} } }
1146
+ if (!clicked && step.element) { const tag=(step.element.tag||'').toLowerCase(); if(['i','svg','path','span'].includes(tag)) { for(const s of selectors){if(clicked)break;try{await page.locator(s).first().locator('xpath=ancestor-or-self::*[self::a or self::button or @role="button"][1]').first().click({timeout:5000});clicked=true;}catch{}} } }
1147
+ if (!clicked) throw new Error('Could not click element. Tried: ' + selectors.join(', '));
1148
+ else await page.waitForTimeout(400);
1149
+ } else if (step.action === 'type') {
1150
+ await page.locator(sel).first().click({timeout:5000});
1151
+ await page.locator(sel).first().fill('');
1152
+ await page.locator(sel).first().pressSequentially(step.value||'', {delay:50});
1153
+ await page.locator(sel).first().evaluate(el => { el.dispatchEvent(new Event('blur',{bubbles:true})); el.dispatchEvent(new Event('change',{bubbles:true})); el.dispatchEvent(new Event('focusout',{bubbles:true})); });
1154
+ await page.waitForTimeout(300);
1155
+ } else if (step.action === 'check') {
1156
+ let alreadyCorrect = false;
1157
+ try { const cur = await page.locator(sel).first().isChecked({timeout:2000}); if(cur===step.checked) alreadyCorrect=true; } catch {}
1158
+ if (!alreadyCorrect) { await page.locator(sel).first().click({timeout:10000}); await page.waitForTimeout(400); }
1159
+ } else if (step.action === 'scroll') {
1160
+ if (step.isWindow||step.selector==='window') await page.evaluate(({x,y})=>window.scrollTo({left:x,top:y,behavior:'smooth'}),{x:step.scrollX||0,y:step.scrollY||0});
1161
+ else await page.evaluate(({s,x,y})=>{const el=document.querySelector(s);if(el)el.scrollTo({left:x,top:y,behavior:'smooth'})},{s:sel,x:step.scrollX||0,y:step.scrollY||0});
1162
+ await page.waitForTimeout(500);
1163
+ }
1164
+ broadcast({ type: 'stdout', text: ' ✓ Done' });
1165
+ } catch (err) {
1166
+ const msg = err.message || '';
1167
+ if (msg.includes('closed') || msg.includes('destroyed') || msg.includes('detached') || msg.includes('crashed')) {
1168
+ browserClosed = true;
1169
+ broadcast({ type: 'stdout', text: ' ✖ Browser closed — stopping replay early' });
1170
+ process.stderr.write('[debug] browser closed at step ' + stepNum + ': ' + msg + '\n');
1171
+ } else {
1172
+ broadcast({ type: 'stdout', text: ' ⚠ Step ' + stepNum + ' skipped: ' + msg.slice(0, 100) });
1173
+ }
1174
+ }
1175
+ }
1176
+
1177
+ broadcast({ type: 'stdout', text: '' });
1178
+ broadcast({ type: 'stdout', text: '━'.repeat(60) });
1179
+ broadcast({ type: 'stdout', text: ' ✔ Reached step ' + (stepIndex + 1) + ' — browser is ready' });
1180
+ broadcast({ type: 'stdout', text: ' Now recording. Use the browser, then click Stop.' });
1181
+ broadcast({ type: 'stdout', text: '━'.repeat(60) });
1182
+
1183
+ // Inject the recorder BEFORE sending done — once done fires the
1184
+ // SSE stream closes and we can't broadcast anymore
1185
+ recording.status = 'recording';
1186
+ process.stderr.write('[debug] injecting recorder into browser\n');
1187
+
1188
+ // Expose capture function on the existing context
1189
+ await ctx.exposeFunction('__skopixCapture', async (actionData) => {
1190
+ if (recording.status !== 'recording') return;
1191
+ if (actionData.action === 'stop') {
1192
+ await stopDebugRecording(recording, ctx, chromiumBrowser, broadcastRec);
1193
+ return;
1194
+ }
1195
+ // Capture step
1196
+ const id = 'step-' + String(recording.steps.length + 1).padStart(3, '0') + '-dbg';
1197
+ const recStep = { id, action: actionData.action, assertType: actionData.assertType||null, attribute: actionData.attribute||null, selector: actionData.selector||(actionData.element?actionData.element.selector:null), element: actionData.element||null, value: actionData.value||null, isPassword: actionData.isPassword||false, label: actionData.label||null, clickX: actionData.clickX||null, clickY: actionData.clickY||null, elementX: actionData.elementX||null, elementY: actionData.elementY||null, description: actionData.description||null, url: page.url(), timestamp: Date.now(), stableSelector: null };
1198
+ recording.steps.push(recStep);
1199
+ broadcastRec({ type: 'step', step: recStep });
1200
+ // Update toolbar count
1201
+ try { await page.evaluate(n => { if(window.__skopixUpdateCount) window.__skopixUpdateCount(n); }, recording.steps.length); } catch {}
1202
+ // Screenshot
1203
+ setTimeout(async () => { try { const sp = path.join(recording.screenshotDir, id+'.png'); await page.screenshot({path:sp,fullPage:false}).catch(()=>{}); recStep.screenshotPath=sp; broadcastRec({type:'screenshot',stepId:id,path:sp}); } catch {} }, 400);
1204
+ });
1205
+
1206
+ // Add the recorder init script to the existing pages
1207
+ await ctx.addInitScript(() => {
1208
+ if (window.__skopixRecording) return;
1209
+ window.__skopixRecording = true;
1210
+ // (full recorder init script injected below)
1211
+ });
1212
+
1213
+ // Inject the toolbar and listeners into the current page
1214
+ await page.evaluate(() => {
1215
+ if (window.__skopixRecording) return;
1216
+ window.__skopixRecording = true;
1217
+
1218
+ function getSelector(el) {
1219
+ if (!el || el === document.body) return 'body';
1220
+ const testAttrs = ['data-testid','data-test','pi-test-identifier','data-cy','data-qa'];
1221
+ for (const attr of testAttrs) { const val = el.getAttribute(attr); if (val) return '['+attr+'="'+val+'"]'; }
1222
+ if (el.id && !/^\d/.test(el.id)) return '#'+el.id;
1223
+ const al = el.getAttribute('aria-label');
1224
+ if (al && ['button','a','input'].includes(el.tagName.toLowerCase())) return el.tagName.toLowerCase()+'[aria-label="'+al+'"]';
1225
+ if (el.name && el.tagName==='INPUT') return 'input[name="'+el.name+'"]';
1226
+ const parts=[]; let cur=el,depth=0;
1227
+ while(cur&&cur!==document.body&&depth<4){let seg=cur.tagName.toLowerCase();if(cur.id&&!/^\d/.test(cur.id)){parts.unshift('#'+cur.id);break;}const sib=Array.from(cur.parentElement?cur.parentElement.children:[]).filter(c=>c.tagName===cur.tagName);if(sib.length>1)seg+=':nth-of-type('+(sib.indexOf(cur)+1)+')';parts.unshift(seg);cur=cur.parentElement;depth++;}
1228
+ return parts.join(' > ');
1229
+ }
1230
+ function getElementInfo(el) { return {tag:el.tagName.toLowerCase(),id:el.id||null,name:el.name||null,type:el.type||null,text:(el.innerText||el.value||el.placeholder||el.getAttribute('aria-label')||'').trim().slice(0,80),selector:getSelector(el),classes:el.className?el.className.toString().trim().slice(0,100):null}; }
1231
+
1232
+ document.addEventListener('click', function(e) {
1233
+ if(e.target&&e.target.closest){if(e.target.closest('#__skopix_toolbar'))return;if(e.target.closest('#__skopix_popover'))return;}
1234
+ if(window.__skopixPickMode)return;
1235
+ const el=e.target;
1236
+ if(!el||el===document.body||el===document.documentElement)return;
1237
+ const rect=el.getBoundingClientRect();
1238
+ const isCheckable=el.type==='checkbox'||el.type==='radio';
1239
+ let checkTarget=null;
1240
+ if(!isCheckable&&el.tagName==='LABEL'&&el.htmlFor)checkTarget=document.getElementById(el.htmlFor);
1241
+ if(!isCheckable&&el.tagName==='LABEL'&&!el.htmlFor)checkTarget=el.querySelector('input[type="checkbox"],input[type="radio"]');
1242
+ const actualCheckable=isCheckable?el:checkTarget;
1243
+ if(actualCheckable&&(actualCheckable.type==='checkbox'||actualCheckable.type==='radio')){
1244
+ window.__skopixCapture&&window.__skopixCapture({action:'check',checked:actualCheckable.checked,element:getElementInfo(actualCheckable),clickX:Math.round(e.clientX),clickY:Math.round(e.clientY),elementX:Math.round(rect.left+rect.width/2),elementY:Math.round(rect.top+rect.height/2)});
1245
+ } else {
1246
+ window.__skopixCapture&&window.__skopixCapture({action:'click',element:getElementInfo(el),clickX:Math.round(e.clientX),clickY:Math.round(e.clientY),elementX:Math.round(rect.left+rect.width/2),elementY:Math.round(rect.top+rect.height/2)});
1247
+ }
1248
+ }, true);
1249
+
1250
+ let typeTimer=null,lastInputEl=null;
1251
+ document.addEventListener('input', function(e) {
1252
+ const el=e.target;
1253
+ if(!el||!['INPUT','TEXTAREA'].includes(el.tagName))return;
1254
+ if(el.type==='checkbox'||el.type==='radio')return;
1255
+ if(el.closest&&(el.closest('#__skopix_toolbar')||el.closest('#__skopix_popover')))return;
1256
+ lastInputEl=el;clearTimeout(typeTimer);
1257
+ typeTimer=setTimeout(()=>{if(!lastInputEl)return;window.__skopixCapture&&window.__skopixCapture({action:'type',element:getElementInfo(lastInputEl),value:lastInputEl.value,isPassword:lastInputEl.type==='password'});lastInputEl=null;},600);
1258
+ },true);
1259
+
1260
+ document.addEventListener('change', function(e) {
1261
+ const el=e.target;if(!el||el.tagName!=='SELECT')return;if(el.closest&&(el.closest('#__skopix_toolbar')||el.closest('#__skopix_popover')))return;
1262
+ const sel=el.options[el.selectedIndex];window.__skopixCapture&&window.__skopixCapture({action:'select',element:getElementInfo(el),value:el.value,label:sel?sel.text:el.value});
1263
+ },true);
1264
+
1265
+ let scrollTimer=null;
1266
+ document.addEventListener('scroll', function(e) {
1267
+ const el=e.target;if(el&&el.closest&&(el.closest('#__skopix_toolbar')||el.closest('#__skopix_popover')))return;
1268
+ clearTimeout(scrollTimer);scrollTimer=setTimeout(()=>{
1269
+ const isWin=el===document||el===document.documentElement||el===document.body;
1270
+ const sx=isWin?window.scrollX:el.scrollLeft,sy=isWin?window.scrollY:el.scrollTop;
1271
+ if(Math.abs(sy)<50&&Math.abs(sx)<50)return;
1272
+ window.__skopixCapture&&window.__skopixCapture({action:'scroll',selector:isWin?'window':getSelector(el),scrollX:Math.round(sx),scrollY:Math.round(sy),isWindow:isWin,element:isWin?null:getElementInfo(el)});
1273
+ },400);
1274
+ },true);
1275
+
1276
+ // Toolbar - full version with Assert button
1277
+ const tb=document.createElement('div');
1278
+ tb.id='__skopix_toolbar';
1279
+ tb.style.cssText='position:fixed;bottom:20px;right:20px;z-index:2147483647;background:#0f1117;border:2px solid #f59e0b;border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:10px;font-family:monospace;font-size:12px;color:#e5e7eb;box-shadow:0 4px 24px rgba(0,0,0,0.6);user-select:none';
1280
+ tb.innerHTML='<span style="color:#f59e0b;font-size:14px;animation:skopix_pulse 1s infinite">●</span>'
1281
+ +'<span style="color:#9ca3af">Debug recording</span>'
1282
+ +'<span id="__skopix_count" style="color:#22d3ee;font-weight:700;min-width:20px;text-align:center">0</span>'
1283
+ +'<span style="color:#4b5563">steps</span>'
1284
+ +'<button id="__skopix_assert_btn" style="background:#1e3a5f;border:1px solid #2563eb;color:#60a5fa;border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:monospace">+ Assert</button>'
1285
+ +'<button id="__skopix_stop_btn" style="background:#3f0d0d;border:1px solid #dc2626;color:#f87171;border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:monospace">■ Stop</button>';
1286
+ const sty=document.createElement('style');sty.textContent='@keyframes skopix_pulse{0%,100%{opacity:1}50%{opacity:0.3}}';document.head.appendChild(sty);
1287
+ document.body.appendChild(tb);
1288
+ tb.querySelector('#__skopix_stop_btn').addEventListener('click',e=>{e.stopPropagation();window.__skopixCapture&&window.__skopixCapture({action:'stop'});});
1289
+ window.__skopixUpdateCount=n=>{const el=document.getElementById('__skopix_count');if(el)el.textContent=n;};
1290
+
1291
+ // Assert button - full picker mode with popover
1292
+ tb.querySelector('#__skopix_assert_btn').addEventListener('click', function(e) {
1293
+ e.stopPropagation();
1294
+ startPickMode();
1295
+ });
1296
+
1297
+ let pickerOverlay=null;
1298
+ function startPickMode() {
1299
+ window.__skopixPickMode=true;
1300
+ document.body.style.cursor='crosshair';
1301
+ const tb2=document.getElementById('__skopix_toolbar');if(tb2)tb2.style.opacity='0.5';
1302
+ const hint=document.createElement('div');hint.id='__skopix_hint';
1303
+ hint.style.cssText='position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:2147483647;background:#1e3a5f;border:1px solid #2563eb;color:#60a5fa;padding:8px 18px;border-radius:8px;font-family:monospace;font-size:13px;pointer-events:none';
1304
+ hint.textContent='Click any element to add an assertion — Esc to cancel';
1305
+ document.body.appendChild(hint);
1306
+ pickerOverlay=document.createElement('div');
1307
+ pickerOverlay.style.cssText='position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:auto;cursor:crosshair';
1308
+ document.body.appendChild(pickerOverlay);
1309
+ const hl=document.createElement('div');
1310
+ hl.style.cssText='position:fixed;pointer-events:none;z-index:2147483645;background:rgba(37,99,235,0.15);border:2px solid #2563eb;border-radius:3px;transition:all 0.1s;display:none';
1311
+ document.body.appendChild(hl);
1312
+ pickerOverlay.addEventListener('mousemove',function(e2){pickerOverlay.style.pointerEvents='none';const el=document.elementFromPoint(e2.clientX,e2.clientY);pickerOverlay.style.pointerEvents='auto';if(!el||el===document.body||el.id==='__skopix_toolbar'){hl.style.display='none';return;}const r=el.getBoundingClientRect();hl.style.display='block';hl.style.top=r.top+'px';hl.style.left=r.left+'px';hl.style.width=r.width+'px';hl.style.height=r.height+'px';});
1313
+ pickerOverlay.addEventListener('click',function(e2){e2.preventDefault();e2.stopPropagation();pickerOverlay.style.pointerEvents='none';const el=document.elementFromPoint(e2.clientX,e2.clientY);pickerOverlay.style.pointerEvents='auto';if(!el||el===document.body){stopPickMode(hl);return;}stopPickMode(hl);showAssertionPopover(el,hl);});
1314
+ document.addEventListener('keydown',function escH(e2){if(e2.key==='Escape'){stopPickMode(hl);document.removeEventListener('keydown',escH);}});
1315
+ }
1316
+
1317
+ function stopPickMode(hl) {
1318
+ window.__skopixPickMode=false;document.body.style.cursor='';
1319
+ if(pickerOverlay){pickerOverlay.remove();pickerOverlay=null;}
1320
+ const hint=document.getElementById('__skopix_hint');if(hint)hint.remove();
1321
+ const tb2=document.getElementById('__skopix_toolbar');if(tb2)tb2.style.opacity='1';
1322
+ if(hl)hl.style.display='none';
1323
+ }
1324
+
1325
+ function showAssertionPopover(el, hl) {
1326
+ const existing=document.getElementById('__skopix_popover');if(existing)existing.remove();
1327
+ function getSelector2(el2){
1328
+ if(!el2||el2===document.body)return'body';
1329
+ const testAttrs=['data-testid','data-test','pi-test-identifier','data-cy','data-qa'];
1330
+ for(const a of testAttrs){const v=el2.getAttribute(a);if(v)return'['+a+'="'+v+'"]';}
1331
+ if(el2.id&&!/^\d/.test(el2.id))return'#'+el2.id;
1332
+ const al=el2.getAttribute('aria-label');if(al&&['button','a','input'].includes(el2.tagName.toLowerCase()))return el2.tagName.toLowerCase()+'[aria-label="'+al+'"]';
1333
+ if(el2.name&&el2.tagName==='INPUT')return'input[name="'+el2.name+'"]';
1334
+ const parts=[];let cur=el2,d=0;while(cur&&cur!==document.body&&d<4){let seg=cur.tagName.toLowerCase();if(cur.id&&!/^\d/.test(cur.id)){parts.unshift('#'+cur.id);break;}const sib=Array.from(cur.parentElement?cur.parentElement.children:[]).filter(c=>c.tagName===cur.tagName);if(sib.length>1)seg+=':nth-of-type('+(sib.indexOf(cur)+1)+')';parts.unshift(seg);cur=cur.parentElement;d++;}
1335
+ return parts.join(' > ');
1336
+ }
1337
+ const sel=getSelector2(el);
1338
+ const currentText=(el.innerText||el.textContent||'').trim().slice(0,100);
1339
+ const rect=el.getBoundingClientRect();
1340
+ let sugType='visible',sugVal='';
1341
+ if(currentText&&currentText.length>0&&currentText.length<80){sugType='text_contains';sugVal=currentText.replace(/\s+/g,' ').trim();}
1342
+ const rows=el.querySelectorAll('tr:not(thead tr),li').length;
1343
+ if(['table','tbody','ul','ol'].includes(el.tagName.toLowerCase())||rows>1){sugType='element_count';sugVal=String(rows);}
1344
+ if(hl){hl.style.background='rgba(34,197,94,0.15)';hl.style.borderColor='#22c55e';hl.style.display='block';hl.style.top=rect.top+'px';hl.style.left=rect.left+'px';hl.style.width=rect.width+'px';hl.style.height=rect.height+'px';}
1345
+ const popover=document.createElement('div');popover.id='__skopix_popover';
1346
+ const topPos=rect.bottom+8+280>window.innerHeight?Math.max(8,rect.top-280-8):rect.bottom+8;
1347
+ const leftPos=Math.min(rect.left,window.innerWidth-360);
1348
+ popover.style.cssText='position:fixed;z-index:2147483647;top:'+topPos+'px;left:'+leftPos+'px;width:350px;background:#0f1117;border:1px solid #2563eb;border-radius:10px;padding:16px;font-family:monospace;font-size:12px;color:#e5e7eb;box-shadow:0 8px 32px rgba(0,0,0,0.7)';
1349
+ popover.innerHTML='<div style="color:#60a5fa;font-size:11px;letter-spacing:0.1em;margin-bottom:12px">ADD ASSERTION</div>'
1350
+ +'<div style="margin-bottom:10px"><div style="color:#9ca3af;font-size:10px;margin-bottom:4px">SELECTED ELEMENT</div><div style="background:#1a1d2e;padding:6px 10px;border-radius:6px;color:#22d3ee;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="'+sel+'">'+sel+'</div>'+(currentText?'<div style="color:#6b7280;font-size:10px;margin-top:3px">Current text: "'+currentText.slice(0,50)+'"</div>':'')+'</div>'
1351
+ +'<div style="margin-bottom:10px"><div style="color:#9ca3af;font-size:10px;margin-bottom:4px">TYPE</div><select id="__skopix_assert_type" style="width:100%;background:#1a1d2e;border:1px solid #374151;color:#e5e7eb;padding:6px 8px;border-radius:6px;font-family:monospace;font-size:12px"><option value="visible"'+(sugType==="visible"?" selected":"")+'>Element is visible</option><option value="text_contains"'+(sugType==="text_contains"?" selected":"")+'>Text contains</option><option value="text_equals">Text equals</option><option value="url_contains">URL contains</option><option value="element_count"'+(sugType==="element_count"?" selected":"")+'>Element count</option><option value="attribute_contains"'+(sugType==="attribute_contains"?" selected":"")+'>Attribute contains (title, alt...)</option></select></div>'
1352
+ +'<div id="__skopix_attr_row" style="margin-bottom:10px;display:none"><div style="color:#9ca3af;font-size:10px;margin-bottom:4px">ATTRIBUTE NAME</div><input id="__skopix_assert_attr" type="text" value="title" placeholder="e.g. title, alt, aria-label" style="width:100%;box-sizing:border-box;background:#1a1d2e;border:1px solid #374151;color:#e5e7eb;padding:6px 8px;border-radius:6px;font-family:monospace;font-size:12px"></div>'
1353
+ +'<div id="__skopix_value_row" style="margin-bottom:10px;'+(sugType==="visible"?"display:none":"")+'"><div style="color:#9ca3af;font-size:10px;margin-bottom:4px" id="__skopix_val_lbl">EXPECTED VALUE</div><input id="__skopix_assert_value" type="text" value="'+sugVal.replace(/"/g,"&quot;")+'" style="width:100%;box-sizing:border-box;background:#1a1d2e;border:1px solid #374151;color:#e5e7eb;padding:6px 8px;border-radius:6px;font-family:monospace;font-size:12px"></div>'
1354
+ +'<div style="margin-bottom:14px"><div style="color:#9ca3af;font-size:10px;margin-bottom:4px">DESCRIPTION (optional)</div><input id="__skopix_assert_desc" type="text" placeholder="e.g. Chart is visible after save" style="width:100%;box-sizing:border-box;background:#1a1d2e;border:1px solid #374151;color:#e5e7eb;padding:6px 8px;border-radius:6px;font-family:monospace;font-size:12px"></div>'
1355
+ +'<div style="display:flex;gap:8px;justify-content:flex-end"><button id="__skopix_cancel_btn" style="background:transparent;border:1px solid #374151;color:#9ca3af;border-radius:6px;padding:6px 14px;cursor:pointer;font-family:monospace;font-size:12px">Cancel</button><button id="__skopix_add_btn" style="background:#1e3a5f;border:1px solid #2563eb;color:#60a5fa;border-radius:6px;padding:6px 14px;cursor:pointer;font-family:monospace;font-size:12px;font-weight:700">Add ✓</button></div>';
1356
+ document.body.appendChild(popover);
1357
+ const typeSelect=popover.querySelector('#__skopix_assert_type');
1358
+ const valRow=popover.querySelector('#__skopix_value_row');
1359
+ const valLabel=popover.querySelector('#__skopix_val_lbl');
1360
+ const valInput=popover.querySelector('#__skopix_assert_value');
1361
+ typeSelect.addEventListener('change',function(){const t=typeSelect.value;valRow.style.display=t==='visible'?'none':'';const attrRow=document.getElementById('__skopix_attr_row');if(attrRow)attrRow.style.display=t==='attribute_contains'?'block':'none';if(t==='element_count')valLabel.textContent='EXPECTED COUNT';else if(t==='url_contains')valLabel.textContent='URL MUST CONTAIN';else if(t==='attribute_contains')valLabel.textContent='ATTRIBUTE VALUE MUST CONTAIN';else valLabel.textContent='EXPECTED VALUE';});
1362
+ popover.querySelector('#__skopix_cancel_btn').addEventListener('click',function(e2){e2.stopPropagation();if(hl)hl.style.display='none';popover.remove();});
1363
+ popover.querySelector('#__skopix_add_btn').addEventListener('click',function(e2){
1364
+ e2.stopPropagation();
1365
+ const assertType=typeSelect.value;
1366
+ const value=valInput.value.trim();
1367
+ const description=popover.querySelector('#__skopix_assert_desc').value.trim();
1368
+ if(assertType!=='visible'&&assertType!=='url_contains'&&!value){valInput.style.borderColor='#dc2626';return;}
1369
+ const attrInput=document.getElementById('__skopix_assert_attr');
1370
+ window.__skopixCapture&&window.__skopixCapture({action:'assert',assertType,selector:assertType==='url_contains'?null:sel,value:value||null,attribute:assertType==='attribute_contains'?(attrInput?attrInput.value.trim()||'title':'title'):null,description:description||null,element:assertType==='url_contains'?null:{tag:el.tagName.toLowerCase(),text:currentText}});
1371
+ if(hl)hl.style.display='none';popover.remove();
1372
+ const ab=document.getElementById('__skopix_assert_btn');
1373
+ if(ab){const orig=ab.style.cssText;ab.textContent='\u2713 Added';ab.style.background='#14532d';ab.style.borderColor='#22c55e';ab.style.color='#4ade80';setTimeout(()=>{ab.textContent='+ Assert';ab.style.cssText=orig;},1500);}
1374
+ });
1375
+ setTimeout(()=>{if(valRow.style.display!=='none')valInput.focus();},50);
1376
+ }
1377
+ });
1378
+
1379
+ // Now safe to send done — injection is complete, no more broadcasts needed
1380
+ // Frontend will close the replay SSE and open the recording SSE
1381
+ broadcast({ type: 'done', exitCode: 0, status: 'passed', recordingId });
1382
+ process.stderr.write('[debug] recorder injected, done sent, awaiting user actions\n');
1383
+
1384
+ } catch (err) {
1385
+ broadcast({ type: 'stdout', text: '✖ Debug replay error: ' + err.message });
1386
+ broadcast({ type: 'done', exitCode: 1, status: 'failed' });
1387
+ recording.status = 'stopped';
1388
+ broadcastRec({ type: 'stopped' });
1389
+ if (chromiumBrowser) try { await chromiumBrowser.close(); } catch {}
1390
+ }
1391
+ })().catch(err => {
1392
+ process.stderr.write('[debug session crash] ' + err.message + '\n' + (err.stack||'') + '\n');
1393
+ try { broadcast({ type: 'stdout', text: '\u2716 Debug session crashed: ' + err.message }); } catch {}
1394
+ try { broadcast({ type: 'done', exitCode: 1, status: 'failed' }); } catch {}
1395
+ recording.status = 'stopped';
1396
+ try { broadcastRec({ type: 'stopped' }); } catch {}
1397
+ });
1398
+
1399
+ sendJSON(res, 200, { runId, recordingId });
1400
+ return;
1401
+ }
1402
+
1403
+ if (pathname.match(/^\/api\/test\/[^/]+\/[^/]+\/replay$/) && method === 'POST') {
1404
+ const parts = pathname.split('/');
1405
+ const scope = decodeURIComponent(parts[3]);
1406
+ const testId = decodeURIComponent(parts[4]);
1407
+ const test = await getTest(suitesDir, scope, testId);
1408
+ if (!test) { sendJSON(res, 404, { error: 'Test not found' }); return; }
1409
+ if (!test.steps || !test.steps.length) { sendJSON(res, 400, { error: 'No recorded steps to replay' }); return; }
1410
+ // Resolve credentials — substitute real passwords into password steps
1411
+ if (test.credentials) {
1412
+ const creds = await resolveCredentials(test.credentials);
1413
+ if (creds) {
1414
+ test.steps = test.steps.map(s => {
1415
+ if (s.action === 'type' && s.isPassword && creds.password) {
1416
+ return { ...s, value: creds.password };
1417
+ }
1418
+ if (s.action === 'type' && !s.isPassword && creds.username && (s.element && (s.element.name === 'username' || s.element.type === 'text' && s.element.name))) {
1419
+ return s; // keep recorded username as-is, credential username is optional override
1420
+ }
1421
+ return s;
1422
+ });
1423
+ }
1424
+ }
1425
+ // Load setup test if referenced
1426
+ let setupTest = null;
1427
+ if (test.setup) {
1428
+ const all = await listAllTests(suitesDir);
1429
+ const setupRef = all.find(t => t.id === test.setup || t.name === test.setup);
1430
+ if (setupRef) setupTest = await getTest(suitesDir, setupRef.scope, setupRef.id);
1431
+ }
1432
+ const userEnv = await resolveUserSecretsEnv(currentUser && currentUser.id, teamMode);
1433
+
1434
+ // In team mode, dispatch to least busy agent
1435
+ const replayAgent = teamMode ? getLeastBusyAgent(currentUser?.id) : null;
1436
+ if (teamMode && !replayAgent) {
1437
+ sendJSON(res, 503, { error: 'No agent connected. Run "skopix agent --server ' + (req.headers.host || 'localhost:9000') + '" on your machine to run replays.' });
1438
+ return;
1439
+ }
1440
+
1441
+ if (replayAgent) {
1442
+ const runId = Math.random().toString(36).slice(2, 10);
1443
+ const run = { id: runId, type: 'replay', status: 'running', output: [], listeners: [], sessionId: runId };
1444
+ activeRuns.set(runId, run);
1445
+ replayAgent.status = 'replaying';
1446
+ replayAgent.currentJob = { type: 'replay', runId, testName: test.name };
1447
+ broadcastAgentList();
1448
+ sendToAgent(replayAgent, { type: 'replay', runId, test, setupTest, env: userEnv || {} });
1449
+ sendJSON(res, 200, { runId, agent: { id: replayAgent.id, name: replayAgent.name } });
1450
+ return;
1451
+ }
1452
+
1453
+ const runId = startReplay(test, setupTest, activeRuns, reportsDir, currentUser, { ...process.env, ...(userEnv || {}) });
1454
+ sendJSON(res, 200, { runId });
1455
+ return;
1456
+ }
1457
+
1458
+ if (pathname === '/api/run' && method === 'POST') {
1459
+ const body = await readBody(req);
1460
+ const config = JSON.parse(body);
1461
+ const userEnv = await resolveUserSecretsEnv(currentUser?.id, teamMode);
1462
+ const runId = startRun(config, activeRuns, reportsDir, currentUser, userEnv);
1463
+ sendJSON(res, 200, { runId });
1464
+ return;
1465
+ }
1466
+ if (pathname.startsWith('/api/stream/') && method === 'GET') {
1467
+ const runId = pathname.split('/')[3];
1468
+ streamRun(req, res, runId, activeRuns);
1469
+ return;
1470
+ }
1471
+ if (pathname.startsWith('/api/report/') && method === 'GET') {
1472
+ const id = pathname.split('/')[3];
1473
+ const reportPath = path.join(reportsDir, id, 'report.html');
1474
+ if (await fs.pathExists(reportPath)) {
1475
+ // Instead of trying to open a local file (breaks in Docker), redirect to
1476
+ // the HTTP-served report route which works everywhere.
1477
+ res.writeHead(302, { Location: '/report/' + id + '/' });
1478
+ res.end();
1479
+ } else sendJSON(res, 404, { error: 'Not found' });
1480
+ return;
1481
+ }
1482
+
1483
+ // Serve report HTML and its assets over HTTP.
1484
+ // Works in Docker (no local file access needed) and on bare-metal installs.
1485
+ // Route: /report/:sessionId/ -> serves report.html
1486
+ // /report/:sessionId/<asset> -> serves screenshots/videos/etc
1487
+ if (pathname.startsWith('/report/') && method === 'GET') {
1488
+ const parts = pathname.split('/').filter(Boolean); // ['report', id, ...rest]
1489
+ const id = parts[1];
1490
+ if (!id) { sendJSON(res, 404, { error: 'Not found' }); return; }
1491
+ // Remaining path after /report/:id/ - defaults to report.html
1492
+ const assetPath = parts.slice(2).join('/') || 'report.html';
1493
+ // Prevent path traversal
1494
+ const safeAsset = path.normalize(assetPath).replace(/^(\.\.\/|\/)+/, '');
1495
+ const filePath = path.join(reportsDir, id, safeAsset);
1496
+ if (!await fs.pathExists(filePath)) { sendJSON(res, 404, { error: 'Not found' }); return; }
1497
+ // Serve the file with appropriate Content-Type
1498
+ const ext = path.extname(filePath).toLowerCase();
1499
+ const mime = {
1500
+ '.html': 'text/html; charset=utf-8',
1501
+ '.css': 'text/css',
1502
+ '.js': 'application/javascript',
1503
+ '.json': 'application/json',
1504
+ '.png': 'image/png',
1505
+ '.jpg': 'image/jpeg',
1506
+ '.jpeg': 'image/jpeg',
1507
+ '.webm': 'video/webm',
1508
+ '.mp4': 'video/mp4',
1509
+ '.svg': 'image/svg+xml',
1510
+ '.woff2': 'font/woff2',
1511
+ '.woff': 'font/woff',
1512
+ }[ext] || 'application/octet-stream';
1513
+ const stat = await fs.stat(filePath);
1514
+ res.writeHead(200, { 'Content-Type': mime, 'Content-Length': stat.size });
1515
+ const stream = fs.createReadStream(filePath);
1516
+ stream.pipe(res);
1517
+ return;
1518
+ }
1519
+
1520
+ // ─── ALL TESTS (universal view) ─────────────────────────────────────
1521
+ if (pathname === '/api/tests' && method === 'GET') {
1522
+ sendJSON(res, 200, await listAllTests(suitesDir));
1523
+ return;
1524
+ }
1525
+
1526
+ // /api/test/<scope>/<testId> - get/update/delete a specific test
1527
+ // Scope is either "saved" or a suite filename
1528
+ if (pathname.match(/^\/api\/test\/[^/]+\/[^/]+$/) && method === 'GET') {
1529
+ const parts = pathname.split('/');
1530
+ const scope = decodeURIComponent(parts[3]);
1531
+ const testId = decodeURIComponent(parts[4]);
1532
+ const test = await getTest(suitesDir, scope, testId);
1533
+ if (!test) sendJSON(res, 404, { error: 'Not found' });
1534
+ else sendJSON(res, 200, test);
1535
+ return;
1536
+ }
1537
+ if (pathname.match(/^\/api\/test\/[^/]+\/[^/]+$/) && method === 'PUT') {
1538
+ const parts = pathname.split('/');
1539
+ const scope = decodeURIComponent(parts[3]);
1540
+ const testId = decodeURIComponent(parts[4]);
1541
+ const body = await readBody(req);
1542
+ const data = JSON.parse(body);
1543
+ try {
1544
+ const result = await updateTest(suitesDir, scope, testId, data);
1545
+ sendJSON(res, 200, result);
1546
+ } catch (err) {
1547
+ sendJSON(res, 400, { error: err.message });
1548
+ }
1549
+ return;
1550
+ }
1551
+ if (pathname.match(/^\/api\/test\/[^/]+\/[^/]+$/) && method === 'DELETE') {
1552
+ const parts = pathname.split('/');
1553
+ const scope = decodeURIComponent(parts[3]);
1554
+ const testId = decodeURIComponent(parts[4]);
1555
+ await deleteTest(suitesDir, scope, testId);
1556
+ sendJSON(res, 200, { deleted: true });
1557
+ return;
1558
+ }
1559
+
1560
+ // /api/test - create a new test in a scope
1561
+ if (pathname === '/api/test' && method === 'POST') {
1562
+ const body = await readBody(req);
1563
+ const data = JSON.parse(body); // { scope, test }
1564
+ try {
1565
+ const result = await createTest(suitesDir, data.scope, data.test);
1566
+ sendJSON(res, 200, result);
1567
+ } catch (err) {
1568
+ sendJSON(res, 400, { error: err.message });
1569
+ }
1570
+ return;
1571
+ }
1572
+
1573
+ // /api/test/<scope>/<testId>/duplicate - create a copy in the same scope
1574
+ if (pathname.match(/^\/api\/test\/[^/]+\/[^/]+\/duplicate$/) && method === 'POST') {
1575
+ const parts = pathname.split('/');
1576
+ const scope = decodeURIComponent(parts[3]);
1577
+ const testId = decodeURIComponent(parts[4]);
1578
+ try {
1579
+ const result = await duplicateTest(suitesDir, scope, testId);
1580
+ if (!result) { sendJSON(res, 404, { error: 'Test not found' }); return; }
1581
+ // Audit log entry if in team mode
1582
+ if (teamMode && currentUser) {
1583
+ teamMode.db.logAudit({
1584
+ userId: currentUser.id, action: 'test.duplicated',
1585
+ targetType: 'test', targetId: result.id,
1586
+ metadata: { scope, sourceTestId: testId, newName: result.name },
1587
+ });
1588
+ }
1589
+ sendJSON(res, 200, result);
1590
+ } catch (err) {
1591
+ sendJSON(res, 400, { error: err.message });
1592
+ }
1593
+ return;
1594
+ }
1595
+
1596
+ // /api/test/<scope>/<testId>/move - move to different scope
1597
+ if (pathname.match(/^\/api\/test\/[^/]+\/[^/]+\/move$/) && method === 'POST') {
1598
+ const parts = pathname.split('/');
1599
+ const fromScope = decodeURIComponent(parts[3]);
1600
+ const testId = decodeURIComponent(parts[4]);
1601
+ const body = await readBody(req);
1602
+ const { toScope } = JSON.parse(body);
1603
+ try {
1604
+ const result = await moveTest(suitesDir, fromScope, testId, toScope);
1605
+ sendJSON(res, 200, result);
1606
+ } catch (err) {
1607
+ sendJSON(res, 400, { error: err.message });
1608
+ }
1609
+ return;
1610
+ }
1611
+
1612
+ // /api/test/<scope>/<testId>/run - run a single test by reference
1613
+ if (pathname.match(/^\/api\/test\/[^/]+\/[^/]+\/run$/) && method === 'POST') {
1614
+ const parts = pathname.split('/');
1615
+ const scope = decodeURIComponent(parts[3]);
1616
+ const testId = decodeURIComponent(parts[4]);
1617
+ const test = await getTest(suitesDir, scope, testId);
1618
+ if (!test) { sendJSON(res, 404, { error: 'Not found' }); return; }
1619
+ process.stderr.write('[run] test=' + testId + ' type=' + test.type + ' steps=' + (test.steps||[]).length + ' url=' + test.url + '\n');
1620
+ const userEnv = await resolveUserSecretsEnv(currentUser?.id, teamMode);
1621
+
1622
+ // Recorded tests use the replay engine
1623
+ if (test.type === 'recorded' || (test.steps && test.steps.length)) {
1624
+ if (!test.steps || !test.steps.length) { sendJSON(res, 400, { error: 'No recorded steps' }); return; }
1625
+ let setupTest = null;
1626
+ if (test.setup) {
1627
+ const all = await listAllTests(suitesDir);
1628
+ const ref = all.find(t => t.id === test.setup || t.name === test.setup);
1629
+ if (ref) setupTest = await getTest(suitesDir, ref.scope, ref.id);
1630
+ }
1631
+ if (test.credentials) {
1632
+ const creds = await resolveCredentials(test.credentials);
1633
+ if (creds) test.steps = test.steps.map(s => s.action === 'type' && s.isPassword && creds.password ? { ...s, value: creds.password } : s);
1634
+ }
1635
+ const runId = startReplay(test, setupTest, activeRuns, reportsDir, currentUser, { ...process.env, ...(userEnv || {}) });
1636
+ sendJSON(res, 200, { runId });
1637
+ return;
1638
+ }
1639
+
1640
+ // Goal-based tests
1641
+ const config = {
1642
+ url: test.url,
1643
+ goal: test.goal,
1644
+ maxSteps: test.maxSteps,
1645
+ credentials: test.credentials,
1646
+ provider: test.provider,
1647
+ model: test.model,
1648
+ github: !!test.github,
1649
+ jira: !!test.jira,
1650
+ linear: !!test.linear,
1651
+ headless: !!test.headless,
1652
+ testName: test.name,
1653
+ };
1654
+ const runId = startRun(config, activeRuns, reportsDir, currentUser, userEnv);
1655
+ sendJSON(res, 200, { runId });
1656
+ return;
1657
+ }
1658
+
1659
+ // ─── SUITES ────────────────────────────────────────────────────────
1660
+ if (pathname === '/api/suites' && method === 'GET') {
1661
+ sendJSON(res, 200, await listSuites(suitesDir));
1662
+ return;
1663
+ }
1664
+ if (pathname === '/api/suites' && method === 'POST') {
1665
+ const body = await readBody(req);
1666
+ const data = JSON.parse(body);
1667
+ const result = await saveSuite(suitesDir, data);
1668
+ sendJSON(res, 200, result);
1669
+ return;
1670
+ }
1671
+ if (pathname.match(/^\/api\/suite\/[^/]+\/duplicate$/) && method === 'POST') {
1672
+ const name = decodeURIComponent(pathname.split('/')[3]);
1673
+ const result = await duplicateSuite(suitesDir, name);
1674
+ sendJSON(res, 200, result);
1675
+ return;
1676
+ }
1677
+ if (pathname.match(/^\/api\/suite\/[^/]+\/run$/) && method === 'POST') {
1678
+ const name = decodeURIComponent(pathname.split('/')[3]);
1679
+ const userEnv = await resolveUserSecretsEnv(currentUser?.id, teamMode);
1680
+ const result = await startSuiteRun(suitesDir, name, activeRuns, suiteRunsDir, reportsDir, currentUser, userEnv, { getLeastBusyAgent, sendToAgent, broadcastAgentList });
1681
+ if (!result) sendJSON(res, 404, { error: 'Not found' });
1682
+ else sendJSON(res, 200, result);
1683
+ return;
1684
+ }
1685
+ if (pathname.match(/^\/api\/suite\/[^/]+$/) && method === 'GET') {
1686
+ const name = decodeURIComponent(pathname.split('/')[3]);
1687
+ const suite = await getSuite(suitesDir, name);
1688
+ if (!suite) sendJSON(res, 404, { error: 'Not found' });
1689
+ else sendJSON(res, 200, suite);
1690
+ return;
1691
+ }
1692
+ if (pathname.match(/^\/api\/suite\/[^/]+$/) && method === 'PUT') {
1693
+ const name = decodeURIComponent(pathname.split('/')[3]);
1694
+ const body = await readBody(req);
1695
+ const data = JSON.parse(body);
1696
+ const result = await updateSuite(suitesDir, name, data);
1697
+ sendJSON(res, 200, result);
1698
+ return;
1699
+ }
1700
+ if (pathname.match(/^\/api\/suite\/[^/]+$/) && method === 'DELETE') {
1701
+ const name = decodeURIComponent(pathname.split('/')[3]);
1702
+ await deleteSuite(suitesDir, name);
1703
+ sendJSON(res, 200, { deleted: true });
1704
+ return;
1705
+ }
1706
+
1707
+ // ─── ISSUES ────────────────────────────────────────────────────────
1708
+ if (pathname === '/api/issues' && method === 'GET') {
1709
+ const store = await loadIssueStore();
1710
+ sendJSON(res, 200, store.issues || []);
1711
+ return;
1712
+ }
1713
+ if (pathname === '/api/issues/sync' && method === 'POST') {
1714
+ // Sync all open issues' status from their trackers
1715
+ const result = await syncIssuesStatus();
1716
+ sendJSON(res, 200, result);
1717
+ return;
1718
+ }
1719
+ if (pathname.match(/^\/api\/issues\/[^/]+$/) && method === 'PATCH') {
1720
+ // Manual status update: { status: 'open' | 'resolved' }
1721
+ const id = decodeURIComponent(pathname.split('/')[3]);
1722
+ const body = await readBody(req);
1723
+ const data = JSON.parse(body);
1724
+ const store = await loadIssueStore();
1725
+ const issue = store.issues.find(i => i.fingerprint + ':' + i.tracker === id);
1726
+ if (!issue) { sendJSON(res, 404, { error: 'Not found' }); return; }
1727
+ if (data.status) issue.status = data.status;
1728
+ await saveIssueStore(store);
1729
+ sendJSON(res, 200, issue);
1730
+ return;
1731
+ }
1732
+ if (pathname.match(/^\/api\/issues\/[^/]+$/) && method === 'DELETE') {
1733
+ const id = decodeURIComponent(pathname.split('/')[3]);
1734
+ const url = new URL(req.url, `http://localhost:${port}`);
1735
+ const closeRemote = url.searchParams.get('closeRemote') === 'true';
1736
+ const store = await loadIssueStore();
1737
+ const issue = store.issues.find(i => (i.fingerprint + ':' + i.tracker) === id);
1738
+ let remoteResult = null;
1739
+ if (issue && closeRemote) {
1740
+ try {
1741
+ await closeRemoteIssue(issue);
1742
+ remoteResult = 'closed';
1743
+ } catch (err) {
1744
+ remoteResult = 'failed: ' + err.message;
1745
+ }
1746
+ }
1747
+ store.issues = store.issues.filter(i => (i.fingerprint + ':' + i.tracker) !== id);
1748
+ await saveIssueStore(store);
1749
+ sendJSON(res, 200, { deleted: true, remoteResult });
1750
+ return;
1751
+ }
1752
+
1753
+ // ─── CREDENTIALS ───────────────────────────────────────────────────
1754
+ if (pathname === '/api/credentials' && method === 'GET') {
1755
+ sendJSON(res, 200, await listCredentials());
1756
+ return;
1757
+ }
1758
+ if (pathname === '/api/credentials' && method === 'POST') {
1759
+ const body = await readBody(req);
1760
+ const data = JSON.parse(body);
1761
+ try {
1762
+ await saveCredentialSet(data.name, data.values);
1763
+ sendJSON(res, 200, { saved: true });
1764
+ } catch (err) {
1765
+ sendJSON(res, 400, { error: err.message });
1766
+ }
1767
+ return;
1768
+ }
1769
+ if (pathname.match(/^\/api\/credentials\/[^/]+$/) && method === 'GET') {
1770
+ const name = decodeURIComponent(pathname.split('/')[3]);
1771
+ const values = await getCredentialSet(name);
1772
+ if (!values) sendJSON(res, 404, { error: 'Not found' });
1773
+ else sendJSON(res, 200, { name, values });
1774
+ return;
1775
+ }
1776
+ if (pathname.match(/^\/api\/credentials\/[^/]+$/) && method === 'PUT') {
1777
+ const name = decodeURIComponent(pathname.split('/')[3]);
1778
+ const body = await readBody(req);
1779
+ const data = JSON.parse(body);
1780
+ try {
1781
+ await saveCredentialSet(data.name || name, data.values, name);
1782
+ sendJSON(res, 200, { saved: true });
1783
+ } catch (err) {
1784
+ sendJSON(res, 400, { error: err.message });
1785
+ }
1786
+ return;
1787
+ }
1788
+ if (pathname.match(/^\/api\/credentials\/[^/]+$/) && method === 'DELETE') {
1789
+ const name = decodeURIComponent(pathname.split('/')[3]);
1790
+ await deleteCredentialSet(name);
1791
+ sendJSON(res, 200, { deleted: true });
1792
+ return;
1793
+ }
1794
+
1795
+ // ─── SUITE RUNS ────────────────────────────────────────────────────
1796
+ if (pathname === '/api/suite-runs' && method === 'GET') {
1797
+ sendJSON(res, 200, await listSuiteRuns(suiteRunsDir, reportsDir));
1798
+ return;
1799
+ }
1800
+ if (pathname.startsWith('/api/suite-run/') && method === 'GET') {
1801
+ const id = pathname.split('/')[3];
1802
+ const data = await getSuiteRun(suiteRunsDir, reportsDir, id);
1803
+ if (!data) sendJSON(res, 404, { error: 'Not found' });
1804
+ else sendJSON(res, 200, data);
1805
+ return;
1806
+ }
1807
+ if (pathname.startsWith('/api/suite-run/') && method === 'DELETE') {
1808
+ const id = pathname.split('/')[3];
1809
+ const url = new URL(req.url, `http://localhost:${port}`);
1810
+ const cascade = url.searchParams.get('cascade') === 'true';
1811
+ await deleteSuiteRun(suiteRunsDir, reportsDir, id, cascade);
1812
+ sendJSON(res, 200, { deleted: true });
1813
+ return;
1814
+ }
1815
+ if (pathname === '/api/suite-runs' && method === 'DELETE') {
1816
+ await deleteAllSuiteRuns(suiteRunsDir);
1817
+ sendJSON(res, 200, { deleted: true });
1818
+ return;
1819
+ }
1820
+
1821
+ // ─── STATIC FILES ──────────────────────────────────────────────────
1822
+ let filePath;
1823
+ if (pathname === '/' || pathname === '/index.html') filePath = path.join(webRoot, 'index.html');
1824
+ else if (pathname === '/app' || pathname === '/app/' || pathname === '/app/index.html') filePath = path.join(webRoot, 'app', 'index.html');
1825
+ else if (pathname === '/setup' || pathname === '/setup/' || pathname === '/setup.html') filePath = path.join(webRoot, 'setup.html');
1826
+ else if (pathname === '/login' || pathname === '/login/' || pathname === '/login.html') filePath = path.join(webRoot, 'login.html');
1827
+ else if (pathname.startsWith('/invite/')) filePath = path.join(webRoot, 'invite.html');
1828
+ else if (pathname.startsWith('/reset/')) filePath = path.join(webRoot, 'reset.html');
1829
+ else if (pathname.startsWith('/reports/')) filePath = path.join(reportsDir, pathname.replace('/reports/', ''));
1830
+ else filePath = path.join(webRoot, pathname);
1831
+
1832
+ if (filePath && await fs.pathExists(filePath)) {
1833
+ const stat = await fs.stat(filePath);
1834
+ if (stat.isFile()) {
1835
+ const ext = path.extname(filePath);
1836
+ const ct = {
1837
+ '.html':'text/html','.css':'text/css','.js':'application/javascript',
1838
+ '.json':'application/json','.png':'image/png','.jpg':'image/jpeg',
1839
+ '.webm':'video/webm','.svg':'image/svg+xml',
1840
+ }[ext] || 'application/octet-stream';
1841
+ res.writeHead(200, { 'Content-Type': ct });
1842
+ fs.createReadStream(filePath).pipe(res);
1843
+ return;
1844
+ }
1845
+ }
1846
+
1847
+ res.writeHead(404);
1848
+ res.end('Not found');
1849
+ } catch (err) {
1850
+ console.error('Server error:', err);
1851
+ sendJSON(res, 500, { error: err.message });
1852
+ }
1853
+ });
1854
+
1855
+ // ── WEBSOCKET AGENT SERVER ──────────────────────────────────────────────
1856
+ // Handles agent connections using raw HTTP upgrade (no ws package needed)
1857
+ // Protocol: newline-delimited JSON over a persistent TCP connection
1858
+ server.on('upgrade', (req, socket, head) => {
1859
+ const url = req.url || '';
1860
+ if (!url.startsWith('/agent')) { socket.destroy(); return; }
1861
+
1862
+ // Verify secret key
1863
+ const expectedKey = process.env.SKOPIX_SECRET_KEY || '';
1864
+ const auth = req.headers['x-skopix-key'] || '';
1865
+ if (!expectedKey || auth !== expectedKey) {
1866
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
1867
+ socket.destroy();
1868
+ return;
1869
+ }
1870
+
1871
+ // WebSocket handshake
1872
+ const wsKey = req.headers['sec-websocket-key'];
1873
+ if (!wsKey) { socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); socket.destroy(); return; }
1874
+ const accept = crypto.createHash('sha1').update(wsKey + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
1875
+ socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ' + accept + '\r\n\r\n');
1876
+
1877
+ // Create minimal WS wrapper
1878
+ const ws = createWsWrapper(socket);
1879
+
1880
+ ws.on('message', (raw) => {
1881
+ let msg;
1882
+ try { msg = JSON.parse(raw); } catch { return; }
1883
+
1884
+ if (msg.type === 'register') {
1885
+ const agentId = msg.agentId || crypto.randomUUID();
1886
+ const agent = { id: agentId, name: msg.name || msg.machine, machine: msg.machine, userId: msg.userId || null, ws, status: 'idle', currentJob: null, connectedAt: Date.now() };
1887
+ agents.set(agentId, agent);
1888
+ ws.agentId = agentId;
1889
+ ws.send(JSON.stringify({ type: 'registered', agentId }));
1890
+ console.log(chalk.cyan('[agent]') + ' Connected: ' + agent.name + ' (' + agent.machine + ')');
1891
+ broadcastAgentList();
1892
+ return;
1893
+ }
1894
+
1895
+ const agent = ws.agentId ? agents.get(ws.agentId) : null;
1896
+ if (!agent) return;
1897
+
1898
+ if (msg.type === 'jobUpdate') {
1899
+ // Agent streaming step output back to server
1900
+ const run = msg.runId ? activeRuns.get(msg.runId) : null;
1901
+ if (run) {
1902
+ run.output.push(msg.data);
1903
+ run.listeners.forEach(l => { try { l(msg.data); } catch {} });
1904
+ if (msg.data.type === 'done') {
1905
+ run.status = msg.data.status || 'done';
1906
+ agent.status = 'idle';
1907
+ agent.currentJob = null;
1908
+ broadcastAgentList();
1909
+ }
1910
+ }
1911
+ } else if (msg.type === 'recordingUpdate') {
1912
+ // Agent streaming recording events back
1913
+ const rec = msg.recordingId ? activeRecordings.get(msg.recordingId) : null;
1914
+ if (rec) {
1915
+ rec.output.push(msg.data);
1916
+ rec.listeners.forEach(l => { try { l(msg.data); } catch {} });
1917
+ if (msg.data.type === 'step') rec.steps.push(msg.data.step);
1918
+ if (msg.data.type === 'done' || msg.data.type === 'stopped') {
1919
+ if (msg.data.steps) rec.steps = msg.data.steps;
1920
+ rec.status = 'stopped';
1921
+ agent.status = 'idle';
1922
+ agent.currentJob = null;
1923
+ broadcastAgentList();
1924
+ }
1925
+ }
1926
+ }
1927
+ });
1928
+
1929
+ ws.on('close', () => {
1930
+ if (ws.agentId) {
1931
+ const agent = agents.get(ws.agentId);
1932
+ if (agent) console.log(chalk.yellow('[agent]') + ' Disconnected: ' + agent.name);
1933
+ agents.delete(ws.agentId);
1934
+ broadcastAgentList();
1935
+ }
1936
+ });
1937
+ });
1938
+
1939
+ // Minimal WebSocket frame encoder/decoder for Node streams
1940
+ function createWsWrapper(socket) {
1941
+ const emitter = new (class WsEmitter {
1942
+ constructor() { this._events = {}; this.readyState = 1; }
1943
+ on(ev, fn) { (this._events[ev] = this._events[ev] || []).push(fn); return this; }
1944
+ emit(ev, ...args) { (this._events[ev] || []).forEach(fn => fn(...args)); }
1945
+ send(data) {
1946
+ const buf = Buffer.from(data);
1947
+ const len = buf.length;
1948
+ let header;
1949
+ if (len < 126) { header = Buffer.alloc(2); header[0] = 0x81; header[1] = len; }
1950
+ else if (len < 65536) { header = Buffer.alloc(4); header[0] = 0x81; header[1] = 126; header.writeUInt16BE(len, 2); }
1951
+ else { header = Buffer.alloc(10); header[0] = 0x81; header[1] = 127; header.writeBigUInt64BE(BigInt(len), 2); }
1952
+ try { socket.write(Buffer.concat([header, buf])); } catch {}
1953
+ }
1954
+ })();
1955
+ emitter.readyState = 1;
1956
+ emitter.send = (data) => {
1957
+ const buf = Buffer.from(data);
1958
+ const len = buf.length;
1959
+ let header;
1960
+ if (len < 126) { header = Buffer.alloc(2); header[0] = 0x81; header[1] = len; }
1961
+ else if (len < 65536) { header = Buffer.alloc(4); header[0] = 0x81; header[1] = 126; header.writeUInt16BE(len, 2); }
1962
+ else { header = Buffer.alloc(10); header[0] = 0x81; header[1] = 127; header.writeBigUInt64BE(BigInt(len), 2); }
1963
+ socket.write(Buffer.concat([header, buf]));
1964
+ };
1965
+ let buf = Buffer.alloc(0);
1966
+ socket.on('data', (chunk) => {
1967
+ buf = Buffer.concat([buf, chunk]);
1968
+ while (buf.length >= 2) {
1969
+ const masked = (buf[1] & 0x80) !== 0;
1970
+ let payloadLen = buf[1] & 0x7f;
1971
+ let offset = 2;
1972
+ if (payloadLen === 126) { if (buf.length < 4) break; payloadLen = buf.readUInt16BE(2); offset = 4; }
1973
+ else if (payloadLen === 127) { if (buf.length < 10) break; payloadLen = Number(buf.readBigUInt64BE(2)); offset = 10; }
1974
+ if (masked) offset += 4;
1975
+ if (buf.length < offset + payloadLen) break;
1976
+ let payload = buf.slice(offset, offset + payloadLen);
1977
+ if (masked) { const mask = buf.slice(offset - 4, offset); payload = Buffer.from(payload.map((b, i) => b ^ mask[i % 4])); }
1978
+ const opcode = buf[0] & 0x0f;
1979
+ if (opcode === 8) { emitter.readyState = 3; emitter.emit('close'); socket.end(); }
1980
+ else if (opcode === 1 || opcode === 2) emitter.emit('message', payload.toString());
1981
+ buf = buf.slice(offset + payloadLen);
1982
+ }
1983
+ });
1984
+ socket.on('close', () => { emitter.readyState = 3; emitter.emit('close'); });
1985
+ socket.on('error', () => { emitter.readyState = 3; emitter.emit('close'); });
1986
+ return emitter;
1987
+ }
1988
+
1989
+ server.listen(port, host, () => {
1990
+ const displayHost = (host === '0.0.0.0' || host === '::') ? 'localhost' : host;
1991
+ console.log(chalk.cyan('━'.repeat(60)));
1992
+ console.log(chalk.white.bold(' Skopix Dashboard'));
1993
+ console.log(chalk.cyan('━'.repeat(60)));
1994
+ console.log();
1995
+ console.log(chalk.green(' ✓ ') + 'Server: ' + chalk.cyan(`http://${displayHost}:${port}`));
1996
+ if (host === '0.0.0.0' || host === '::') {
1997
+ console.log(chalk.green(' ✓ ') + 'Listening on all interfaces (team mode bind)');
1998
+ }
1999
+ console.log(chalk.green(' ✓ ') + 'Reports: ' + chalk.cyan(reportsDir));
2000
+ console.log(chalk.green(' ✓ ') + 'Suites: ' + chalk.cyan(suitesDir));
2001
+ if (teamMode) {
2002
+ console.log(chalk.green(' ✓ ') + 'Mode: ' + chalk.cyan('team (multi-user)'));
2003
+ console.log(chalk.green(' ✓ ') + 'Database: ' + chalk.cyan(teamMode.db.getDbPath()));
2004
+ if (!teamMode.db.hasAnyAdmin()) {
2005
+ console.log();
2006
+ console.log(chalk.yellow(' ⚠ Setup required: visit ') + chalk.cyan(`http://${displayHost}:${port}/setup`) + chalk.yellow(' to create the first admin'));
2007
+ }
2008
+ } else {
2009
+ console.log(chalk.green(' ✓ ') + 'Mode: ' + chalk.cyan('single-user'));
2010
+ }
2011
+ console.log();
2012
+ console.log(chalk.dim(' Press Ctrl+C to stop'));
2013
+ console.log();
2014
+ if (!options.noOpen) {
2015
+ const openPath = (teamMode && !teamMode.db.hasAnyAdmin()) ? '/setup' : '/app/';
2016
+ open(`http://${displayHost}:${port}${openPath}`).catch(() => {});
2017
+ }
2018
+ });
2019
+
2020
+ process.on('SIGINT', () => { console.log(chalk.yellow('\n Stopping...')); server.close(); process.exit(0); });
2021
+ }
2022
+
2023
+ // ─── HELPERS ──────────────────────────────────────────────────────────────────
2024
+ function sendJSON(res, status, data) {
2025
+ res.writeHead(status, { 'Content-Type': 'application/json' });
2026
+ res.end(JSON.stringify(data));
2027
+ }
2028
+
2029
+ // Parse a single cookie value out of a Cookie header string.
2030
+ // Returns null if not found. Safe against malformed input.
2031
+ function parseCookie(cookieHeader, name) {
2032
+ if (!cookieHeader || typeof cookieHeader !== 'string') return null;
2033
+ const parts = cookieHeader.split(';');
2034
+ for (const part of parts) {
2035
+ const trimmed = part.trim();
2036
+ const eqIdx = trimmed.indexOf('=');
2037
+ if (eqIdx === -1) continue;
2038
+ const key = trimmed.slice(0, eqIdx);
2039
+ const value = trimmed.slice(eqIdx + 1);
2040
+ if (key === name) return decodeURIComponent(value);
2041
+ }
2042
+ return null;
2043
+ }
2044
+
2045
+ // Resolve the request to a user via the session cookie.
2046
+ // Returns: { user } if valid session and active user, null otherwise.
2047
+ // Pass `tm` (the teamMode object) so this works without globals.
2048
+ function resolveCurrentUser(req, tm) {
2049
+ if (!tm) return null;
2050
+ const token = parseCookie(req.headers.cookie || '', 'skopix_session');
2051
+ if (!token) return null;
2052
+ const session = tm.db.getWebSession(token);
2053
+ if (!session) return null;
2054
+ const user = tm.db.getUserById(session.user_id);
2055
+ if (!user || user.status !== 'active') return null;
2056
+ // Touch the session so active users don't expire prematurely
2057
+ tm.db.touchWebSession(token);
2058
+ return { user, sessionToken: token };
2059
+ }
2060
+
2061
+ // Decide whether a request path should bypass auth in team mode.
2062
+ // Public paths: setup wizard, login flow, status check, static assets.
2063
+ // Everything else (incl. /app/ JS-loaded API calls) requires a session.
2064
+ function isPublicPath(pathname) {
2065
+ // API routes that don't require auth
2066
+ const publicApis = new Set([
2067
+ '/api/team/status',
2068
+ '/api/setup',
2069
+ '/api/auth/login',
2070
+ '/api/auth/logout',
2071
+ '/api/auth/me', // returns 401 itself, doesn't need to be blocked here
2072
+ '/api/agent/auth', // agents authenticate with secret key, not session cookie
2073
+ ]);
2074
+ if (publicApis.has(pathname)) return true;
2075
+ // Public invite endpoints: GET invite details + accept invite (no auth needed)
2076
+ // /api/invites/<token> GET, /api/invites/<token>/accept POST
2077
+ if (pathname.match(/^\/api\/invites\/[^/]+$/)) return true;
2078
+ if (pathname.match(/^\/api\/invites\/[^/]+\/accept$/)) return true;
2079
+ // Password reset (admin-generated link, used by the recipient who isn't logged in)
2080
+ if (pathname.match(/^\/api\/password-reset\/[^/]+$/)) return true;
2081
+ // Static pages everyone can fetch (the JS inside enforces auth)
2082
+ if (pathname === '/' || pathname === '/index.html') return true;
2083
+ if (pathname === '/login' || pathname === '/login/' || pathname === '/login.html') return true;
2084
+ if (pathname === '/setup' || pathname === '/setup/' || pathname === '/setup.html') return true;
2085
+ // Accept invite page - public
2086
+ if (pathname.startsWith('/invite/')) return true;
2087
+ if (pathname.startsWith('/reset/')) return true;
2088
+ // /app/ is NOT in the public list - handled specially in the auth gate (redirects to /login if no session).
2089
+ // Static assets used by the pages above (fonts, css, images served from web/)
2090
+ // Anything outside /api/ that's a static file should be allowed.
2091
+ if (!pathname.startsWith('/api/')) return true;
2092
+ return false;
2093
+ }
2094
+ function readBody(req) {
2095
+ return new Promise((resolve, reject) => {
2096
+ let body = '';
2097
+ req.on('data', c => body += c);
2098
+ req.on('end', () => resolve(body));
2099
+ req.on('error', reject);
2100
+ });
2101
+ }
2102
+
2103
+ function slugify(s) {
2104
+ return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
2105
+ }
2106
+
2107
+ function testIdFromName(name) {
2108
+ return slugify(name) || 'test-' + Math.random().toString(36).slice(2, 8);
2109
+ }
2110
+
2111
+ // ─── SESSIONS ─────────────────────────────────────────────────────────────────
2112
+ async function listSessions(reportsDir) {
2113
+ if (!await fs.pathExists(reportsDir)) return [];
2114
+ const entries = await fs.readdir(reportsDir);
2115
+ const sessions = [];
2116
+ for (const entry of entries) {
2117
+ if (entry.startsWith('.')) continue;
2118
+ const sessionPath = path.join(reportsDir, entry);
2119
+ const stat = await fs.stat(sessionPath);
2120
+ if (!stat.isDirectory()) continue;
2121
+ const jsonPath = path.join(sessionPath, 'report.json');
2122
+ if (await fs.pathExists(jsonPath)) {
2123
+ try {
2124
+ const data = await fs.readJson(jsonPath);
2125
+ const status = data.goalAchieved ? 'passed' : data.stuck ? 'stuck' : 'failed';
2126
+ // Read run attribution if present (team mode)
2127
+ let runBy = null;
2128
+ const runByPath = path.join(sessionPath, 'runBy.json');
2129
+ if (await fs.pathExists(runByPath)) {
2130
+ try { runBy = await fs.readJson(runByPath); } catch {}
2131
+ }
2132
+ sessions.push({
2133
+ id: data.sessionId || entry, status,
2134
+ url: data.url, goal: data.goal,
2135
+ steps: data.steps?.length || 0,
2136
+ issues: data.issues?.length || 0,
2137
+ duration: formatDuration(data.duration || 0),
2138
+ durationMs: data.duration || 0,
2139
+ when: relativeTime(stat.mtime),
2140
+ mtime: stat.mtime.toISOString(),
2141
+ model: data.model, provider: data.provider,
2142
+ runBy, // null in single-user mode or when no attribution recorded
2143
+ });
2144
+ } catch {}
2145
+ }
2146
+ }
2147
+ sessions.sort((a, b) => new Date(b.mtime) - new Date(a.mtime));
2148
+ return sessions;
2149
+ }
2150
+ async function getSession(reportsDir, id) {
2151
+ const jsonPath = path.join(reportsDir, id, 'report.json');
2152
+ if (!await fs.pathExists(jsonPath)) return null;
2153
+ try {
2154
+ const data = await fs.readJson(jsonPath);
2155
+ // Attach runBy if present
2156
+ const runByPath = path.join(reportsDir, id, 'runBy.json');
2157
+ if (await fs.pathExists(runByPath)) {
2158
+ try { data.runBy = await fs.readJson(runByPath); } catch {}
2159
+ }
2160
+ return data;
2161
+ } catch { return null; }
2162
+ }
2163
+ function computeStats(sessions) {
2164
+ const total = sessions.length;
2165
+ const passed = sessions.filter(s => s.status === 'passed').length;
2166
+ const failed = sessions.filter(s => s.status === 'failed').length;
2167
+ const stuck = sessions.filter(s => s.status === 'stuck').length;
2168
+ const passRate = total > 0 ? Math.round((passed / total) * 100) : 0;
2169
+ const totalIssues = sessions.reduce((sum, s) => sum + (s.issues || 0), 0);
2170
+ const avgMs = total > 0 ? sessions.reduce((sum, s) => sum + (s.durationMs || 0), 0) / total : 0;
2171
+ const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
2172
+ const thisWeek = sessions.filter(s => new Date(s.mtime).getTime() > weekAgo).length;
2173
+ return { total, passed, failed, stuck, passRate, totalIssues, avgDuration: formatDuration(avgMs), avgDurationMs: avgMs, thisWeek };
2174
+ }
2175
+ async function getConfig() {
2176
+ const envPath = path.resolve(process.cwd(), '.skopix.env');
2177
+ if (!await fs.pathExists(envPath)) return [];
2178
+ const content = await fs.readFile(envPath, 'utf-8');
2179
+ const config = [];
2180
+ for (const line of content.split('\n')) {
2181
+ const t = line.trim();
2182
+ if (!t || t.startsWith('#')) continue;
2183
+ const eq = t.indexOf('=');
2184
+ if (eq === -1) continue;
2185
+ const key = t.slice(0, eq).trim();
2186
+ const value = t.slice(eq + 1).trim();
2187
+ const isSecret = key.includes('KEY') || key.includes('TOKEN') || key.includes('PASSWORD');
2188
+ config.push({ key, value: isSecret ? value.slice(0, 6) + '••••••••••••••' : value, isSecret });
2189
+ }
2190
+ return config;
2191
+ }
2192
+
2193
+ // ─── SUITES ───────────────────────────────────────────────────────────────────
2194
+ async function listSuites(suitesDir) {
2195
+ const files = await fs.readdir(suitesDir);
2196
+ const suiteFiles = files.filter(f => (f.endsWith('.suite.yaml') || f.endsWith('.suite.yml')) && f !== SAVED_TESTS_FILE);
2197
+ const suites = [];
2198
+ for (const file of suiteFiles) {
2199
+ try {
2200
+ const content = await fs.readFile(path.join(suitesDir, file), 'utf-8');
2201
+ const data = yaml.parse(content);
2202
+ const stat = await fs.stat(path.join(suitesDir, file));
2203
+ suites.push({
2204
+ filename: file,
2205
+ name: data.name || file,
2206
+ description: data.description || '',
2207
+ testCount: data.tests?.length || 0,
2208
+ defaults: data.defaults || {},
2209
+ tags: collectTags(data.tests),
2210
+ modified: stat.mtime.toISOString(),
2211
+ modifiedRelative: relativeTime(stat.mtime),
2212
+ });
2213
+ } catch {}
2214
+ }
2215
+ return suites;
2216
+ }
2217
+ function collectTags(tests) {
2218
+ if (!tests) return [];
2219
+ const set = new Set();
2220
+ tests.forEach(t => (t.tags || []).forEach(tag => set.add(tag)));
2221
+ return Array.from(set);
2222
+ }
2223
+ async function getSuite(suitesDir, filename) {
2224
+ const filePath = path.join(suitesDir, filename);
2225
+ if (!await fs.pathExists(filePath)) return null;
2226
+ try {
2227
+ const content = await fs.readFile(filePath, 'utf-8');
2228
+ return { filename, ...yaml.parse(content) };
2229
+ } catch { return null; }
2230
+ }
2231
+ function suiteFileName(name) { return slugify(name) + '.suite.yaml'; }
2232
+ async function saveSuite(suitesDir, data) {
2233
+ const filename = suiteFileName(data.name || 'untitled');
2234
+ const filePath = path.join(suitesDir, filename);
2235
+ const suite = {
2236
+ name: data.name || 'Untitled Suite',
2237
+ description: data.description || '',
2238
+ defaults: data.defaults || {},
2239
+ waitBetweenTests: data.waitBetweenTests || 2000,
2240
+ tests: data.tests || [],
2241
+ };
2242
+ if (data.parallel && data.parallel > 1) suite.parallel = data.parallel;
2243
+ await fs.writeFile(filePath, yaml.stringify(suite));
2244
+ return { filename, ...suite };
2245
+ }
2246
+ async function updateSuite(suitesDir, filename, data) {
2247
+ const filePath = path.join(suitesDir, filename);
2248
+ const suite = {
2249
+ name: data.name || 'Untitled Suite',
2250
+ description: data.description || '',
2251
+ defaults: data.defaults || {},
2252
+ waitBetweenTests: data.waitBetweenTests || 2000,
2253
+ tests: data.tests || [],
2254
+ };
2255
+ if (data.parallel && data.parallel > 1) suite.parallel = data.parallel;
2256
+ await fs.writeFile(filePath, yaml.stringify(suite));
2257
+ return { filename, ...suite };
2258
+ }
2259
+ async function duplicateSuite(suitesDir, filename) {
2260
+ const filePath = path.join(suitesDir, filename);
2261
+ if (!await fs.pathExists(filePath)) return null;
2262
+ const content = await fs.readFile(filePath, 'utf-8');
2263
+ const data = yaml.parse(content);
2264
+ data.name = (data.name || 'Suite') + ' (copy)';
2265
+ return await saveSuite(suitesDir, data);
2266
+ }
2267
+ async function deleteSuite(suitesDir, filename) {
2268
+ const filePath = path.join(suitesDir, filename);
2269
+ if (await fs.pathExists(filePath)) await fs.remove(filePath);
2270
+ }
2271
+
2272
+ // ─── TESTS (across all scopes) ────────────────────────────────────────────────
2273
+ function scopeToFilename(scope) {
2274
+ if (scope === 'saved') return SAVED_TESTS_FILE;
2275
+ return scope; // already a filename like "smoke.suite.yaml"
2276
+ }
2277
+ function filenameToScope(filename) {
2278
+ if (filename === SAVED_TESTS_FILE) return 'saved';
2279
+ return filename;
2280
+ }
2281
+
2282
+ async function readSuiteFile(suitesDir, filename) {
2283
+ const filePath = path.join(suitesDir, filename);
2284
+ if (!await fs.pathExists(filePath)) return null;
2285
+ try {
2286
+ const content = await fs.readFile(filePath, 'utf-8');
2287
+ return yaml.parse(content);
2288
+ } catch { return null; }
2289
+ }
2290
+ async function writeSuiteFile(suitesDir, filename, data) {
2291
+ const filePath = path.join(suitesDir, filename);
2292
+ await fs.writeFile(filePath, yaml.stringify(data));
2293
+ }
2294
+ async function ensureSavedTestsFile(suitesDir) {
2295
+ const fp = path.join(suitesDir, SAVED_TESTS_FILE);
2296
+ if (!await fs.pathExists(fp)) {
2297
+ await fs.writeFile(fp, yaml.stringify({
2298
+ name: 'Saved Tests',
2299
+ description: 'Standalone tests not in any suite',
2300
+ tests: [],
2301
+ }));
2302
+ }
2303
+ }
2304
+
2305
+ async function listAllTests(suitesDir) {
2306
+ const files = await fs.readdir(suitesDir);
2307
+ const suiteFiles = files.filter(f => f.endsWith('.suite.yaml') || f.endsWith('.suite.yml'));
2308
+ const allTests = [];
2309
+
2310
+ for (const file of suiteFiles) {
2311
+ const data = await readSuiteFile(suitesDir, file);
2312
+ if (!data) continue;
2313
+ const scope = filenameToScope(file);
2314
+ const scopeName = file === SAVED_TESTS_FILE ? 'Saved tests' : (data.name || file);
2315
+
2316
+ for (const test of (data.tests || [])) {
2317
+ // Skip cross-reference entries — these are tests from another scope
2318
+ // that were added to this suite via the picker. They're stored with
2319
+ // a `scope` field pointing to their real home (e.g. 'saved').
2320
+ // They'll appear under their actual scope — no need to show them here too.
2321
+ if (test.scope && test.scope !== scope) continue;
2322
+
2323
+ const id = test.id || testIdFromName(test.name);
2324
+ allTests.push({
2325
+ id,
2326
+ scope,
2327
+ scopeName,
2328
+ scopeIsSaved: scope === 'saved',
2329
+ name: test.name || 'Untitled',
2330
+ type: test.type || 'goal',
2331
+ url: test.url,
2332
+ goal: test.goal,
2333
+ steps: test.steps || [],
2334
+ playwrightJs: test.playwrightJs || '',
2335
+ playwrightTs: test.playwrightTs || '',
2336
+ reusable: test.reusable || false,
2337
+ setup: test.setup || null,
2338
+ maxSteps: test.maxSteps,
2339
+ tags: test.tags || [],
2340
+ provider: test.provider,
2341
+ model: test.model,
2342
+ credentials: test.credentials || '',
2343
+ });
2344
+ }
2345
+ }
2346
+ return allTests;
2347
+ }
2348
+
2349
+ async function getTest(suitesDir, scope, testId) {
2350
+ const filename = scopeToFilename(scope);
2351
+ const data = await readSuiteFile(suitesDir, filename);
2352
+ if (!data || !data.tests) return null;
2353
+ const test = data.tests.find(t => (t.id || testIdFromName(t.name)) === testId);
2354
+ if (!test) return null;
2355
+ // If this is a cross-reference (test lives in another scope), load from there
2356
+ if (test.scope && test.scope !== scope) {
2357
+ return getTest(suitesDir, test.scope, test.id || testIdFromName(test.name));
2358
+ }
2359
+ return { ...test, id: test.id || testIdFromName(test.name), scope };
2360
+ }
2361
+
2362
+ async function createTest(suitesDir, scope, testData) {
2363
+ const filename = scopeToFilename(scope);
2364
+ if (scope === 'saved') await ensureSavedTestsFile(suitesDir);
2365
+ const data = await readSuiteFile(suitesDir, filename);
2366
+ if (!data) throw new Error('Scope not found: ' + scope);
2367
+
2368
+ data.tests = data.tests || [];
2369
+
2370
+ const id = testIdFromName(testData.name);
2371
+
2372
+ // Check uniqueness within scope
2373
+ if (data.tests.some(t => (t.id || testIdFromName(t.name)) === id)) {
2374
+ throw new Error(`A test named "${testData.name}" already exists in this scope`);
2375
+ }
2376
+
2377
+ const newTest = { id, ...cleanTest(testData) };
2378
+ data.tests.push(newTest);
2379
+ await writeSuiteFile(suitesDir, filename, data);
2380
+ return { ...newTest, scope };
2381
+ }
2382
+
2383
+ async function updateTest(suitesDir, scope, testId, testData) {
2384
+ const filename = scopeToFilename(scope);
2385
+ const data = await readSuiteFile(suitesDir, filename);
2386
+ if (!data || !data.tests) throw new Error('Test not found');
2387
+
2388
+ const idx = data.tests.findIndex(t => (t.id || testIdFromName(t.name)) === testId);
2389
+ if (idx === -1) throw new Error('Test not found');
2390
+
2391
+ // If renaming, check the new name doesn't collide with a different test
2392
+ const newId = testIdFromName(testData.name);
2393
+ if (newId !== testId && data.tests.some((t, i) => i !== idx && (t.id || testIdFromName(t.name)) === newId)) {
2394
+ throw new Error(`A test named "${testData.name}" already exists in this scope`);
2395
+ }
2396
+
2397
+ const existing = data.tests[idx];
2398
+ const merged = { ...existing, ...testData };
2399
+ data.tests[idx] = { id: newId, ...cleanTest(merged) };
2400
+ await writeSuiteFile(suitesDir, filename, data);
2401
+ return { ...data.tests[idx], scope };
2402
+ }
2403
+
2404
+ async function deleteTest(suitesDir, scope, testId) {
2405
+ const filename = scopeToFilename(scope);
2406
+ const data = await readSuiteFile(suitesDir, filename);
2407
+ if (!data || !data.tests) return;
2408
+ data.tests = data.tests.filter(t => (t.id || testIdFromName(t.name)) !== testId);
2409
+ await writeSuiteFile(suitesDir, filename, data);
2410
+ }
2411
+
2412
+ async function duplicateTest(suitesDir, scope, testId) {
2413
+ const test = await getTest(suitesDir, scope, testId);
2414
+ if (!test) return null;
2415
+
2416
+ // Strip the id and scope from the source test, copy everything else
2417
+ const { scope: _scope, id: _id, ...testData } = test;
2418
+
2419
+ // Build a unique copy name: "Login test" -> "Login test (copy)", "Login test (copy 2)", etc.
2420
+ const filename = scopeToFilename(scope);
2421
+ const data = await readSuiteFile(suitesDir, filename);
2422
+ const existingNames = new Set((data?.tests || []).map(t => t.name));
2423
+ const baseName = (testData.name || 'Test').replace(/\s*\(copy(?:\s+\d+)?\)\s*$/i, '');
2424
+ let newName = baseName + ' (copy)';
2425
+ let copyNum = 2;
2426
+ while (existingNames.has(newName)) {
2427
+ newName = baseName + ' (copy ' + copyNum + ')';
2428
+ copyNum++;
2429
+ }
2430
+ testData.name = newName;
2431
+
2432
+ return await createTest(suitesDir, scope, testData);
2433
+ }
2434
+
2435
+ async function moveTest(suitesDir, fromScope, testId, toScope) {
2436
+ if (fromScope === toScope) return { moved: false };
2437
+ const test = await getTest(suitesDir, fromScope, testId);
2438
+ if (!test) throw new Error('Test not found');
2439
+
2440
+ // Strip the scope from test before adding
2441
+ const { scope: _omit, id: _id, ...testData } = test;
2442
+
2443
+ // Will throw if duplicate name in target scope
2444
+ const created = await createTest(suitesDir, toScope, testData);
2445
+ await deleteTest(suitesDir, fromScope, testId);
2446
+ return { moved: true, newScope: toScope, test: created };
2447
+ }
2448
+
2449
+ function cleanTest(t) {
2450
+ const out = { name: t.name };
2451
+ if (t.goal) out.goal = t.goal;
2452
+ if (t.type) out.type = t.type;
2453
+ if (t.steps) out.steps = t.steps;
2454
+ if (t.playwrightJs) out.playwrightJs = t.playwrightJs;
2455
+ if (t.playwrightTs) out.playwrightTs = t.playwrightTs;
2456
+ if (t.reusable) out.reusable = true;
2457
+ if (t.setup) out.setup = t.setup;
2458
+ if (t.url) out.url = t.url;
2459
+ if (t.maxSteps) out.maxSteps = t.maxSteps;
2460
+ if (t.tags && t.tags.length > 0) out.tags = t.tags;
2461
+ if (t.provider) out.provider = t.provider;
2462
+ if (t.model) out.model = t.model;
2463
+ if (t.credentials) out.credentials = t.credentials;
2464
+ if (t.github) out.github = true;
2465
+ if (t.jira) out.jira = true;
2466
+ if (t.linear) out.linear = true;
2467
+ if (t.headless) out.headless = true;
2468
+ return out;
2469
+ }
2470
+
2471
+ // ─── SUITE RUNS ───────────────────────────────────────────────────────────────
2472
+ async function listSuiteRuns(suiteRunsDir, reportsDir) {
2473
+ if (!await fs.pathExists(suiteRunsDir)) return [];
2474
+ const files = await fs.readdir(suiteRunsDir);
2475
+ const runs = [];
2476
+ for (const file of files) {
2477
+ if (!file.endsWith('.json')) continue;
2478
+ try {
2479
+ const data = await fs.readJson(path.join(suiteRunsDir, file));
2480
+ const stat = await fs.stat(path.join(suiteRunsDir, file));
2481
+ runs.push({
2482
+ ...data,
2483
+ when: relativeTime(stat.mtime),
2484
+ mtime: stat.mtime.toISOString(),
2485
+ });
2486
+ } catch {}
2487
+ }
2488
+ runs.sort((a, b) => new Date(b.mtime) - new Date(a.mtime));
2489
+ return runs;
2490
+ }
2491
+
2492
+ async function getSuiteRun(suiteRunsDir, reportsDir, id) {
2493
+ const filePath = path.join(suiteRunsDir, id + '.json');
2494
+ if (!await fs.pathExists(filePath)) return null;
2495
+ const data = await fs.readJson(filePath);
2496
+
2497
+ // Resolve session details for each test
2498
+ const enrichedResults = [];
2499
+ for (const result of (data.results || [])) {
2500
+ if (result.sessionId) {
2501
+ const session = await getSession(reportsDir, result.sessionId);
2502
+ if (session) {
2503
+ const status = session.goalAchieved !== undefined ? (session.goalAchieved ? 'passed' : session.stuck ? 'stuck' : 'failed') : (result.status || 'unknown');
2504
+ enrichedResults.push({
2505
+ ...result,
2506
+ status,
2507
+ duration: formatDuration(session.duration || 0),
2508
+ steps: session.steps?.length || 0,
2509
+ issues: session.issues?.length || 0,
2510
+ hasReport: true,
2511
+ });
2512
+ continue;
2513
+ }
2514
+ }
2515
+ // Session not on this server (ran on agent machine) — use stored result
2516
+ enrichedResults.push({ ...result, hasReport: false });
2517
+ }
2518
+ return { ...data, results: enrichedResults };
2519
+ }
2520
+
2521
+ async function startSuiteRun(suitesDir, filename, activeRuns, suiteRunsDir, reportsDir, currentUser, userEnv, agentHelpers = {}) {
2522
+ const { getLeastBusyAgent = () => null, sendToAgent = () => {}, broadcastAgentList = () => {} } = agentHelpers;
2523
+ const runBy = currentUser ? {
2524
+ id: currentUser.id, name: currentUser.name,
2525
+ email: currentUser.email, role: currentUser.role,
2526
+ } : null;
2527
+ const suite = await getSuite(suitesDir, filename);
2528
+ if (!suite || !suite.tests || suite.tests.length === 0) return null;
2529
+
2530
+ const runId = Math.random().toString(36).slice(2, 10);
2531
+ const startedAt = new Date().toISOString();
2532
+ const run = {
2533
+ id: runId, type: 'suite',
2534
+ suiteName: suite.name, suiteFilename: filename,
2535
+ status: 'running', output: [], listeners: [],
2536
+ sessionIds: [], testResults: [], startedAt,
2537
+ };
2538
+ activeRuns.set(runId, run);
2539
+
2540
+ const broadcast = (line) => {
2541
+ run.output.push(line);
2542
+ run.listeners.forEach(l => l(line));
2543
+ };
2544
+
2545
+ (async () => {
2546
+ // Determine concurrency: from suite.parallel (default 1 for backwards compat)
2547
+ const parallel = Math.max(1, Math.min(5, suite.parallel || suite.defaults?.parallel || 1));
2548
+ const isParallel = parallel > 1;
2549
+
2550
+ broadcast({ type: 'stdout', text: `\n━━━ Suite: ${suite.name} ━━━` });
2551
+ broadcast({ type: 'stdout', text: `${suite.tests.length} test(s) to run${isParallel ? ` (running ${parallel} in parallel)` : ''}\n` });
2552
+
2553
+ let completedCount = 0;
2554
+ let runningCount = 0;
2555
+
2556
+ // Pre-resolve test configs (skip invalid ones up front)
2557
+ const testTasks = suite.tests.map((test, i) => ({
2558
+ index: i,
2559
+ test,
2560
+ config: {
2561
+ url: test.url || suite.defaults?.url,
2562
+ goal: test.goal,
2563
+ credentials: test.credentials || suite.defaults?.credentials,
2564
+ maxSteps: test.maxSteps || suite.defaults?.maxSteps || 20,
2565
+ provider: test.provider || suite.defaults?.provider || 'gemini',
2566
+ model: test.model || suite.defaults?.model,
2567
+ github: test.github !== undefined ? test.github : suite.defaults?.github,
2568
+ jira: test.jira !== undefined ? test.jira : suite.defaults?.jira,
2569
+ linear: test.linear !== undefined ? test.linear : suite.defaults?.linear,
2570
+ headless: test.headless !== undefined ? test.headless : suite.defaults?.headless,
2571
+ testName: test.name,
2572
+ suiteName: suite.name,
2573
+ },
2574
+ }));
2575
+
2576
+ // Build a per-test broadcast that tags lines with [T<n>] in parallel mode
2577
+ const taggedBroadcast = (workerNum) => (line) => {
2578
+ if (isParallel && (line.type === 'stdout' || line.type === 'stderr')) {
2579
+ broadcast({ ...line, text: `[T${workerNum}] ${line.text}` });
2580
+ } else if (line.type === 'sessionId') {
2581
+ // Pass session IDs through directly so the dashboard can pick them up
2582
+ broadcast(line);
2583
+ } else if (line.type !== 'done' && line.type !== 'suiteProgress') {
2584
+ // Non-stdout (e.g. status updates) we just pass along
2585
+ broadcast(line);
2586
+ }
2587
+ };
2588
+
2589
+ // Execute a single test task
2590
+ const runOne = async (task, workerNum) => {
2591
+ const t = task.test;
2592
+ runningCount++;
2593
+ broadcast({ type: 'suiteProgress', current: completedCount + 1, total: suite.tests.length, testName: t.name, runningCount });
2594
+ const header = `\n━━━ Test ${task.index + 1}/${suite.tests.length}${isParallel ? ` [worker T${workerNum}]` : ''}: ${t.name} ━━━`;
2595
+ broadcast({ type: 'stdout', text: header });
2596
+
2597
+ // Recorded tests — run via startReplay
2598
+ // May need to look up full test data if suite only stored id/name/scope
2599
+ let fullTest = t;
2600
+ if (t.type === 'recorded' || (!t.goal && (t.id || t.scope))) {
2601
+ try {
2602
+ const scope = t.scope || 'saved';
2603
+ const testId = t.id || testIdFromName(t.name);
2604
+ const loaded = await getTest(suitesDir, scope, testId);
2605
+ if (loaded) fullTest = { ...loaded, ...t }; // merge, test fields take priority
2606
+ } catch {}
2607
+ }
2608
+
2609
+ if (fullTest.type === 'recorded' || (fullTest.steps && fullTest.steps.length)) {
2610
+ if (!fullTest.steps || !fullTest.steps.length) {
2611
+ broadcast({ type: 'stdout', text: `[SKIP] Recorded test "${fullTest.name}" has no steps` });
2612
+ run.testResults.push({ name: fullTest.name, testId: fullTest.id || testIdFromName(fullTest.name), status: 'skipped' });
2613
+ runningCount--; completedCount++; return;
2614
+ }
2615
+ // Load setup if referenced
2616
+ let setupTest = null;
2617
+ if (fullTest.setup) {
2618
+ try {
2619
+ const all = await listAllTests(suitesDir);
2620
+ const ref = all.find(x => x.id === fullTest.setup || x.name === fullTest.setup);
2621
+ if (ref) setupTest = await getTest(suitesDir, ref.scope, ref.id);
2622
+ } catch {}
2623
+ }
2624
+ // Resolve credentials
2625
+ if (fullTest.credentials) {
2626
+ const creds = await resolveCredentials(fullTest.credentials);
2627
+ if (creds) {
2628
+ fullTest.steps = fullTest.steps.map(s => s.action === 'type' && s.isPassword && creds.password ? { ...s, value: creds.password } : s);
2629
+ }
2630
+ }
2631
+ // Dispatch to agent if available, otherwise run locally
2632
+ const suiteAgent = getLeastBusyAgent(runBy?.id);
2633
+ let replayRunId;
2634
+
2635
+ if (suiteAgent) {
2636
+ // Run on agent machine
2637
+ replayRunId = Math.random().toString(36).slice(2, 10);
2638
+ const agentRun = { id: replayRunId, type: 'replay', status: 'running', output: [], listeners: [], sessionId: replayRunId };
2639
+ activeRuns.set(replayRunId, agentRun);
2640
+ suiteAgent.status = 'replaying';
2641
+ suiteAgent.currentJob = { type: 'replay', runId: replayRunId, testName: fullTest.name };
2642
+ broadcastAgentList();
2643
+ sendToAgent(suiteAgent, { type: 'replay', runId: replayRunId, test: fullTest, setupTest, env: userEnv || {} });
2644
+ broadcast({ type: 'stdout', text: ` → Dispatched to agent: ${suiteAgent.name}` });
2645
+ } else {
2646
+ // Run locally
2647
+ replayRunId = startReplay(fullTest, setupTest, activeRuns, reportsDir, { id: runBy?.id, name: runBy?.name, email: runBy?.email, role: runBy?.role }, userEnv);
2648
+ }
2649
+ // Wait for replay to complete
2650
+ await new Promise(resolve => {
2651
+ const replayRun = activeRuns.get(replayRunId);
2652
+ if (!replayRun) { resolve(); return; }
2653
+ if (replayRun.status !== 'running') { resolve(); return; }
2654
+ const check = setInterval(() => {
2655
+ const r = activeRuns.get(replayRunId);
2656
+ if (!r || r.status !== 'running') { clearInterval(check); resolve(); }
2657
+ }, 500);
2658
+ });
2659
+ const replayRun = activeRuns.get(replayRunId);
2660
+ const passed = replayRun ? replayRun.status === 'passed' : false;
2661
+ const sessionId = replayRun ? replayRun.sessionId : null;
2662
+ if (sessionId) { run.sessionIds.push(sessionId); broadcast({ type: 'sessionId', sessionId }); }
2663
+ broadcast({ type: 'stdout', text: `\n Result: ${passed ? 'PASSED ✓' : 'FAILED ✗'}` });
2664
+ run.testResults.push({ name: fullTest.name, testId: fullTest.id || testIdFromName(fullTest.name), status: passed ? 'passed' : 'failed', sessionId });
2665
+ runningCount--; completedCount++;
2666
+ return;
2667
+ }
2668
+
2669
+ // Goal-based tests (legacy)
2670
+ if (!task.config.url || !task.config.goal) {
2671
+ broadcast({ type: 'stdout', text: `[SKIP] Test "${t.name}" has no url or goal` });
2672
+ run.testResults.push({ name: t.name, testId: t.id || testIdFromName(t.name), status: 'skipped' });
2673
+ runningCount--;
2674
+ completedCount++;
2675
+ return;
2676
+ }
2677
+
2678
+ const result = await runSingleTestSync(task.config, isParallel ? taggedBroadcast(workerNum) : broadcast, userEnv, reportsDir, runBy);
2679
+ run.testResults.push({
2680
+ name: t.name,
2681
+ testId: t.id || testIdFromName(t.name),
2682
+ status: result.status,
2683
+ sessionId: result.sessionId,
2684
+ });
2685
+ if (result.sessionId) run.sessionIds.push(result.sessionId);
2686
+ runningCount--;
2687
+ completedCount++;
2688
+ broadcast({ type: 'suiteProgress', current: completedCount, total: suite.tests.length, testName: t.name, runningCount });
2689
+ };
2690
+
2691
+ // Pool execution: keep up to N workers busy at all times
2692
+ let nextIdx = 0;
2693
+ const workers = [];
2694
+ for (let w = 1; w <= parallel; w++) {
2695
+ workers.push((async () => {
2696
+ while (nextIdx < testTasks.length) {
2697
+ const task = testTasks[nextIdx++];
2698
+ await runOne(task, w);
2699
+ // Wait between tests on this worker (sequential or parallel)
2700
+ if (nextIdx < testTasks.length) {
2701
+ const wait = suite.waitBetweenTests || (isParallel ? 500 : 2000);
2702
+ await new Promise(r => setTimeout(r, wait));
2703
+ }
2704
+ }
2705
+ })());
2706
+ }
2707
+ await Promise.all(workers);
2708
+
2709
+ const passed = run.testResults.filter(r => r.status === 'passed').length;
2710
+ const failed = run.testResults.filter(r => r.status !== 'passed' && r.status !== 'skipped').length;
2711
+ run.status = failed === 0 ? 'passed' : 'failed';
2712
+
2713
+ // Persist the suite run record
2714
+ const record = {
2715
+ id: runId,
2716
+ suiteName: suite.name,
2717
+ suiteFilename: filename,
2718
+ startedAt,
2719
+ finishedAt: new Date().toISOString(),
2720
+ status: run.status,
2721
+ passed,
2722
+ failed,
2723
+ total: run.testResults.length,
2724
+ results: run.testResults,
2725
+ };
2726
+ try {
2727
+ await fs.writeJson(path.join(suiteRunsDir, runId + '.json'), record, { spaces: 2 });
2728
+ } catch (err) {
2729
+ console.error('Failed to write suite run record:', err);
2730
+ }
2731
+
2732
+ broadcast({ type: 'stdout', text: '\n━━━ Suite Complete ━━━' });
2733
+ broadcast({ type: 'stdout', text: `${passed} passed, ${failed} failed of ${run.testResults.length}` });
2734
+ broadcast({ type: 'done', status: run.status, results: run.testResults, suiteRunId: runId });
2735
+
2736
+ setTimeout(() => activeRuns.delete(runId), 60000);
2737
+ })();
2738
+
2739
+ return { runId };
2740
+ }
2741
+
2742
+ async function resolveCredsToFile(credRef) {
2743
+ if (!credRef) return null;
2744
+ // Already a file path? use as-is
2745
+ if (credRef.includes('/') || credRef.endsWith('.yaml') || credRef.endsWith('.yml')) {
2746
+ if (await fs.pathExists(credRef)) return credRef;
2747
+ }
2748
+ // Look up by name in vault, write to temp file
2749
+ const data = await loadCredentialsFile();
2750
+ if (!data[credRef]) return null;
2751
+ const tmpPath = path.join(os.tmpdir(), `skopix-creds-${Date.now()}-${Math.random().toString(36).slice(2,6)}.yaml`);
2752
+ // CLI expects format:
2753
+ // credentials:
2754
+ // - label: "Main account"
2755
+ // fields:
2756
+ // username: ...
2757
+ // password: ...
2758
+ const fileFormat = {
2759
+ credentials: [
2760
+ { label: credRef, fields: data[credRef] }
2761
+ ]
2762
+ };
2763
+ await fs.writeFile(tmpPath, yaml.stringify(fileFormat));
2764
+ // Cleanup after 60s
2765
+ setTimeout(() => fs.remove(tmpPath).catch(() => {}), 60000);
2766
+ return tmpPath;
2767
+ }
2768
+
2769
+ function runSingleTestSync(config, broadcast, userEnv, reportsDir, runBy) {
2770
+ return new Promise(async (resolve) => {
2771
+ const args = ['run', '--url', config.url, '--goal', config.goal];
2772
+ const credsFile = await resolveCredsToFile(config.credentials);
2773
+ if (credsFile) args.push('--credentials', credsFile);
2774
+ if (config.maxSteps) args.push('--max-steps', String(config.maxSteps));
2775
+ if (config.provider) args.push('--provider', config.provider);
2776
+ if (config.model) args.push('--model', config.model);
2777
+ if (config.github) args.push('--github');
2778
+ if (config.jira) args.push('--jira');
2779
+ if (config.linear) args.push('--linear');
2780
+ if (config.headless) args.push('--headless');
2781
+ if (config.testName) args.push('--test-name', config.testName);
2782
+ if (config.suiteName) args.push('--suite-name', config.suiteName);
2783
+
2784
+ const cliPath = path.resolve(__dirname, '..', 'index.js');
2785
+ const childEnv = { ...process.env, ...(userEnv || {}) };
2786
+ const child = spawn('node', [cliPath, ...args], { cwd: process.cwd(), env: childEnv });
2787
+ let sessionId = null;
2788
+
2789
+ child.stdout.on('data', (chunk) => {
2790
+ const text = chunk.toString();
2791
+ text.split('\n').forEach(line => { if (line.length > 0) broadcast({ type: 'stdout', text: line }); });
2792
+ const m = text.match(/Session:\s+([a-f0-9]{8})/);
2793
+ if (m && !sessionId) {
2794
+ sessionId = m[1];
2795
+ // Persist run attribution (for suite tests, mirrors what startRun does for single tests)
2796
+ if (runBy && reportsDir) {
2797
+ const sessionDir = path.join(reportsDir, sessionId);
2798
+ fs.ensureDir(sessionDir).then(() => {
2799
+ return fs.writeJson(path.join(sessionDir, 'runBy.json'), runBy, { spaces: 2 });
2800
+ }).catch(() => {});
2801
+ }
2802
+ }
2803
+ });
2804
+ child.stderr.on('data', (chunk) => {
2805
+ chunk.toString().split('\n').forEach(line => { if (line.length > 0) broadcast({ type: 'stderr', text: line }); });
2806
+ });
2807
+ child.on('close', (code) => resolve({ status: code === 0 ? 'passed' : 'failed', sessionId }));
2808
+ child.on('error', (err) => {
2809
+ broadcast({ type: 'stderr', text: 'Error: ' + err.message });
2810
+ resolve({ status: 'error', sessionId });
2811
+ });
2812
+ });
2813
+ }
2814
+
2815
+ // ─── SELECTOR SANITISER ──────────────────────────────────────────────────────
2816
+ // Fixes dynamic selectors at replay time — e.g. Panintelligence appends runtime
2817
+ // IDs to pi-test-identifier values: "ChartColumn.sort.desc.885249556"
2818
+ // We strip the trailing number and use *= (contains) instead of = (exact).
2819
+ function sanitiseSelector(sel) {
2820
+ if (!sel) return sel;
2821
+ // Match [attr="value.12345678"] where value ends in a dot + 5+ digits
2822
+ return sel.replace(/\[([a-zA-Z_-]+)="([^"]+\.\d{5,})"\]/g, (match, attr, val) => {
2823
+ // Strip the trailing .NUMBER from the value
2824
+ const stable = val.replace(/\.\d{5,}$/, '');
2825
+ return '[' + attr + '*="' + stable + '"]';
2826
+ });
2827
+ }
2828
+
2829
+ // ─── REPLAY ENGINE ───────────────────────────────────────────────────────────
2830
+ // Executes recorded steps deterministically. setupTest runs first in the same
2831
+ // browser session, then the main test steps continue.
2832
+ // Called when user clicks Stop in the debug recording browser
2833
+ async function stopDebugRecording(recording, ctx, browser, broadcastRec) {
2834
+ recording.status = 'stopped';
2835
+ try { await ctx.close(); } catch {}
2836
+ try { await browser.close(); } catch {}
2837
+ broadcastRec({ type: 'done', steps: recording.steps });
2838
+ broadcastRec({ type: 'stopped' });
2839
+ }
2840
+
2841
+ function startReplay(test, setupTest, activeRuns, reportsDir, currentUser, env) {
2842
+ const runId = Math.random().toString(36).slice(2, 10);
2843
+ const run = { id: runId, type: 'replay', status: 'running', output: [], listeners: [], sessionId: runId };
2844
+ activeRuns.set(runId, run);
2845
+ const broadcast = (line) => { run.output.push(line); run.listeners.forEach(l => l(line)); };
2846
+ const runBy = currentUser ? { id: currentUser.id, name: currentUser.name, email: currentUser.email, role: currentUser.role } : null;
2847
+
2848
+ (async () => {
2849
+ const sessionDir = path.join(reportsDir, runId);
2850
+ await fs.ensureDir(sessionDir);
2851
+ if (runBy) await fs.writeJson(path.join(sessionDir, 'runBy.json'), runBy, { spaces: 2 }).catch(() => {});
2852
+
2853
+ const startedAt = Date.now();
2854
+ broadcast({ type: 'stdout', text: '' });
2855
+ broadcast({ type: 'stdout', text: ' AI-powered QA agent. Tests your app like a human would.' });
2856
+ broadcast({ type: 'stdout', text: '' });
2857
+ broadcast({ type: 'stdout', text: '\u2501'.repeat(60) });
2858
+ broadcast({ type: 'sessionId', sessionId: runId });
2859
+ broadcast({ type: 'stdout', text: ' Session: ' + runId });
2860
+ broadcast({ type: 'stdout', text: ' Target: ' + (test.url || '') });
2861
+ broadcast({ type: 'stdout', text: ' Test: ' + test.name });
2862
+ if (setupTest) broadcast({ type: 'stdout', text: ' Setup: ' + setupTest.name + ' (' + (setupTest.steps||[]).length + ' steps)' });
2863
+ const totalSteps = (setupTest ? (setupTest.steps||[]).length : 0) + test.steps.length;
2864
+ broadcast({ type: 'stdout', text: ' Mode: Recorded replay (' + totalSteps + ' steps)' });
2865
+ broadcast({ type: 'stdout', text: '\u2501'.repeat(60) });
2866
+
2867
+ let passed = true;
2868
+ let stepNum = 0;
2869
+ let failReason = '';
2870
+ let chromiumBrowser = null;
2871
+
2872
+ // Helper that executes a list of steps on a page
2873
+ async function executeSteps(steps, page, label) {
2874
+ for (const step of steps) {
2875
+ stepNum++;
2876
+ const sel = sanitiseSelector(step.stableSelector || step.selector);
2877
+ const desc = step.description || (step.action + ' ' + (sel || ''));
2878
+ broadcast({ type: 'stdout', text: '' });
2879
+ broadcast({ type: 'stdout', text: ' [' + stepNum + '/' + totalSteps + '] ' + step.action.toUpperCase() + ' \u2014 ' + (label ? '[' + label + '] ' : '') + desc });
2880
+
2881
+ try {
2882
+ if (step.action === 'assert') {
2883
+ const assertSel = sanitiseSelector(step.stableSelector || step.selector);
2884
+ switch (step.assertType) {
2885
+ case 'visible':
2886
+ await page.locator(assertSel).first().waitFor({ state: 'visible', timeout: 10000 });
2887
+ broadcast({ type: 'stdout', text: ' \u2713 Visible: ' + assertSel });
2888
+ break;
2889
+ case 'text_contains': {
2890
+ const txt = await page.locator(assertSel).first().textContent({ timeout: 10000 });
2891
+ if (!txt || !txt.includes(step.value || '')) throw new Error('Expected to contain "' + step.value + '" but got "' + (txt||'').slice(0,50) + '"');
2892
+ broadcast({ type: 'stdout', text: ' \u2713 Text contains "' + step.value + '"' });
2893
+ break;
2894
+ }
2895
+ case 'text_equals': {
2896
+ const txt = await page.locator(assertSel).first().textContent({ timeout: 10000 });
2897
+ if ((txt||'').trim() !== (step.value||'').trim()) throw new Error('Expected "' + step.value + '" but got "' + (txt||'').slice(0,50) + '"');
2898
+ broadcast({ type: 'stdout', text: ' \u2713 Text equals "' + step.value + '"' });
2899
+ break;
2900
+ }
2901
+ case 'url_contains':
2902
+ if (!page.url().includes(step.value||'')) throw new Error('URL "' + page.url() + '" does not contain "' + step.value + '"');
2903
+ broadcast({ type: 'stdout', text: ' \u2713 URL contains "' + step.value + '"' });
2904
+ break;
2905
+
2906
+ case 'attribute_contains': {
2907
+ const attrName = step.attribute || 'title';
2908
+ const attrVal = await page.locator(assertSel).first().getAttribute(attrName, { timeout: 10000 });
2909
+ if (!attrVal || !attrVal.includes(step.value || '')) throw new Error('Attribute ' + attrName + ' "' + (attrVal||'').slice(0,80) + '" does not contain "' + step.value + '"');
2910
+ broadcast({ type: 'stdout', text: ' \u2713 ' + attrName + ' contains "' + step.value + '"' });
2911
+ break;
2912
+ }
2913
+ case 'element_count': {
2914
+ const count = await page.locator(assertSel).count();
2915
+ if (count !== parseInt(step.value||'0',10)) throw new Error('Expected ' + step.value + ' elements but found ' + count);
2916
+ broadcast({ type: 'stdout', text: ' \u2713 Count = ' + count });
2917
+ break;
2918
+ }
2919
+ default:
2920
+ broadcast({ type: 'stdout', text: ' \u26a0 Unknown assertion: ' + step.assertType + ' (skipped)' });
2921
+ }
2922
+
2923
+ } else if (step.action === 'navigate') {
2924
+ let navUrl = step.url || step.value;
2925
+ if (test.url && navUrl && navUrl.startsWith('http')) {
2926
+ try {
2927
+ const recOrigin = new URL(navUrl).origin;
2928
+ const tgtOrigin = new URL(test.url).origin;
2929
+ if (recOrigin !== tgtOrigin) { navUrl = navUrl.replace(recOrigin, tgtOrigin); broadcast({ type: 'stdout', text: ' (rebased URL)' }); }
2930
+ } catch {}
2931
+ }
2932
+ if (page.url() === navUrl) { broadcast({ type: 'stdout', text: ' \u2713 Already at URL (skipped)' }); }
2933
+ else {
2934
+ try { await page.goto(navUrl, { waitUntil: 'domcontentloaded', timeout: 15000 }); await page.waitForTimeout(800); broadcast({ type: 'stdout', text: ' \u2713 Navigated' }); }
2935
+ catch { if (page.url().includes((navUrl.split('#')[1]||navUrl).slice(0,20))) { broadcast({ type: 'stdout', text: ' \u2713 Navigated (hash route)' }); } else throw new Error('Navigation failed to ' + navUrl); }
2936
+ }
2937
+
2938
+ } else if (step.action === 'click') {
2939
+ await page.waitForTimeout(300);
2940
+ let clicked = false;
2941
+ const selectors = [];
2942
+ if (step.stableSelector && step.stableSelector !== step.selector) selectors.push(step.stableSelector);
2943
+ if (step.selector) selectors.push(step.selector);
2944
+ if (!clicked && (step.elementX || step.clickX)) {
2945
+ const targetX = step.elementX || step.clickX, targetY = step.elementY || step.clickY;
2946
+ for (const s of selectors) {
2947
+ if (clicked) break;
2948
+ try {
2949
+ const count = await page.locator(s).count();
2950
+ if (count > 1) {
2951
+ let bestIdx = 0, bestDist = Infinity;
2952
+ for (let i = 0; i < count; i++) { try { const box = await page.locator(s).nth(i).boundingBox({ timeout: 2000 }); if (!box) continue; const d = Math.sqrt(Math.pow(box.x+box.width/2-targetX,2)+Math.pow(box.y+box.height/2-targetY,2)); if (d < bestDist) { bestDist = d; bestIdx = i; } } catch {} }
2953
+ await page.locator(s).nth(bestIdx).click({ timeout: 5000 });
2954
+ if (count > 1) broadcast({ type: 'stdout', text: ' (matched element ' + (bestIdx+1) + ' of ' + count + ' by position)' });
2955
+ clicked = true;
2956
+ } else if (count === 1) { await page.locator(s).first().waitFor({ state: 'visible', timeout: 3000 }); await page.locator(s).first().click({ timeout: 5000 }); clicked = true; }
2957
+ } catch {}
2958
+ }
2959
+ }
2960
+ if (!clicked) { for (const s of selectors) { if (clicked) break; try { await page.locator(s).first().waitFor({ state: 'visible', timeout: 3000 }); await page.locator(s).first().click({ timeout: 5000 }); clicked = true; } catch {} } }
2961
+ if (!clicked) { for (const s of selectors) { if (clicked) break; try { await page.locator(s).first().click({ force: true, timeout: 5000 }); clicked = true; } catch {} } }
2962
+ if (!clicked && step.element) { const tag = (step.element.tag||'').toLowerCase(); if (['i','svg','path','span','img'].includes(tag)) { for (const s of selectors) { if (clicked) break; try { await page.locator(s).first().locator('xpath=ancestor-or-self::*[self::a or self::button or @role="button"][1]').first().click({ timeout: 5000 }); clicked = true; } catch {} } } }
2963
+ if (!clicked && step.element && step.element.text && step.element.text.length > 1) { try { await page.locator('*:has-text("' + step.element.text.replace(/"/g, '\\"') + '")').first().click({ timeout: 5000 }); clicked = true; } catch {} }
2964
+ if (!clicked) throw new Error('Could not click element. Tried: ' + selectors.join(', '));
2965
+ const descLower = (step.description||'').toLowerCase(), selLower = selectors.join(' ').toLowerCase();
2966
+ const isSave = descLower.includes('save')||descLower.includes('submit')||selLower.includes('fa-save')||selLower.includes('fa-floppy');
2967
+ await page.waitForTimeout(isSave ? 2000 : 500);
2968
+ broadcast({ type: 'stdout', text: ' \u2713 Clicked' + (isSave?' (waited for save)':'') });
2969
+
2970
+ } else if (step.action === 'check') {
2971
+ const checkSel = sanitiseSelector(step.stableSelector || step.selector);
2972
+ // Use click() rather than check() — fires the real click event Angular needs.
2973
+ // Verify current state first to avoid double-toggling.
2974
+ let alreadyCorrect = false;
2975
+ try {
2976
+ const isCurrentlyChecked = await page.locator(checkSel).first().isChecked({ timeout: 2000 });
2977
+ if (isCurrentlyChecked === step.checked) alreadyCorrect = true;
2978
+ } catch {}
2979
+ if (!alreadyCorrect) {
2980
+ await page.locator(checkSel).first().click({ timeout: 10000 });
2981
+ await page.waitForTimeout(400);
2982
+ }
2983
+ broadcast({ type: 'stdout', text: ' \u2713 ' + (step.checked ? 'Checked' : 'Unchecked') });
2984
+
2985
+ } else if (step.action === 'type') {
2986
+ const typeLocator = page.locator(sel).first();
2987
+ await typeLocator.click({ timeout: 10000 });
2988
+ await typeLocator.fill('');
2989
+ await typeLocator.pressSequentially(step.value || '', { delay: 50 });
2990
+ await typeLocator.evaluate(el => { el.dispatchEvent(new Event('blur',{bubbles:true})); el.dispatchEvent(new Event('change',{bubbles:true})); el.dispatchEvent(new Event('focusout',{bubbles:true})); });
2991
+ await page.waitForTimeout(400);
2992
+ broadcast({ type: 'stdout', text: ' \u2713 Typed' + (step.isPassword?' [password hidden]':' "' + (step.value||'').slice(0,30) + '"') });
2993
+
2994
+ } else if (step.action === 'select') {
2995
+ await page.locator(sel).first().selectOption(step.value || '', { timeout: 10000 });
2996
+ broadcast({ type: 'stdout', text: ' \u2713 Selected "' + (step.label||step.value||'') + '"' });
2997
+
2998
+ } else if (step.action === 'scroll') {
2999
+ if (step.isWindow || step.selector === 'window') { await page.evaluate(({x,y}) => window.scrollTo({left:x,top:y,behavior:'smooth'}), {x:step.scrollX||0,y:step.scrollY||0}); }
3000
+ else { await page.evaluate(({sel,x,y}) => { const el = document.querySelector(sel); if(el) el.scrollTo({left:x,top:y,behavior:'smooth'}); }, {sel,x:step.scrollX||0,y:step.scrollY||0}); }
3001
+ await page.waitForTimeout(600);
3002
+ broadcast({ type: 'stdout', text: ' \u2713 Scrolled to (' + (step.scrollX||0) + ', ' + (step.scrollY||0) + ')' });
3003
+
3004
+ } else {
3005
+ broadcast({ type: 'stdout', text: ' \u26a0 Unknown action: ' + step.action + ' (skipped)' });
3006
+ }
3007
+
3008
+ if (wantReport) {
3009
+ const screenshotPath = path.join(sessionDir, 'step-' + String(stepNum).padStart(3, '0') + '.png');
3010
+ await page.screenshot({ path: screenshotPath }).catch(() => {});
3011
+ }
3012
+
3013
+ } catch (stepErr) {
3014
+ broadcast({ type: 'stdout', text: ' \u2716 FAILED: ' + stepErr.message });
3015
+ failReason = stepErr.message;
3016
+ passed = false;
3017
+ return false; // signal caller to stop
3018
+ }
3019
+ }
3020
+ return true; // all steps passed
3021
+ }
3022
+
3023
+ try {
3024
+ const { chromium } = await import('playwright');
3025
+ const fsSync = await import('fs');
3026
+ const inDocker = fsSync.existsSync('/.dockerenv');
3027
+
3028
+ chromiumBrowser = await chromium.launch({
3029
+ headless: inDocker || test.headless || false,
3030
+ args: ['--no-sandbox', '--disable-blink-features=AutomationControlled', '--allow-insecure-localhost'],
3031
+ });
3032
+
3033
+ const wantReport = test.generateReport !== false;
3034
+
3035
+ const ctx = await chromiumBrowser.newContext({
3036
+ viewport: { width: 1280, height: 800 },
3037
+ ...(wantReport ? { recordVideo: { dir: sessionDir, size: { width: 1280, height: 800 } } } : {}),
3038
+ });
3039
+ const page = await ctx.newPage();
3040
+
3041
+ // Navigate to start URL
3042
+ if (test.url) {
3043
+ broadcast({ type: 'stdout', text: '- Navigating to ' + test.url + '...' });
3044
+ await page.goto(test.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
3045
+ broadcast({ type: 'stdout', text: '\u2714 Loaded: ' + test.url });
3046
+ }
3047
+
3048
+ const assertionCount = [...(setupTest ? setupTest.steps : []), ...test.steps].filter(s => s.action === 'assert').length;
3049
+ broadcast({ type: 'stdout', text: '\u25c6 Replaying ' + totalSteps + ' steps' + (assertionCount ? ' (' + assertionCount + ' assertions)' : '') });
3050
+
3051
+ // Run setup steps first
3052
+ if (setupTest && setupTest.steps && setupTest.steps.length) {
3053
+ broadcast({ type: 'stdout', text: '' });
3054
+ broadcast({ type: 'stdout', text: ' ─── SETUP: ' + setupTest.name + ' ───' });
3055
+ const ok = await executeSteps(setupTest.steps, page, 'setup');
3056
+ if (!ok) { await ctx.close(); throw new Error('Setup failed: ' + failReason); }
3057
+ broadcast({ type: 'stdout', text: '' });
3058
+ broadcast({ type: 'stdout', text: ' \u2714 Setup complete — continuing with test steps' });
3059
+ }
3060
+
3061
+ // Run main test steps
3062
+ if (setupTest) { broadcast({ type: 'stdout', text: '' }); broadcast({ type: 'stdout', text: ' ─── TEST: ' + test.name + ' ───' }); }
3063
+ await executeSteps(test.steps, page, null);
3064
+
3065
+ // Get video path after close
3066
+ try {
3067
+ const videoPath = await page.video().path();
3068
+ if (videoPath) {
3069
+ const videoName = 'replay' + path.extname(videoPath);
3070
+ await fs.move(videoPath, path.join(sessionDir, videoName), { overwrite: true }).catch(() => {});
3071
+ run.videoName = videoName;
3072
+ }
3073
+ } catch {}
3074
+
3075
+ await ctx.close();
3076
+ } catch (err) {
3077
+ if (!failReason) { broadcast({ type: 'stdout', text: '\u2716 Replay error: ' + err.message }); failReason = err.message; passed = false; }
3078
+ } finally {
3079
+ if (chromiumBrowser) try { await chromiumBrowser.close(); } catch {}
3080
+ }
3081
+
3082
+ broadcast({ type: 'stdout', text: '' });
3083
+ broadcast({ type: 'stdout', text: '\u2501'.repeat(60) });
3084
+ broadcast({ type: 'stdout', text: ' Status: ' + (passed ? 'PASSED \u2713' : 'FAILED \u2717') });
3085
+ if (!passed && failReason) broadcast({ type: 'stdout', text: ' Reason: ' + failReason });
3086
+ broadcast({ type: 'stdout', text: ' Steps: ' + stepNum + ' / ' + totalSteps });
3087
+ broadcast({ type: 'stdout', text: '\u2501'.repeat(60) });
3088
+
3089
+ // Write report.json
3090
+ const allSteps = [...(setupTest ? (setupTest.steps||[]) : []), ...(test.steps||[])];
3091
+ await fs.writeJson(path.join(sessionDir, 'report.json'), { sessionId: runId, goalAchieved: passed, stuck: false, url: test.url||'', goal: test.name + ' (recorded replay)', steps: allSteps.slice(0, stepNum), issues: [], duration: Date.now()-startedAt, model: 'recorded', provider: 'replay', type: 'replay', generateReport: wantReport, videoPath: run.videoName ? path.join(sessionDir, run.videoName) : null }, { spaces: 2 }).catch(() => {});
3092
+
3093
+ // Write report.html
3094
+ const assertionSteps = allSteps.filter(s => s.action === 'assert');
3095
+ const duration = ((Date.now() - startedAt) / 1000).toFixed(1);
3096
+ const stepRows = allSteps.slice(0, stepNum).map((s, i) => {
3097
+ const desc = (s.description || (s.action + ' ' + (s.stableSelector || s.selector || ''))).replace(/</g,'&lt;').replace(/>/g,'&gt;');
3098
+ const screenshotFile = 'step-' + String(i+1).padStart(3,'0') + '.png';
3099
+ const isAssert = s.action === 'assert';
3100
+ const isFailed = !passed && i === stepNum - 1;
3101
+ const isSetup = setupTest && i < (setupTest.steps||[]).length;
3102
+ if (isAssert) {
3103
+ const ad = (s.description || ((s.assertType||'assert') + (s.selector?' on '+s.selector.slice(0,40):'') + (s.value?' = "'+s.value+'"':''))).replace(/</g,'&lt;').replace(/>/g,'&gt;');
3104
+ return '<div class="step assert-step"><div class="step-num">'+String(i+1).padStart(2,'0')+'</div><div class="step-body"><div class="step-action assert-action">✓ '+(s.assertType||'assert')+'</div><div class="step-desc">'+ad+'</div></div></div>';
3105
+ }
3106
+ const actionColors = {click:'#22d3ee',type:'#a78bfa',navigate:'#fb923c',scroll:'#6b7280',check:'#34d399',select:'#f472b6'};
3107
+ const actionIcons = {click:'↗',type:'⌨',navigate:'→',scroll:'↕',check:'☑',select:'▼'};
3108
+ const color = actionColors[s.action]||'#9ca3af';
3109
+ const icon = actionIcons[s.action]||'·';
3110
+ const setupBadge = isSetup ? '<span style="font-size:9px;color:#f59e0b;background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.2);border-radius:3px;padding:1px 5px;margin-left:6px">SETUP</span>' : '';
3111
+ return '<div class="step'+(isFailed?' step-failed':'')+'" onclick="toggleScreenshot(this)">'
3112
+ + '<div class="step-num">'+String(i+1).padStart(2,'0')+'</div>'
3113
+ + '<div class="step-thumb"><img src="'+screenshotFile+'" onerror="this.closest(\'.step-thumb\').style.display=\'none\'" loading="lazy"></div>'
3114
+ + '<div class="step-body"><div class="step-action" style="color:'+color+'">'+icon+' '+s.action+setupBadge+'</div><div class="step-desc">'+desc+'</div></div>'
3115
+ + (isFailed?'<div class="step-fail-badge">✗ FAILED</div>':'')
3116
+ + '<div class="step-screenshot" style="display:none"><img src="'+screenshotFile+'" onerror="this.style.display=\'none\'"></div>'
3117
+ + '</div>';
3118
+ }).join('');
3119
+ const videoSection = run.videoName ? '<div class="video-panel"><div class="panel-label">SESSION VIDEO</div><video controls src="'+run.videoName+'"></video><div class="video-hint">Click any step to view screenshot</div></div>' : '<div class="video-panel no-video"><div class="panel-label">NO VIDEO</div></div>';
3120
+ const reportHtml = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Skopix — ${test.name}</title><style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}:root{--bg:#0a0c12;--surface:#111318;--surface2:#1a1d28;--border:#1e2235;--text:#e2e8f0;--muted:#64748b;--cyan:#22d3ee;--green:#22c55e;--red:#ef4444;--mono:'Fira Code','Consolas',monospace}body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;min-height:100vh}.header{background:var(--surface);border-bottom:1px solid var(--border);padding:16px 24px;display:flex;align-items:center;gap:16px;flex-wrap:wrap;position:sticky;top:0;z-index:100}.header-logo{font-family:var(--mono);font-size:12px;font-weight:700;color:var(--cyan);letter-spacing:0.1em}.header-title{font-size:15px;font-weight:700;color:var(--text);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.status-badge{display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:20px;font-size:11px;font-weight:700;font-family:var(--mono);white-space:nowrap}.passed{background:rgba(34,197,94,0.12);color:var(--green);border:1px solid rgba(34,197,94,0.25)}.failed{background:rgba(239,68,68,0.12);color:var(--red);border:1px solid rgba(239,68,68,0.25)}.stats-row{display:flex;background:var(--surface);border-bottom:1px solid var(--border)}.stat{flex:1;padding:12px 16px;text-align:center;border-right:1px solid var(--border)}.stat:last-child{border-right:none}.stat-value{font-family:var(--mono);font-size:20px;font-weight:700;color:var(--cyan)}.stat-label{font-size:9px;color:var(--muted);letter-spacing:0.1em;margin-top:2px;text-transform:uppercase}.layout{display:grid;grid-template-columns:380px 1fr;min-height:calc(100vh - 100px)}@media(max-width:860px){.layout{grid-template-columns:1fr}}.video-panel{background:#000;border-right:1px solid var(--border);position:sticky;top:100px;height:calc(100vh - 100px);display:flex;flex-direction:column}.panel-label{padding:8px 14px;font-family:var(--mono);font-size:10px;letter-spacing:0.12em;color:var(--muted);background:var(--surface2);border-bottom:1px solid var(--border);flex-shrink:0}.video-panel video{width:100%;flex:1;min-height:0;background:#000;display:block}.video-hint{padding:7px 14px;font-size:11px;color:var(--muted);text-align:center;background:var(--surface2);border-top:1px solid var(--border);flex-shrink:0}.no-video{justify-content:center;align-items:center;color:var(--muted);font-size:13px}.steps-panel{overflow-y:auto}.steps-header{padding:10px 16px;font-family:var(--mono);font-size:10px;letter-spacing:0.1em;color:var(--muted);background:var(--surface2);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:10}.step{display:grid;grid-template-columns:28px 52px 1fr auto;align-items:center;gap:10px;padding:9px 14px;border-bottom:1px solid var(--border);cursor:pointer;transition:background 0.1s}.step:hover{background:var(--surface2)}.step-failed{border-left:3px solid var(--red);background:rgba(239,68,68,0.04)}.step-failed:hover{background:rgba(239,68,68,0.08)}.assert-step{display:grid;grid-template-columns:28px 1fr;align-items:center;gap:10px;padding:9px 14px 9px 17px;border-bottom:1px solid var(--border);border-left:3px solid rgba(34,197,94,0.35);background:rgba(34,197,94,0.03)}.step-num{font-family:var(--mono);font-size:10px;color:var(--muted);text-align:right}.step-thumb{width:52px;height:34px;border-radius:3px;overflow:hidden;background:var(--surface2);flex-shrink:0}.step-thumb img{width:100%;height:100%;object-fit:cover;object-position:top;display:block}.step-body{min-width:0}.step-action{font-family:var(--mono);font-size:10px;font-weight:600;letter-spacing:0.05em}.assert-action{color:#4ade80;font-family:var(--mono);font-size:10px;font-weight:600}.step-desc{font-size:12px;color:var(--text);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}.step-fail-badge{font-family:var(--mono);font-size:10px;font-weight:700;color:var(--red);white-space:nowrap}.step-screenshot{grid-column:1/-1;padding:8px 0 4px}.step-screenshot img{width:100%;border-radius:5px;border:1px solid var(--border);display:block}</style></head><body>
3121
+ <div class="header"><div class="header-logo">SKOPIX</div><div class="header-title">${test.name}${setupTest?' <span style="font-size:11px;color:#f59e0b;font-weight:400">via '+setupTest.name+'</span>':''}</div><div class="status-badge ${passed?'passed':'failed'}">${passed?'✓ PASSED':'✗ FAILED'}</div></div>
3122
+ <div class="stats-row"><div class="stat"><div class="stat-value">${stepNum}</div><div class="stat-label">Steps</div></div><div class="stat"><div class="stat-value">${assertionSteps.length}</div><div class="stat-label">Assertions</div></div><div class="stat"><div class="stat-value">${duration}s</div><div class="stat-label">Duration</div></div><div class="stat"><div class="stat-value" style="font-size:11px;padding-top:6px">${test.url||''}</div><div class="stat-label">URL</div></div></div>
3123
+ <div class="layout">${videoSection}<div class="steps-panel"><div class="steps-header">STEPS (${stepNum} / ${totalSteps})${setupTest?' — amber = setup steps':''} — click to expand screenshot</div>${stepRows}</div></div>
3124
+ <script>function toggleScreenshot(row){const ss=row.querySelector('.step-screenshot');if(!ss)return;const isOpen=ss.style.display!=='none';document.querySelectorAll('.step-screenshot').forEach(el=>el.style.display='none');document.querySelectorAll('.step').forEach(el=>el.style.background='');if(!isOpen){ss.style.display='block';row.style.background='var(--surface2)';ss.scrollIntoView({behavior:'smooth',block:'nearest'});}}</script>
3125
+ </body></html>`;
3126
+ await fs.writeFile(path.join(sessionDir, 'report.html'), reportHtml).catch(() => {});
3127
+
3128
+ run.status = passed ? 'passed' : 'failed';
3129
+ broadcast({ type: 'done', exitCode: passed ? 0 : 1, status: run.status });
3130
+ setTimeout(() => activeRuns.delete(runId), 60000);
3131
+ })();
3132
+
3133
+ return runId;
3134
+ }
3135
+
3136
+ function startRun(config, activeRuns, reportsDir, currentUser, userEnv) {
3137
+ const runId = Math.random().toString(36).slice(2, 10);
3138
+ const run = { id: runId, type: 'single', status: 'running', output: [], listeners: [], sessionId: null };
3139
+ activeRuns.set(runId, run);
3140
+
3141
+ // Build the env for the child process. User-specific secrets (if any) override
3142
+ // workspace defaults from .skopix.env which the child loads via dotenv.
3143
+ // process.env wins over .skopix.env (dotenv doesn't overwrite existing vars),
3144
+ // and userEnv wins over process.env (last spread wins).
3145
+ const childEnv = { ...process.env, ...(userEnv || {}) };
3146
+
3147
+ const broadcast = (line) => { run.output.push(line); run.listeners.forEach(l => l(line)); };
3148
+
3149
+ // Run attribution - records who triggered this run.
3150
+ // Written as a small companion file alongside the session.
3151
+ const runBy = currentUser ? {
3152
+ id: currentUser.id,
3153
+ name: currentUser.name,
3154
+ email: currentUser.email,
3155
+ role: currentUser.role,
3156
+ } : null;
3157
+
3158
+ // Async credential resolve - kick off the spawn after we have the creds file
3159
+ (async () => {
3160
+ const args = ['run', '--url', config.url, '--goal', config.goal];
3161
+ const credsFile = await resolveCredsToFile(config.credentials);
3162
+ if (credsFile) args.push('--credentials', credsFile);
3163
+ if (config.maxSteps) args.push('--max-steps', String(config.maxSteps));
3164
+ if (config.provider) args.push('--provider', config.provider);
3165
+ if (config.model) args.push('--model', config.model);
3166
+ if (config.headless) args.push('--headless');
3167
+ if (config.github) args.push('--github');
3168
+ if (config.jira) args.push('--jira');
3169
+ if (config.linear) args.push('--linear');
3170
+ if (config.testName) args.push('--test-name', config.testName);
3171
+ if (config.suiteName) args.push('--suite-name', config.suiteName);
3172
+
3173
+ const cliPath = path.resolve(__dirname, '..', 'index.js');
3174
+ const child = spawn('node', [cliPath, ...args], { cwd: process.cwd(), env: childEnv });
3175
+
3176
+ child.stdout.on('data', (chunk) => {
3177
+ const text = chunk.toString();
3178
+ text.split('\n').forEach(line => { if (line.length > 0) broadcast({ type: 'stdout', text: line }); });
3179
+ const m = text.match(/Session:\s+([a-f0-9]{8})/);
3180
+ if (m && !run.sessionId) {
3181
+ run.sessionId = m[1];
3182
+ broadcast({ type: 'sessionId', sessionId: m[1] });
3183
+ // Persist run attribution
3184
+ if (runBy && reportsDir) {
3185
+ const sessionDir = path.join(reportsDir, run.sessionId);
3186
+ fs.ensureDir(sessionDir).then(() => {
3187
+ return fs.writeJson(path.join(sessionDir, 'runBy.json'), runBy, { spaces: 2 });
3188
+ }).catch(() => {});
3189
+ }
3190
+ }
3191
+ });
3192
+ child.stderr.on('data', (chunk) => {
3193
+ chunk.toString().split('\n').forEach(line => { if (line.length > 0) broadcast({ type: 'stderr', text: line }); });
3194
+ });
3195
+ child.on('close', (code) => {
3196
+ run.status = code === 0 ? 'passed' : 'failed';
3197
+ broadcast({ type: 'done', exitCode: code, status: run.status });
3198
+ setTimeout(() => activeRuns.delete(runId), 60000);
3199
+ });
3200
+ child.on('error', (err) => { broadcast({ type: 'error', error: err.message }); run.status = 'error'; });
3201
+ })();
3202
+
3203
+ return runId;
3204
+ }
3205
+
3206
+ function streamRun(req, res, runId, activeRuns) {
3207
+ const run = activeRuns.get(runId);
3208
+ if (!run) { sendJSON(res, 404, { error: 'Run not found' }); return; }
3209
+ res.writeHead(200, {
3210
+ 'Content-Type': 'text/event-stream',
3211
+ 'Cache-Control': 'no-cache',
3212
+ 'Connection': 'keep-alive',
3213
+ 'Access-Control-Allow-Origin': '*',
3214
+ });
3215
+ run.output.forEach(line => res.write(`data: ${JSON.stringify(line)}\n\n`));
3216
+ if (run.status !== 'running') {
3217
+ res.write(`data: ${JSON.stringify({ type: 'done', status: run.status })}\n\n`);
3218
+ res.end();
3219
+ return;
3220
+ }
3221
+ const listener = (line) => {
3222
+ if (res.writableEnded) return;
3223
+ try { res.write(`data: ${JSON.stringify(line)}\n\n`); } catch {}
3224
+ if (line.type === 'done') { try { res.end(); } catch {} }
3225
+ };
3226
+ run.listeners.push(listener);
3227
+ req.on('close', () => { run.listeners = run.listeners.filter(l => l !== listener); });
3228
+ }
3229
+
3230
+ function formatDuration(ms) {
3231
+ if (!ms || ms < 1000) return `${ms || 0}ms`;
3232
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
3233
+ const m = Math.floor(ms / 60000);
3234
+ const s = Math.round((ms % 60000) / 1000);
3235
+ return `${m}m ${s}s`;
3236
+ }
3237
+ function relativeTime(date) {
3238
+ const now = Date.now();
3239
+ const then = new Date(date).getTime();
3240
+ const diff = (now - then) / 1000;
3241
+ if (diff < 60) return 'just now';
3242
+ if (diff < 3600) return `${Math.floor(diff / 60)} mins ago`;
3243
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
3244
+ if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
3245
+ return new Date(date).toLocaleDateString();
3246
+ }
3247
+
3248
+
3249
+ // ─── CREDENTIALS ──────────────────────────────────────────────────────────────
3250
+ const CREDENTIALS_PATH = path.join(os.homedir(), '.skopix', 'credentials.yaml');
3251
+
3252
+ async function loadCredentialsFile() {
3253
+ try {
3254
+ if (!await fs.pathExists(CREDENTIALS_PATH)) return {};
3255
+ const content = await fs.readFile(CREDENTIALS_PATH, 'utf-8');
3256
+ return yaml.parse(content) || {};
3257
+ } catch (err) {
3258
+ console.error('Failed to read credentials file:', err);
3259
+ return {};
3260
+ }
3261
+ }
3262
+
3263
+ async function writeCredentialsFile(data) {
3264
+ await fs.ensureDir(path.dirname(CREDENTIALS_PATH));
3265
+ await fs.writeFile(CREDENTIALS_PATH, yaml.stringify(data));
3266
+ // Set restrictive permissions on Unix
3267
+ try { await fs.chmod(CREDENTIALS_PATH, 0o600); } catch {}
3268
+ }
3269
+
3270
+ async function listCredentials() {
3271
+ const data = await loadCredentialsFile();
3272
+ // Return masked - just names + key list, never values
3273
+ return Object.entries(data).map(([name, values]) => ({
3274
+ name,
3275
+ keys: Object.keys(values || {}),
3276
+ keyCount: Object.keys(values || {}).length,
3277
+ }));
3278
+ }
3279
+
3280
+ async function getCredentialSet(name) {
3281
+ const data = await loadCredentialsFile();
3282
+ if (!data[name]) return null;
3283
+ // Mask values: return key + length-aware mask
3284
+ const masked = {};
3285
+ for (const [k, v] of Object.entries(data[name])) {
3286
+ masked[k] = String(v).slice(0, 2) + '••••••••';
3287
+ }
3288
+ return masked;
3289
+ }
3290
+
3291
+ async function saveCredentialSet(name, values, oldName) {
3292
+ if (!name || !name.trim()) throw new Error('Credential set name is required');
3293
+ if (!values || typeof values !== 'object') throw new Error('Values must be an object');
3294
+ const cleanName = name.trim();
3295
+ // Reject names with whitespace or weird chars - keep keys clean
3296
+ if (!/^[a-z0-9_-]+$/i.test(cleanName)) throw new Error('Name can only contain letters, numbers, dashes and underscores');
3297
+
3298
+ const data = await loadCredentialsFile();
3299
+ if (oldName && oldName !== cleanName) {
3300
+ // Renaming - remove old entry
3301
+ delete data[oldName];
3302
+ }
3303
+ // Filter out empty keys/values - merge with existing if values are placeholder masks
3304
+ const existing = data[cleanName] || {};
3305
+ const cleaned = {};
3306
+ for (const [k, v] of Object.entries(values)) {
3307
+ if (!k || !k.trim()) continue;
3308
+ const trimKey = k.trim();
3309
+ // If value looks like a mask (contains ••), keep the existing value
3310
+ if (typeof v === 'string' && v.includes('•') && existing[trimKey]) {
3311
+ cleaned[trimKey] = existing[trimKey];
3312
+ } else {
3313
+ cleaned[trimKey] = v;
3314
+ }
3315
+ }
3316
+ data[cleanName] = cleaned;
3317
+ await writeCredentialsFile(data);
3318
+ }
3319
+
3320
+ async function deleteCredentialSet(name) {
3321
+ const data = await loadCredentialsFile();
3322
+ delete data[name];
3323
+ await writeCredentialsFile(data);
3324
+ }
3325
+
3326
+ // Resolve a credential reference to actual values (used during test execution)
3327
+ async function resolveCredentials(ref) {
3328
+ if (!ref) return null;
3329
+ // If it's a path that exists as a file, treat as old-style yaml file
3330
+ if (ref.includes('/') || ref.endsWith('.yaml') || ref.endsWith('.yml')) {
3331
+ if (await fs.pathExists(ref)) {
3332
+ try {
3333
+ const content = await fs.readFile(ref, 'utf-8');
3334
+ return yaml.parse(content);
3335
+ } catch { return null; }
3336
+ }
3337
+ }
3338
+ // Otherwise look up by name in the vault
3339
+ const data = await loadCredentialsFile();
3340
+ return data[ref] || null;
3341
+ }
3342
+
3343
+
3344
+ async function deleteSession(reportsDir, id) {
3345
+ const sessionPath = path.join(reportsDir, id);
3346
+ if (await fs.pathExists(sessionPath)) {
3347
+ await fs.remove(sessionPath);
3348
+ }
3349
+ }
3350
+
3351
+ async function deleteAllSessions(reportsDir) {
3352
+ if (!await fs.pathExists(reportsDir)) return;
3353
+ const entries = await fs.readdir(reportsDir);
3354
+ for (const entry of entries) {
3355
+ if (entry.startsWith('.')) continue; // preserve .suite-runs/
3356
+ const p = path.join(reportsDir, entry);
3357
+ const stat = await fs.stat(p);
3358
+ if (stat.isDirectory()) await fs.remove(p);
3359
+ }
3360
+ }
3361
+
3362
+ async function deleteSuiteRun(suiteRunsDir, reportsDir, id, cascade) {
3363
+ const filePath = path.join(suiteRunsDir, id + '.json');
3364
+ let sessionIds = [];
3365
+ if (cascade && await fs.pathExists(filePath)) {
3366
+ try {
3367
+ const data = await fs.readJson(filePath);
3368
+ sessionIds = (data.results || []).map(r => r.sessionId).filter(Boolean);
3369
+ } catch {}
3370
+ }
3371
+ if (await fs.pathExists(filePath)) await fs.remove(filePath);
3372
+ if (cascade) {
3373
+ for (const sid of sessionIds) {
3374
+ await deleteSession(reportsDir, sid);
3375
+ }
3376
+ }
3377
+ }
3378
+
3379
+ async function deleteAllSuiteRuns(suiteRunsDir) {
3380
+ if (!await fs.pathExists(suiteRunsDir)) return;
3381
+ const files = await fs.readdir(suiteRunsDir);
3382
+ for (const f of files) {
3383
+ if (f.endsWith('.json')) await fs.remove(path.join(suiteRunsDir, f));
3384
+ }
3385
+ }
3386
+
3387
+
3388
+ async function closeRemoteIssue(issue) {
3389
+ const axios = (await import('axios')).default;
3390
+ const dotenv = (await import('dotenv')).default;
3391
+ dotenv.config({ path: path.resolve(process.cwd(), '.skopix.env') });
3392
+ dotenv.config();
3393
+
3394
+ if (issue.tracker === 'github') {
3395
+ const { GITHUB_TOKEN, GITHUB_REPO } = process.env;
3396
+ if (!GITHUB_TOKEN || !GITHUB_REPO) throw new Error('GitHub env vars not set');
3397
+ await axios.patch(
3398
+ `https://api.github.com/repos/${GITHUB_REPO}/issues/${issue.trackerRef}`,
3399
+ { state: 'closed', state_reason: 'completed' },
3400
+ { headers: { Authorization: `token ${GITHUB_TOKEN}`, Accept: 'application/vnd.github.v3+json' } }
3401
+ );
3402
+ } else if (issue.tracker === 'jira') {
3403
+ const { JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN } = process.env;
3404
+ if (!JIRA_BASE_URL) throw new Error('Jira env vars not set');
3405
+ // Jira needs a transition - find the "Done" transition for this issue
3406
+ const transRes = await axios.get(
3407
+ `${JIRA_BASE_URL}/rest/api/3/issue/${issue.trackerRef}/transitions`,
3408
+ { auth: { username: JIRA_EMAIL, password: JIRA_API_TOKEN } }
3409
+ );
3410
+ const doneTransition = (transRes.data.transitions || []).find(t =>
3411
+ t.to?.statusCategory?.key === 'done' || /done|closed|resolved/i.test(t.name)
3412
+ );
3413
+ if (!doneTransition) throw new Error('No done transition available');
3414
+ await axios.post(
3415
+ `${JIRA_BASE_URL}/rest/api/3/issue/${issue.trackerRef}/transitions`,
3416
+ { transition: { id: doneTransition.id } },
3417
+ { auth: { username: JIRA_EMAIL, password: JIRA_API_TOKEN } }
3418
+ );
3419
+ } else if (issue.tracker === 'linear') {
3420
+ const { LINEAR_API_KEY, LINEAR_TEAM_ID } = process.env;
3421
+ if (!LINEAR_API_KEY) throw new Error('Linear env vars not set');
3422
+ // Find a completed state on this team
3423
+ const stateQuery = `query States($teamId: String!) { team(id: $teamId) { states { nodes { id type } } } }`;
3424
+ const stateRes = await axios.post(
3425
+ 'https://api.linear.app/graphql',
3426
+ { query: stateQuery, variables: { teamId: LINEAR_TEAM_ID } },
3427
+ { headers: { Authorization: LINEAR_API_KEY } }
3428
+ );
3429
+ const states = stateRes.data.data?.team?.states?.nodes || [];
3430
+ const doneState = states.find(s => s.type === 'completed');
3431
+ if (!doneState) throw new Error('No completed state available');
3432
+ const updateQuery = `mutation UpdateIssue($id: String!, $stateId: String!) { issueUpdate(id: $id, input: { stateId: $stateId }) { success } }`;
3433
+ await axios.post(
3434
+ 'https://api.linear.app/graphql',
3435
+ { query: updateQuery, variables: { id: issue.trackerRef, stateId: doneState.id } },
3436
+ { headers: { Authorization: LINEAR_API_KEY } }
3437
+ );
3438
+ } else {
3439
+ throw new Error('Unknown tracker: ' + issue.tracker);
3440
+ }
3441
+ }
3442
+
3443
+ // Build an env-var dictionary from a user's stored secrets (team mode only).
3444
+ // Returns {} in single-user mode or if user has no secrets.
3445
+ // GOOGLE_API_KEY <- GEMINI_API_KEY, ANTHROPIC_API_KEY <- CLAUDE_API_KEY (provide both aliases).
3446
+ async function resolveUserSecretsEnv(userId, teamMode) {
3447
+ if (!teamMode || !userId) return {};
3448
+ const keys = teamMode.db.getUserSecretKeys(userId);
3449
+ if (!keys.length) return {};
3450
+ const env = {};
3451
+ for (const { key } of keys) {
3452
+ const encrypted = teamMode.db.getUserSecret(userId, key);
3453
+ if (!encrypted) continue;
3454
+ try {
3455
+ const value = teamMode.auth.decryptSecret(encrypted);
3456
+ env[key] = value;
3457
+ // Provide common aliases so users don't have to set both
3458
+ if (key === 'GEMINI_API_KEY' && !env.GOOGLE_API_KEY) env.GOOGLE_API_KEY = value;
3459
+ if (key === 'GOOGLE_API_KEY' && !env.GEMINI_API_KEY) env.GEMINI_API_KEY = value;
3460
+ if (key === 'CLAUDE_API_KEY' && !env.ANTHROPIC_API_KEY) env.ANTHROPIC_API_KEY = value;
3461
+ if (key === 'ANTHROPIC_API_KEY' && !env.CLAUDE_API_KEY) env.CLAUDE_API_KEY = value;
3462
+ } catch (err) {
3463
+ // Decryption failed - log and skip. Could happen if SKOPIX_SECRET_KEY changed since the value was encrypted.
3464
+ console.error(`Failed to decrypt secret ${key} for user ${userId}: ${err.message}`);
3465
+ }
3466
+ }
3467
+ return env;
3468
+ }
3469
+
3470
+ async function syncIssuesStatus() {
3471
+ const axios = (await import('axios')).default;
3472
+ const dotenv = (await import('dotenv')).default;
3473
+ dotenv.config({ path: path.resolve(process.cwd(), '.skopix.env') });
3474
+ dotenv.config();
3475
+
3476
+ const store = await loadIssueStore();
3477
+ let updated = 0;
3478
+ let failed = 0;
3479
+
3480
+ for (const issue of store.issues) {
3481
+ if (issue.status !== 'open') continue;
3482
+ try {
3483
+ let liveStatus = null;
3484
+ if (issue.tracker === 'github') {
3485
+ const { GITHUB_TOKEN, GITHUB_REPO } = process.env;
3486
+ if (!GITHUB_TOKEN || !GITHUB_REPO) continue;
3487
+ const r = await axios.get(
3488
+ `https://api.github.com/repos/${GITHUB_REPO}/issues/${issue.trackerRef}`,
3489
+ { headers: { Authorization: `token ${GITHUB_TOKEN}`, Accept: 'application/vnd.github.v3+json' } }
3490
+ );
3491
+ liveStatus = r.data.state === 'closed' ? 'resolved' : 'open';
3492
+ } else if (issue.tracker === 'jira') {
3493
+ const { JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN } = process.env;
3494
+ if (!JIRA_BASE_URL) continue;
3495
+ const r = await axios.get(
3496
+ `${JIRA_BASE_URL}/rest/api/3/issue/${issue.trackerRef}?fields=status`,
3497
+ { auth: { username: JIRA_EMAIL, password: JIRA_API_TOKEN } }
3498
+ );
3499
+ const cat = r.data.fields?.status?.statusCategory?.key;
3500
+ liveStatus = cat === 'done' ? 'resolved' : 'open';
3501
+ } else if (issue.tracker === 'linear') {
3502
+ const { LINEAR_API_KEY } = process.env;
3503
+ if (!LINEAR_API_KEY) continue;
3504
+ const query = `query GetIssue($id: String!) { issue(id: $id) { state { type } } }`;
3505
+ const r = await axios.post(
3506
+ 'https://api.linear.app/graphql',
3507
+ { query, variables: { id: issue.trackerRef } },
3508
+ { headers: { Authorization: LINEAR_API_KEY } }
3509
+ );
3510
+ const t = r.data.data?.issue?.state?.type;
3511
+ liveStatus = (t === 'completed' || t === 'canceled') ? 'resolved' : 'open';
3512
+ }
3513
+ if (liveStatus && liveStatus !== issue.status) {
3514
+ issue.status = liveStatus;
3515
+ updated++;
3516
+ }
3517
+ } catch {
3518
+ failed++;
3519
+ }
3520
+ }
3521
+
3522
+ await saveIssueStore(store);
3523
+ return { updated, failed, total: store.issues.length };
3524
+ }