skopix 2.0.96 → 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.
@@ -114,6 +114,10 @@ export async function agentCommand(options) {
114
114
  console.log('');
115
115
  console.log(' Machine : ' + chalk.white(machine));
116
116
  console.log(' Server : ' + chalk.white(serverUrl));
117
+ const hasDisplay = process.platform !== 'linux' || !!process.env.DISPLAY || !!process.env.WAYLAND_DISPLAY;
118
+ if (!hasDisplay) {
119
+ console.log(chalk.yellow(' ⚠ No display detected — headless only (replay/suites work, recording/debug require a display)'));
120
+ }
117
121
  console.log('');
118
122
 
119
123
  // Authenticate to identify this agent with the correct user
@@ -172,7 +176,9 @@ export async function agentCommand(options) {
172
176
 
173
177
  ws.addEventListener('open', () => {
174
178
  reconnectDelay = 2000;
175
- ws.send(JSON.stringify({ type: 'register', agentId, name: userName, machine, userId }));
179
+ // Detect if this machine has a display (headed browser capability)
180
+ const hasDisplay = process.platform !== 'linux' || !!process.env.DISPLAY || !!process.env.WAYLAND_DISPLAY;
181
+ ws.send(JSON.stringify({ type: 'register', agentId, name: userName, machine, userId, hasDisplay }));
176
182
  console.log(chalk.green(' ✔ Connected — waiting for jobs\n'));
177
183
  });
178
184
 
@@ -184,6 +190,7 @@ export async function agentCommand(options) {
184
190
  if (msg.type === 'ping') { ws.send(JSON.stringify({ type: 'pong' })); return; }
185
191
  if (msg.type === 'record') { await handleRecord(msg); return; }
186
192
  if (msg.type === 'replay') { await handleReplay(msg); return; }
193
+ if (msg.type === 'debug-record') { await handleDebugRecord(msg); return; }
187
194
  });
188
195
 
189
196
  ws.addEventListener('close', () => {
@@ -374,6 +381,103 @@ export async function agentCommand(options) {
374
381
  }
375
382
  }
376
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
+
377
481
  // ── HELPERS ────────────────────────────────────────────────────────────────
378
482
  function sanitiseSelector(sel) {
379
483
  if (!sel) return sel;
@@ -31,16 +31,19 @@ export async function dashboardCommand(options) {
31
31
 
32
32
  // ── AGENT REGISTRY ────────────────────────────────────────────────────────
33
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 }
34
+ const agents = new Map(); // agentId -> { id, name, machine, userId, ws, status, currentJob, connectedAt, hasDisplay }
35
35
 
36
- function getAvailableAgents() {
37
- return [...agents.values()].filter(a => a.status === 'idle' && a.ws.readyState === 1);
36
+ function getAvailableAgents(requireDisplay) {
37
+ return [...agents.values()].filter(a => {
38
+ if (a.status !== 'idle' || a.ws.readyState !== 1) return false;
39
+ if (requireDisplay && !a.hasDisplay) return false;
40
+ return true;
41
+ });
38
42
  }
39
43
 
40
- function getLeastBusyAgent(preferUserId) {
41
- const available = getAvailableAgents();
44
+ function getLeastBusyAgent(preferUserId, requireDisplay) {
45
+ const available = getAvailableAgents(requireDisplay);
42
46
  if (!available.length) return null;
43
- // Prefer the agent belonging to the requesting user
44
47
  const mine = available.find(a => a.userId === preferUserId);
45
48
  if (mine) return mine;
46
49
  return available[0];
@@ -1045,6 +1048,7 @@ export async function dashboardCommand(options) {
1045
1048
  const list = [...agents.values()].map(a => ({
1046
1049
  id: a.id, name: a.name, machine: a.machine, userId: a.userId,
1047
1050
  status: a.status, currentJob: a.currentJob || null, connectedAt: a.connectedAt,
1051
+ hasDisplay: a.hasDisplay !== false,
1048
1052
  }));
1049
1053
  sendJSON(res, 200, list);
1050
1054
  return;
@@ -1067,12 +1071,12 @@ export async function dashboardCommand(options) {
1067
1071
  const config = JSON.parse(body);
1068
1072
  if (!config.url) { sendJSON(res, 400, { error: 'url is required' }); return; }
1069
1073
 
1070
- // In team mode, dispatch to an agent if available
1074
+ // In team mode, dispatch to a headed agent (needs display for recording)
1071
1075
  const userId = currentUser?.id || null;
1072
- const agent = teamMode ? getLeastBusyAgent(userId) : null;
1076
+ const agent = teamMode ? getLeastBusyAgent(userId, true) : null;
1073
1077
 
1074
1078
  if (teamMode && !agent) {
1075
- sendJSON(res, 503, { error: 'No agent connected. Run "skopix agent --server ' + (req.headers.host || 'localhost:9000') + '" on your machine first.' });
1079
+ sendJSON(res, 503, { error: 'No agent with a display connected. Run "skopix agent --server ' + (req.headers.host || 'localhost:9000') + '" on your local machine first.' });
1076
1080
  return;
1077
1081
  }
1078
1082
 
@@ -1245,6 +1249,33 @@ export async function dashboardCommand(options) {
1245
1249
  const broadcast = (line) => { run.output.push(line); run.listeners.forEach(l => l(line)); };
1246
1250
  const broadcastRec = (line) => { recording.output.push(line); recording.listeners.forEach(l => l(line)); };
1247
1251
 
1252
+ // In team mode, dispatch to a headed agent — never run locally on the server
1253
+ if (teamMode) {
1254
+ const headedAgent = getLeastBusyAgent(currentUser?.id, true);
1255
+ if (!headedAgent) {
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
+ activeRuns.delete(runId);
1258
+ activeRecordings.delete(recordingId);
1259
+ return;
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;
1275
+ }
1276
+
1277
+ // Solo mode — run locally
1278
+ sendJSON(res, 200, { runId, recordingId });
1248
1279
  // Start the debug session asynchronously
1249
1280
  (async () => {
1250
1281
  const sessionDir = path.join(reportsDir, runId);
@@ -2338,7 +2369,7 @@ export async function dashboardCommand(options) {
2338
2369
 
2339
2370
  if (msg.type === 'register') {
2340
2371
  const agentId = msg.agentId || crypto.randomUUID();
2341
- const agent = { id: agentId, name: msg.name || msg.machine, machine: msg.machine, userId: msg.userId || null, ws, status: 'idle', currentJob: null, connectedAt: Date.now() };
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 };
2342
2373
  agents.set(agentId, agent);
2343
2374
  ws.agentId = agentId;
2344
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.96",
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": {
package/web/index.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Skopix — Record. Replay. Ship with confidence.</title>
7
7
  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230d1117'/%3E%3Cpolygon points='16,6 26,11 16,16 6,11' fill='%2300d4ff'/%3E%3Cpolygon points='6,11 16,16 26,11 26,14 16,19 6,14' fill='%2300a8cc'/%3E%3Cpolygon points='6,14 16,19 26,14 26,17 16,22 6,17' fill='%23007a99'/%3E%3C/svg%3E">
8
- <meta name="description" content="Skopix is a browser-based QA tool that records your test flows and replays them deterministically. AI stabilises selectors, generates Playwright code, and catches regressions before your users do.">
8
+ <meta name="description" content="Skopix is an AI-powered QA platform record tests, build from a reusable element library, run on a shared team server with MFA, and catch regressions before your users do.">
9
9
  <link rel="preconnect" href="https://fonts.googleapis.com">
10
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
11
  <link href="https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300&family=Syne:wght@400;500;600;700;800&display=swap" rel="stylesheet">
@@ -197,11 +197,12 @@ footer { border-top: 1px solid var(--border); padding: 48px; display: flex; alig
197
197
  <a href="#" class="nav-logo">SKOP<span>IX</span></a>
198
198
  <ul class="nav-links">
199
199
  <li><a href="#how">How it works</a></li>
200
+ <li><a href="#library">Step library</a></li>
200
201
  <li><a href="#ai">AI providers</a></li>
201
- <li><a href="#modes">Solo & team</a></li>
202
+ <li><a href="#modes">Deploy</a></li>
202
203
  <li><a href="#install">Install</a></li>
203
204
  <li><a href="#faq">FAQ</a></li>
204
- <li><a href="https://github.com/x444dyx/skopix" class="nav-cta">GitHub →</a></li>
205
+ <li><a href="#install" class="nav-cta">Install →</a></li>
205
206
  </ul>
206
207
  </nav>
207
208
 
@@ -210,7 +211,7 @@ footer { border-top: 1px solid var(--border); padding: 48px; display: flex; alig
210
211
  <div class="hero-glow"></div>
211
212
  <div class="hero-glow2"></div>
212
213
 
213
- <div class="badge"><span class="badge-dot"></span>Record · Replay · Regression-proof</div>
214
+ <div class="badge"><span class="badge-dot"></span>Record · Build · Library · Team · MFA</div>
214
215
 
215
216
  <h1 class="hero-title">
216
217
  QA that works<br>
@@ -218,7 +219,7 @@ footer { border-top: 1px solid var(--border); padding: 48px; display: flex; alig
218
219
  </h1>
219
220
 
220
221
  <p class="hero-sub">
221
- Use your app normally. Skopix records every action. AI stabilises the selectors, writes the Playwright code, and replays it all — deterministically — every time.
222
+ Record tests by using your app. Build tests from a reusable element library. Deploy to a shared team server with user accounts and MFA. AI stabilises selectors, matches known elements automatically, and replays everything — deterministically — every time.
222
223
  </p>
223
224
 
224
225
  <div class="hero-actions">
@@ -235,16 +236,17 @@ footer { border-top: 1px solid var(--border); padding: 48px; display: flex; alig
235
236
  <span style="font-family:var(--mono);font-size:11px;color:var(--muted);margin-left:8px">skopix dashboard</span>
236
237
  </div>
237
238
  <div class="terminal-body">
238
- <div><span class="t-cmd">$</span> SKOPIX_SECRET_KEY="..." skopix dashboard --team</div>
239
- <div class="t-out">✔ Dashboard running at http://localhost:9000</div>
240
- <div class="t-out">✔ Team mode active · 3 users online</div>
241
- <div style="margin-top:12px;color:var(--muted2)">── Replaying: Create A Bar Chart ──────────────────</div>
242
- <div><span class="t-pass">✓</span> <span class="t-step">[1/14] CLICK Click username input field</span></div>
243
- <div><span class="t-pass">✓</span> <span class="t-step">[2/14] TYPE Type 'admin' into username field</span></div>
244
- <div><span class="t-pass">✓</span> <span class="t-step">[5/14] CLICK — Click the plus icon to add chart</span></div>
245
- <div><span class="t-pass">✓</span> <span class="t-step">[13/14] ASSERT — Chart is visible</span></div>
246
- <div><span class="t-pass">✓</span> <span class="t-step">[14/14] ASSERTTitle attribute contains "Bar chart"</span></div>
247
- <div style="margin-top:8px"><span class="t-pass">✔ PASSED</span> <span style="color:var(--muted)" 14 steps · 9.4s</span></div>
239
+ <div><span class="t-cmd">$</span> SKOPIX_SECRET_KEY="..." skopix start --host 0.0.0.0 --port 9000</div>
240
+ <div class="t-out">✔ Dashboard running at http://141.147.107.213:9000</div>
241
+ <div class="t-out">✔ Team mode · MFA enabled · 4 users online</div>
242
+ <div class="t-out">✔ Step library · 42 elements · 12 folders</div>
243
+ <div style="margin-top:12px;color:var(--muted2)">── Agent: Adils-Laptop.local (darwin) connected ────</div>
244
+ <div style="margin-top:4px;color:var(--muted2)">── Replaying: Create Chart With Sort ──────────────</div>
245
+ <div><span class="t-pass">✓</span> <span class="t-step">[1/18] CLICK — Login Button</span> <span style="color:var(--muted2);font-size:11px">← from library</span></div>
246
+ <div><span class="t-pass">✓</span> <span class="t-step">[5/18] CLICKCategory - Add New Chart Button</span> <span style="color:var(--muted2);font-size:11px">← from library</span></div>
247
+ <div><span class="t-pass">✓</span> <span class="t-step">[12/18] CLICKChart Editor - Save Chart</span></div>
248
+ <div><span class="t-pass">✓</span> <span class="t-step">[18/18] ASSERT First row contains 60.00</span></div>
249
+ <div style="margin-top:8px"><span class="t-pass">✔ PASSED</span> <span style="color:var(--muted)">· 18 steps · 11.2s · run by adil aslam</span></div>
248
250
  </div>
249
251
  </div>
250
252
  </section>
@@ -254,7 +256,7 @@ footer { border-top: 1px solid var(--border); padding: 48px; display: flex; alig
254
256
  <div class="how-inner">
255
257
  <div class="section-label observe">How it works</div>
256
258
  <h2 class="section-title observe">Record once.<br>Replay forever.</h2>
257
- <p class="section-sub observe">No scripting. No selectors. Just use your app Skopix captures everything and turns it into a stable, repeatable test.</p>
259
+ <p class="section-sub observe">No scripting. No selectors. Record by using your app, or build tests from a reusable element library Skopix handles the rest.</p>
258
260
 
259
261
  <div class="steps-grid">
260
262
  <div class="step-item observe">
@@ -268,8 +270,8 @@ footer { border-top: 1px solid var(--border); padding: 48px; display: flex; alig
268
270
  <div class="step-item observe">
269
271
  <div class="step-num">02 — AI PROCESSING</div>
270
272
  <div class="step-icon">◆</div>
271
- <div class="step-title">AI stabilises your steps</div>
272
- <div class="step-desc">After you stop, Skopix sends your raw steps through an LLM. It rewrites fragile positional selectors into stable ones (data-testid, aria-label, semantic HTML). It generates human-readable descriptions for each step. It produces complete Playwright test code in both JavaScript and TypeScript ready to drop into your CI pipeline.</div>
273
+ <div class="step-title">AI stabilises &amp; matches</div>
274
+ <div class="step-desc">After recording, the LLM rewrites fragile positional selectors into stable ones (pi-test-identifier, aria-label, title, data-testid). It then scans your step library if a recorded element matches a known library entry, it substitutes the canonical name and selector automatically. New elements go to pending review, not duplicated silently.</div>
273
275
  <span class="step-tag">Gemini · OpenAI · Ollama</span>
274
276
  </div>
275
277
 
@@ -290,19 +292,61 @@ footer { border-top: 1px solid var(--border); padding: 48px; display: flex; alig
290
292
  </div>
291
293
 
292
294
  <div class="step-item observe">
293
- <div class="step-num">05 — REUSE</div>
295
+ <div class="step-num">05 — STEP LIBRARY</div>
294
296
  <div class="step-icon">♻</div>
295
- <div class="step-title">Share setup flows</div>
296
- <div class="step-desc">Mark any test as Reusable. Other tests can reference it as their Setup Skopix runs the setup steps first in the same browser session, then hands off to the main test. Record your login flow once. Every other test that needs authentication just references it. No duplication, no maintenance headache.</div>
297
- <span class="step-tag">Setup · Teardown · Reuse</span>
297
+ <div class="step-title">Build a reusable element library</div>
298
+ <div class="step-desc">Harvest UI elements into the step library name them, store stable selectors, organise into folders. Build new tests entirely from the library using the visual test builder. Every recording auto-matches known elements. Update an element's selector once and it propagates to all tests that use it instantly.</div>
299
+ <span class="step-tag">Library · Builder · Auto-sync</span>
298
300
  </div>
299
301
 
300
302
  <div class="step-item observe">
301
- <div class="step-num">06 — REPORT</div>
303
+ <div class="step-num">06 — TEAM &amp; REPORTS</div>
302
304
  <div class="step-icon">📋</div>
303
- <div class="step-title">Full session reports</div>
304
- <div class="step-desc">Every replay generates a full report: two-column layout with video on the left, clickable step list on the right. Click any step to see the screenshot at that point. Sessions are stored in All Sessions. Suite runs aggregate results across all tests. Audit log tracks who ran what and when.</div>
305
- <span class="step-tag">Video · Screenshots · HTML report</span>
305
+ <div class="step-title">Full reports + team audit</div>
306
+ <div class="step-desc">Every replay generates a full report with video and step-by-step screenshots. Deploy to a cloud server team members log in with MFA, run tests through their local agent, and all results stream back to the shared dashboard. Audit log tracks exactly who ran what test, when, and from which machine.</div>
307
+ <span class="step-tag">Video · MFA · Audit log</span>
308
+ </div>
309
+ </div>
310
+ </div>
311
+ </section>
312
+
313
+ <!-- STEP LIBRARY -->
314
+ <section id="library">
315
+ <div class="section-inner">
316
+ <div class="section-label observe">Step library</div>
317
+ <h2 class="section-title observe">One library.<br>Every test.</h2>
318
+ <p class="section-sub observe">Build a catalogue of reusable UI elements. Record a test — known elements are substituted automatically. Update a selector once and every test that uses it updates instantly.</p>
319
+
320
+ <div class="features-grid" style="margin-top:64px">
321
+ <div class="feature-card observe">
322
+ <div class="feature-icon" style="background:var(--cyan-dim)">🎯</div>
323
+ <div class="feature-title">Harvest elements</div>
324
+ <div class="feature-desc">Open the harvester, navigate your app, click "Capture" on any element. Name it, store the stable selector, assign a folder. That element is now in the library ready to use in any test.</div>
325
+ </div>
326
+ <div class="feature-card observe">
327
+ <div class="feature-icon" style="background:var(--cyan-dim)">🔄</div>
328
+ <div class="feature-title">Auto-match on record</div>
329
+ <div class="feature-desc">Record a new test and click through your app. After AI processing, each step is compared against the library. Matching elements get the canonical name and selector substituted automatically — no manual cleanup.</div>
330
+ </div>
331
+ <div class="feature-card observe">
332
+ <div class="feature-icon" style="background:var(--cyan-dim)">🔨</div>
333
+ <div class="feature-title">Visual test builder</div>
334
+ <div class="feature-desc">Build tests without recording. Pick elements from the library, choose an action (click, type, assert), set a value, preview in a real browser, save. Full debug and replay capability — identical to recorded tests.</div>
335
+ </div>
336
+ <div class="feature-card observe">
337
+ <div class="feature-icon" style="background:var(--cyan-dim)">⚡</div>
338
+ <div class="feature-title">One update, all tests</div>
339
+ <div class="feature-desc">Change an element's selector in the library — every test step using that selector updates instantly. Merge duplicate elements and all tests get the canonical version. Fragile selectors get a warning badge.</div>
340
+ </div>
341
+ <div class="feature-card observe">
342
+ <div class="feature-icon" style="background:var(--cyan-dim)">📁</div>
343
+ <div class="feature-title">Folders &amp; organisation</div>
344
+ <div class="feature-desc">Organise elements and tests into nested folders. Drag to reorder. Multi-select and move. Folders are separate from tags — tag for filtering, folder for structure. Same organisation system for both the library and All Tests.</div>
345
+ </div>
346
+ <div class="feature-card observe">
347
+ <div class="feature-icon" style="background:var(--cyan-dim)">⏳</div>
348
+ <div class="feature-title">Pending review queue</div>
349
+ <div class="feature-desc">New elements from recordings don't enter the library silently. They queue in pending review — you name them properly, edit the selector if needed, then approve. The amber badge tells you how many are waiting.</div>
306
350
  </div>
307
351
  </div>
308
352
  </div>
@@ -411,8 +455,8 @@ footer { border-top: 1px solid var(--border); padding: 48px; display: flex; alig
411
455
  <section id="modes">
412
456
  <div class="section-inner">
413
457
  <div class="section-label observe">Deployment modes</div>
414
- <h2 class="section-title observe">Solo or as a team.<br>Same install.</h2>
415
- <p class="section-sub observe">One flag changes everything. Run locally for personal use, or flip to team mode and share with your entire QA team no separate server software needed.</p>
458
+ <h2 class="section-title observe">Solo, team, or<br>cloud-hosted.</h2>
459
+ <p class="section-sub observe">Run locally for personal use, share with your team on a local server, or deploy to a cloud VM for always-on access with MFA and API key authentication for agents.</p>
416
460
 
417
461
  <div class="modes-grid">
418
462
  <div class="mode-card mode-solo observe">
@@ -428,14 +472,15 @@ footer { border-top: 1px solid var(--border); padding: 48px; display: flex; alig
428
472
  </div>
429
473
 
430
474
  <div class="mode-card mode-team observe">
431
- <div class="mode-label" style="color:var(--purple)">⬡ TEAM MODE</div>
475
+ <div class="mode-label" style="color:var(--purple)">⬡ TEAM / CLOUD MODE</div>
432
476
  <div class="mode-title">You and your team</div>
433
- <div class="mode-desc">Adds login, user accounts, roles (Admin/Tester/Viewer), audit log, and shared test libraries. Teammates connect via your IP or a tunnel like <a href="https://portix.ayteelabs.com" style="color:var(--cyan)">Portix</a>. All test data lives on the host machine.</div>
477
+ <div class="mode-desc">Mandatory MFA for all users. User accounts with roles. Audit log. Shared step library. API keys for agents. Deploy to Oracle Cloud, Hetzner, or any Linux VM teammates log in from anywhere, run tests via their local agent.</div>
434
478
  <div class="mode-code">
435
479
  <div><span style="color:var(--purple)">SKOPIX_SECRET_KEY</span>=<span style="color:var(--text)">"long-random-string"</span> \</div>
436
- <div style="padding-left:16px"><span style="color:var(--cyan)">skopix dashboard --team --host 0.0.0.0</span></div>
480
+ <div style="padding-left:16px"><span style="color:var(--cyan)">skopix start --host 0.0.0.0 --port 9000</span></div>
437
481
  <div style="color:var(--muted);font-size:11px;margin-top:8px">→ Visit /setup to create the first admin</div>
438
- <div style="color:var(--muted);font-size:11px">→ Invite teammates from the Users tab</div>
482
+ <div style="color:var(--muted);font-size:11px">→ Mandatory MFA on first login for every user</div>
483
+ <div style="color:var(--muted);font-size:11px">→ Agents connect with API keys — no MFA needed</div>
439
484
  </div>
440
485
  </div>
441
486
  </div>
@@ -452,8 +497,8 @@ footer { border-top: 1px solid var(--border); padding: 48px; display: flex; alig
452
497
  <div style="color:var(--muted)">Run <code style="color:var(--text)">portix 9000 --name skopix</code> for an instant public URL. Teammates open it from anywhere.</div>
453
498
  </div>
454
499
  <div>
455
- <div style="color:var(--cyan);margin-bottom:6px">Always-on · Dedicated machine</div>
456
- <div style="color:var(--muted)">Leave a Mac or Linux desktop running as a shared server. Recording needs a real displayDocker/cloud VMs won't work for that.</div>
500
+ <div style="color:var(--cyan);margin-bottom:6px">Always-on · Cloud VM</div>
501
+ <div style="color:var(--muted)">Deploy to Oracle Cloud Free Tier, Hetzner (~£4/mo), or any Ubuntu VM. Dashboard runs 24/7 via PM2. Each teammate runs an agent locally recordings open Chrome on their screen.</div>
457
502
  </div>
458
503
  </div>
459
504
  </div>
@@ -506,10 +551,12 @@ footer { border-top: 1px solid var(--border); padding: 48px; display: flex; alig
506
551
  <div class="code-comment"># Each teammate runs this on their own machine:</div>
507
552
  <div><span class="code-cmd">$</span> npm install -g skopix</div>
508
553
  <div><span class="code-cmd">$</span> npx playwright install chromium</div>
554
+ <div class="code-comment"># Login with your dashboard credentials (triggers MFA):</div>
509
555
  <div><span class="code-cmd">$</span> skopix agent --server http://HOST-IP:9000 --key "your-secret"</div>
510
- <div class="code-out">→ Prompts for Skopix dashboard login to identify your machine</div>
556
+ <div class="code-comment"># Or use an API key (no MFA generate one in My Settings):</div>
557
+ <div><span class="code-cmd">$</span> skopix agent --server http://HOST-IP:9000 --key "secret" --api-key sk_live_...</div>
511
558
  <div class="code-out">→ Chrome opens on YOUR machine when you trigger tests</div>
512
- <div class="code-out">→ Leave it running in a terminal while you work</div>
559
+ <div class="code-out">→ Results stream back to the shared cloud dashboard</div>
513
560
  </div>
514
561
 
515
562
  <div class="code-label observe">USING OLLAMA (LOCAL AI — NO API KEY NEEDED)</div>
@@ -526,16 +573,16 @@ footer { border-top: 1px solid var(--border); padding: 48px; display: flex; alig
526
573
  <div style="font-family:var(--mono);font-size:10px;color:var(--cyan);letter-spacing:0.15em;margin-bottom:16px">HOW IT ALL FITS TOGETHER</div>
527
574
  <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:24px;font-family:var(--mono);font-size:12px;line-height:1.8">
528
575
  <div>
529
- <div style="color:var(--white);margin-bottom:6px">1 person hosts</div>
530
- <div style="color:var(--muted)">Runs <code style="color:var(--cyan)">skopix dashboard --team</code> and keeps it running. Everyone connects to their machine.</div>
576
+ <div style="color:var(--white);margin-bottom:6px">Dashboard on a cloud VM</div>
577
+ <div style="color:var(--muted)">Runs 24/7 via PM2. Everyone visits <code style="color:var(--cyan)">http://your-ip:9000</code> and logs in with email + MFA. No local install needed to use the dashboard.</div>
531
578
  </div>
532
579
  <div>
533
- <div style="color:var(--white);margin-bottom:6px">Everyone runs an agent</div>
534
- <div style="color:var(--muted)">Runs <code style="color:var(--cyan)">skopix agent</code> in a terminal. Their browser opens locally when they trigger a recording or replay.</div>
580
+ <div style="color:var(--white);margin-bottom:6px">Everyone runs an agent locally</div>
581
+ <div style="color:var(--muted)">Runs <code style="color:var(--cyan)">skopix agent</code> on their machine with an API key. Their browser opens when they trigger a recording or replay.</div>
535
582
  </div>
536
583
  <div>
537
- <div style="color:var(--white);margin-bottom:6px">Tests run on your machine</div>
538
- <div style="color:var(--muted)">When you click Record or Replay, Skopix dispatches to your agent. Chrome opens on your screen. Results stream back to the shared dashboard.</div>
584
+ <div style="color:var(--white);margin-bottom:6px">Shared step library</div>
585
+ <div style="color:var(--muted)">One library, all team members. Record a new test known elements auto-match. New elements go to pending review. Update a selector once, all tests update.</div>
539
586
  </div>
540
587
  </div>
541
588
  </div>
@@ -585,7 +632,7 @@ footer { border-top: 1px solid var(--border); padding: 48px; display: flex; alig
585
632
 
586
633
  <details class="observe">
587
634
  <summary>How is this different from Playwright's own recorder? <span class="faq-icon">+</span></summary>
588
- <div class="faq-body">Playwright's recorder generates raw selectors as-is — often fragile positional ones. Skopix uses an LLM to <strong>stabilise</strong> those selectors into semantic, stable equivalents. It also adds a full dashboard, team mode, setup/teardown flows, debug recording, credential management, suite running, and session history with video. It's a complete QA platform, not just a code generator.</div>
635
+ <div class="faq-body">Playwright's recorder generates raw selectors as-is — often fragile positional ones. Skopix uses an LLM to <strong>stabilise</strong> those selectors into semantic, stable equivalents. Beyond that, Skopix adds a full dashboard, step library with auto-matching, visual test builder, team mode with MFA, cloud deployment, audit log, debug recording, credential management, suite running, and session history with video. It's a complete QA platform, not just a code generator.</div>
589
636
  </details>
590
637
 
591
638
  <details class="observe">
@@ -593,6 +640,16 @@ footer { border-top: 1px solid var(--border); padding: 48px; display: flex; alig
593
640
  <div class="faq-body">Open the step editor (✏ on any test), find the failing step, and update the selector directly. Or use the ⏺ debug button on that step — Skopix replays everything up to that point in a real browser, then lets you record the correct action from scratch. New steps are AI-processed and inserted automatically.</div>
594
641
  </details>
595
642
 
643
+ <details class="observe">
644
+ <summary>What is the Step Library? <span class="faq-icon">+</span></summary>
645
+ <div class="faq-body">The step library is a reusable catalogue of UI elements — each with a name, stable selector, and optional default action. Harvest elements from your app using the harvester tool, or they get added automatically from recordings (pending your review). Once in the library, the visual <strong>Test Builder</strong> lets you build new tests by picking elements and choosing actions — no recording needed. Update an element's selector once and every test using it updates automatically.</div>
646
+ </details>
647
+
648
+ <details class="observe">
649
+ <summary>How does MFA work and how do agents authenticate? <span class="faq-icon">+</span></summary>
650
+ <div class="faq-body">In team mode, MFA is mandatory for all users. On first login, users are prompted to scan a QR code with any TOTP app (Google Authenticator, Authy, 1Password). Every subsequent login requires email + password + 6-digit code. Admins can reset MFA for users who lose their device. For agents running headlessly, use API keys instead — generate one in My Settings, pass it with <code>--api-key sk_live_...</code>. No MFA required for API keys.</div>
651
+ </details>
652
+
596
653
  <details class="observe">
597
654
  <summary>How does the login/setup flow work? <span class="faq-icon">+</span></summary>
598
655
  <div class="faq-body">Record your login flow once and mark it as <strong>Reusable</strong>. In any other test's editor, set that login test as the <strong>Setup</strong>. When that test runs, Skopix runs the login steps first in the same browser session — so the test starts already authenticated. No duplicating login steps across every test.</div>
@@ -610,7 +667,7 @@ footer { border-top: 1px solid var(--border); padding: 48px; display: flex; alig
610
667
 
611
668
  <details class="observe">
612
669
  <summary>How do remote teammates connect? <span class="faq-icon">+</span></summary>
613
- <div class="faq-body"><strong>Same office:</strong> connect at <code>http://your-ip:9000</code> on the same network. <strong>Remote:</strong> run <code>portix 9000 --name skopix</code> (using <a href="https://portix.ayteelabs.com" style="color:var(--cyan)">Portix</a>) for an instant public URL teammates open it from anywhere. <strong>Always-on:</strong> deploy to a cloud VM with Docker (~£4/mo at Hetzner), point your domain at it, done.</div>
670
+ <div class="faq-body"><strong>Same office:</strong> connect at <code>http://your-ip:9000</code> on the same network. <strong>Remote / cloud:</strong> deploy the dashboard to a cloud VM (Oracle Cloud Free Tier, Hetzner ~£4/mo) — teammates connect from anywhere at your public IP. <strong>Portix tunnel:</strong> run <code>portix 9000 --name skopix</code> for an instant public URL without a server. Each teammate still runs the agent locally their Chrome opens on their own machine when tests run.</div>
614
671
  </details>
615
672
 
616
673
  <details class="observe">
@@ -634,8 +691,8 @@ footer { border-top: 1px solid var(--border); padding: 48px; display: flex; alig
634
691
 
635
692
  <!-- CTA -->
636
693
  <section class="cta-section">
637
- <div class="cta-title observe">Start recording.<br><span style="color:var(--cyan)">Stop scripting.</span></div>
638
- <p class="cta-sub observe">Free. Self-hosted. Your tests, your data, your machine. One command to get started.</p>
694
+ <div class="cta-title observe">Record. Build. Deploy.<br><span style="color:var(--cyan)">Ship with confidence.</span></div>
695
+ <p class="cta-sub observe">Free. Self-hosted. Step library, team mode, MFA, cloud-ready. One command to get started.</p>
639
696
  <div style="display:flex;gap:16px;justify-content:center;flex-wrap:wrap" class="observe">
640
697
  <a href="#install" class="btn-primary">Install now →</a>
641
698
  <a href="https://github.com/x444dyx/skopix" class="btn-ghost">GitHub</a>
@@ -648,7 +705,9 @@ footer { border-top: 1px solid var(--border); padding: 48px; display: flex; alig
648
705
  <div class="footer-brand">SKOPIX</div>
649
706
  <ul class="footer-links">
650
707
  <li><a href="#how">How it works</a></li>
708
+ <li><a href="#library">Step library</a></li>
651
709
  <li><a href="#ai">AI providers</a></li>
710
+ <li><a href="#modes">Deploy</a></li>
652
711
  <li><a href="#install">Install</a></li>
653
712
  <li><a href="#faq">FAQ</a></li>
654
713
  <li><a href="https://github.com/x444dyx/skopix">GitHub</a></li>