skopix 2.0.97 → 2.0.98

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.
@@ -190,6 +190,7 @@ export async function agentCommand(options) {
190
190
  if (msg.type === 'ping') { ws.send(JSON.stringify({ type: 'pong' })); return; }
191
191
  if (msg.type === 'record') { await handleRecord(msg); return; }
192
192
  if (msg.type === 'replay') { await handleReplay(msg); return; }
193
+ if (msg.type === 'debug-record') { await handleDebugRecord(msg); return; }
193
194
  });
194
195
 
195
196
  ws.addEventListener('close', () => {
@@ -380,6 +381,103 @@ export async function agentCommand(options) {
380
381
  }
381
382
  }
382
383
 
384
+ // ── DEBUG-RECORD JOB ────────────────────────────────────────────────────────
385
+ // Replays steps to a debug point on this agent (which has a display),
386
+ // then opens the recorder so user can add new steps from that state.
387
+ async function handleDebugRecord(msg) {
388
+ const { runId, recordingId, replaySteps, startUrl, env: jobEnv } = msg;
389
+ console.log(chalk.cyan(' ⏸ Debug replay: ') + chalk.white(replaySteps.length + ' steps to debug point'));
390
+
391
+ const sendRun = (data) => {
392
+ try { ws.send(JSON.stringify({ type: 'runUpdate', runId, data })); } catch {}
393
+ };
394
+ const sendRec = (data) => {
395
+ try { ws.send(JSON.stringify({ type: 'recordingUpdate', recordingId, data })); } catch {}
396
+ };
397
+
398
+ sendRun({ type: 'stdout', text: '' });
399
+ sendRun({ type: 'stdout', text: ' Replaying ' + replaySteps.length + ' steps to reach debug point...' });
400
+ sendRun({ type: 'stdout', text: '━'.repeat(60) });
401
+
402
+ try {
403
+ const { chromium } = await import('playwright');
404
+ const browser = await chromium.launch({ headless: false, args: ['--no-sandbox', '--disable-blink-features=AutomationControlled', '--allow-insecure-localhost'] });
405
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
406
+ const page = await ctx.newPage();
407
+
408
+ if (startUrl) {
409
+ await page.goto(startUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
410
+ await page.waitForTimeout(1000);
411
+ }
412
+
413
+ // Replay each step
414
+ let stepNum = 0;
415
+ for (const step of replaySteps) {
416
+ stepNum++;
417
+ sendRun({ type: 'stdout', text: ' [' + stepNum + '/' + replaySteps.length + '] ' + (step.action || '').toUpperCase() + ' — ' + (step.description || step.selector || '') });
418
+ try {
419
+ const sel = step.stableSelector || step.selector;
420
+ await executeStep(step, sel, page, { url: startUrl });
421
+ sendRun({ type: 'stdout', text: chalk.green ? '✓ Done' : '✓ Done' });
422
+ } catch (err) {
423
+ sendRun({ type: 'stdout', text: '✖ FAILED: ' + err.message });
424
+ sendRun({ type: 'done', exitCode: 1, status: 'failed' });
425
+ sendRun({ type: 'stdout', text: '✗ Replay failed — could not reach debug point' });
426
+ await browser.close().catch(() => {});
427
+ ws.send(JSON.stringify({ type: 'jobDone', runId }));
428
+ return;
429
+ }
430
+ }
431
+
432
+ sendRun({ type: 'stdout', text: '' });
433
+ sendRun({ type: 'stdout', text: ' ✔ Reached debug point — starting recorder' });
434
+
435
+ // Now start the recorder from this browser state
436
+ const screenshotDir = path.join(os.homedir(), '.skopix', 'recordings', recordingId);
437
+ await fs.ensureDir(screenshotDir);
438
+
439
+ const recorderPath = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', 'core', 'recorder.js');
440
+ const { spawn } = await import('child_process');
441
+ const child = spawn('node', [recorderPath, startUrl || '', recordingId, screenshotDir], { stdio: ['pipe', 'pipe', 'pipe'] });
442
+
443
+ child.stdout.on('data', (chunk) => {
444
+ chunk.toString().split('\n').filter(Boolean).forEach(line => {
445
+ try {
446
+ const parsed = JSON.parse(line);
447
+ sendRec(parsed);
448
+ if (parsed.type === 'step') process.stdout.write(chalk.cyan(' ⏺ ') + (parsed.step?.action || '') + '\n');
449
+ if (parsed.type === 'done') console.log(chalk.green(' ✔ Debug recording done'));
450
+ } catch {}
451
+ });
452
+ });
453
+
454
+ child.stderr.on('data', (chunk) => { sendRec({ type: 'error', message: chunk.toString().trim().slice(0, 200) }); });
455
+ child.on('close', () => {
456
+ sendRec({ type: 'stopped' });
457
+ browser.close().catch(() => {});
458
+ ws.send(JSON.stringify({ type: 'jobDone', runId }));
459
+ console.log(chalk.cyan(' ◆ Waiting for jobs\n'));
460
+ });
461
+
462
+ // Listen for stop signal
463
+ const stopHandler = (event) => {
464
+ let m; try { m = JSON.parse(event.data); } catch { return; }
465
+ if (m.type === 'stopRecord' && m.recordingId === recordingId) {
466
+ try { child.stdin.write('stop\n'); } catch {}
467
+ ws.removeEventListener('message', stopHandler);
468
+ }
469
+ };
470
+ ws.addEventListener('message', stopHandler);
471
+
472
+ } catch (err) {
473
+ console.error(chalk.red(' ✖ Debug error: ' + err.message));
474
+ sendRun({ type: 'stdout', text: '✖ Debug replay error: ' + err.message });
475
+ sendRun({ type: 'done', exitCode: 1, status: 'failed' });
476
+ sendRun({ type: 'stdout', text: '<span style="color:var(--red)">✗ Replay failed — could not reach debug point</span>' });
477
+ ws.send(JSON.stringify({ type: 'jobDone', runId }));
478
+ }
479
+ }
480
+
383
481
  // ── HELPERS ────────────────────────────────────────────────────────────────
384
482
  function sanitiseSelector(sel) {
385
483
  if (!sel) return sel;
@@ -1249,17 +1249,33 @@ export async function dashboardCommand(options) {
1249
1249
  const broadcast = (line) => { run.output.push(line); run.listeners.forEach(l => l(line)); };
1250
1250
  const broadcastRec = (line) => { recording.output.push(line); recording.listeners.forEach(l => l(line)); };
1251
1251
 
1252
- // In team mode, check we have a headed agent available before starting
1252
+ // In team mode, dispatch to a headed agent never run locally on the server
1253
1253
  if (teamMode) {
1254
1254
  const headedAgent = getLeastBusyAgent(currentUser?.id, true);
1255
1255
  if (!headedAgent) {
1256
- sendJSON(res, 503, { error: 'No agent with a display connected. Run "skopix agent --server ' + (req.headers.host || 'localhost:9000') + '" on your local machine first to use debug mode.' });
1256
+ sendJSON(res, 503, { error: 'No agent with a display connected. Run "skopix agent --server http://' + (req.headers.host || 'localhost:9000') + '" on your local machine first to use debug mode.' });
1257
1257
  activeRuns.delete(runId);
1258
1258
  activeRecordings.delete(recordingId);
1259
1259
  return;
1260
1260
  }
1261
+ // Dispatch to the headed agent
1262
+ headedAgent.status = 'recording';
1263
+ headedAgent.currentJob = { type: 'debug-record', runId, recordingId };
1264
+ broadcastAgentList();
1265
+ sendToAgent(headedAgent, {
1266
+ type: 'debug-record',
1267
+ runId,
1268
+ recordingId,
1269
+ replaySteps,
1270
+ startUrl: test.url,
1271
+ env: { ...process.env, ...(await resolveUserSecretsEnv(currentUser?.id, teamMode) || {}) },
1272
+ });
1273
+ sendJSON(res, 200, { runId, recordingId, agent: { id: headedAgent.id, name: headedAgent.name } });
1274
+ return;
1261
1275
  }
1262
1276
 
1277
+ // Solo mode — run locally
1278
+ sendJSON(res, 200, { runId, recordingId });
1263
1279
  // Start the debug session asynchronously
1264
1280
  (async () => {
1265
1281
  const sessionDir = path.join(reportsDir, runId);
@@ -2353,7 +2369,7 @@ export async function dashboardCommand(options) {
2353
2369
 
2354
2370
  if (msg.type === 'register') {
2355
2371
  const agentId = msg.agentId || crypto.randomUUID();
2356
- const agent = { id: agentId, name: msg.name || msg.machine, machine: msg.machine, userId: msg.userId || null, ws, status: 'idle', currentJob: null, connectedAt: Date.now(), hasDisplay: msg.hasDisplay !== false };
2372
+ const agent = { id: agentId, name: msg.name || msg.machine, machine: msg.machine, userId: msg.userId || null, ws, status: 'idle', currentJob: null, connectedAt: Date.now(), hasDisplay: msg.hasDisplay === true };
2357
2373
  agents.set(agentId, agent);
2358
2374
  ws.agentId = agentId;
2359
2375
  ws.send(JSON.stringify({ type: 'registered', agentId }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skopix",
3
- "version": "2.0.97",
3
+ "version": "2.0.98",
4
4
  "description": "Browser-based QA tool — record tests by using your app, replay them deterministically, generate Playwright code automatically",
5
5
  "main": "cli/index.js",
6
6
  "bin": {