skopix 2.0.0 → 2.0.2

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.
@@ -220,7 +220,7 @@ export async function agentCommand(options) {
220
220
 
221
221
  try {
222
222
  const { chromium } = await import('playwright');
223
- const sessionDir = path.join(os.homedir(), '.skopix', 'sessions', runId);
223
+ const sessionDir = path.join(os.homedir(), '.skopix', runId);
224
224
  await fs.ensureDir(sessionDir);
225
225
 
226
226
  send({ type: 'stdout', text: '' });
@@ -274,6 +274,25 @@ export async function agentCommand(options) {
274
274
  duration: 0, type: 'replay', provider: 'replay',
275
275
  }, { spaces: 2 }).catch(() => {});
276
276
 
277
+ // Write report.html — two-column layout: video on left, step list on right
278
+ try {
279
+ const duration = '0.0';
280
+ const stepRows = allSteps.slice(0, stepNum).map((s, i) => {
281
+ const desc = (s.description || (s.action + ' ' + (s.stableSelector || s.selector || ''))).replace(/</g, '&lt;').replace(/>/g, '&gt;');
282
+ const screenshotFile = 'step-' + String(i + 1).padStart(3, '0') + '.png';
283
+ const isAssert = s.action === 'assert';
284
+ const isFailed = !passed && i === stepNum - 1;
285
+ if (isAssert) {
286
+ return '<div class="step assert-step" onclick="showStep(' + i + ')"><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">' + desc + '</div></div></div>';
287
+ }
288
+ return '<div class="step ' + (isFailed ? 'failed-step' : '') + '" onclick="showStep(' + i + ')"><div class="step-num">' + String(i + 1).padStart(2, '0') + '</div><div class="step-body"><div class="step-action">' + s.action.toUpperCase() + '</div><div class="step-desc">' + desc + '</div></div></div>';
289
+ }).join('\n');
290
+
291
+ const html = '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Skopix Report — ' + test.name + '</title><style>body{margin:0;font-family:-apple-system,sans-serif;background:#0d0d1a;color:#e8eaf0}.container{display:grid;grid-template-columns:1fr 1fr;height:100vh}.left{padding:24px;background:#080810;border-right:1px solid rgba(255,255,255,0.08);display:flex;flex-direction:column}.right{padding:24px;overflow-y:auto}h1{margin:0 0 8px 0;font-size:24px}.meta{color:#5a6180;font-size:13px;margin-bottom:24px}.status{display:inline-block;padding:4px 12px;border-radius:4px;font-size:12px;font-weight:600;margin-left:8px}.status.passed{background:rgba(34,197,94,0.15);color:#22c55e}.status.failed{background:rgba(239,68,68,0.15);color:#ef4444}video,img{width:100%;border-radius:8px;background:#000}.step{padding:14px 16px;border-bottom:1px solid rgba(255,255,255,0.06);display:flex;gap:14px;cursor:pointer;transition:background 0.15s}.step:hover{background:rgba(0,212,255,0.06)}.step.active{background:rgba(0,212,255,0.1)}.step-num{font-family:monospace;color:#5a6180;font-size:12px;min-width:24px}.step-action{font-family:monospace;font-size:11px;color:#00d4ff;letter-spacing:0.05em;margin-bottom:4px}.assert-action{color:#22c55e}.step-desc{font-size:13px}.failed-step .step-action{color:#ef4444}</style></head><body><div class="container"><div class="left"><h1>' + test.name + '<span class="status ' + (passed ? 'passed' : 'failed') + '">' + (passed ? 'PASSED' : 'FAILED') + '</span></h1><div class="meta">' + allSteps.length + ' steps · ' + duration + 's · agent: ' + os.hostname() + '</div><div id="media"><video src="replay.webm" controls autoplay muted loop></video></div></div><div class="right">' + stepRows + '</div></div><script>function showStep(i){document.querySelectorAll(".step").forEach(s=>s.classList.remove("active"));document.querySelectorAll(".step")[i].classList.add("active");const img=document.createElement("img");img.src="step-"+String(i+1).padStart(3,"0")+".png";document.getElementById("media").innerHTML="";document.getElementById("media").appendChild(img);}</script></body></html>';
292
+
293
+ await fs.writeFile(path.join(sessionDir, 'report.html'), html);
294
+ } catch (e) { /* ignore report html errors */ }
295
+
277
296
  send({ type: 'stdout', text: '' });
278
297
  send({ type: 'stdout', text: '━'.repeat(60) });
279
298
  send({ type: 'stdout', text: ' Status: ' + (passed ? 'PASSED ✓' : 'FAILED ✗') });
@@ -19,9 +19,9 @@ const SAVED_TESTS_FILE = '_saved.suite.yaml';
19
19
  export async function dashboardCommand(options) {
20
20
  const port = parseInt(options.port) || 9000;
21
21
  const host = options.host || process.env.SKOPIX_HOST || '127.0.0.1';
22
- const reportsDir = path.resolve(options.dir || './skopix-reports');
22
+ const reportsDir = path.resolve(options.dir || path.join(os.homedir(), '.skopix'));
23
23
  const webRoot = path.resolve(__dirname, '..', '..', 'web');
24
- const suitesDir = path.resolve(process.cwd());
24
+ const suitesDir = path.join(os.homedir(), '.skopix');
25
25
  const suiteRunsDir = path.join(reportsDir, '.suite-runs');
26
26
 
27
27
  await fs.ensureDir(reportsDir);
@@ -1889,6 +1889,13 @@ export async function dashboardCommand(options) {
1889
1889
  ws.send(JSON.stringify({ type: 'registered', agentId }));
1890
1890
  console.log(chalk.cyan('[agent]') + ' Connected: ' + agent.name + ' (' + agent.machine + ')');
1891
1891
  broadcastAgentList();
1892
+
1893
+ // Keepalive ping every 30s to prevent idle disconnection
1894
+ const pingInterval = setInterval(() => {
1895
+ if (ws.readyState !== 1) { clearInterval(pingInterval); return; }
1896
+ try { ws.send(JSON.stringify({ type: 'ping' })); } catch { clearInterval(pingInterval); }
1897
+ }, 30000);
1898
+ ws.on('close', () => clearInterval(pingInterval));
1892
1899
  return;
1893
1900
  }
1894
1901
 
@@ -2110,55 +2117,64 @@ function testIdFromName(name) {
2110
2117
 
2111
2118
  // ─── SESSIONS ─────────────────────────────────────────────────────────────────
2112
2119
  async function listSessions(reportsDir) {
2113
- if (!await fs.pathExists(reportsDir)) return [];
2114
- const entries = await fs.readdir(reportsDir);
2120
+ if (!await fs.pathExists(reportsDir)) { process.stderr.write('[sessions] reportsDir not found: ' + reportsDir + '\n'); return []; }
2115
2121
  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 {}
2122
+ // Check both reportsDir directly and reportsDir/sessions subfolder
2123
+ const dirsToCheck = [reportsDir, path.join(reportsDir, 'sessions')];
2124
+ for (const dir of dirsToCheck) {
2125
+ if (!await fs.pathExists(dir)) continue;
2126
+ const entries = await fs.readdir(dir);
2127
+ process.stderr.write('[sessions] scanning ' + dir + ' entries: ' + entries.join(',') + '\n');
2128
+ for (const entry of entries) {
2129
+ if (entry.startsWith('.')) continue;
2130
+ const sessionPath = path.join(dir, entry);
2131
+ const stat = await fs.stat(sessionPath);
2132
+ if (!stat.isDirectory()) continue;
2133
+ const jsonPath = path.join(sessionPath, 'report.json');
2134
+ if (await fs.pathExists(jsonPath)) {
2135
+ try {
2136
+ const data = await fs.readJson(jsonPath);
2137
+ const status = data.goalAchieved ? 'passed' : data.stuck ? 'stuck' : 'failed';
2138
+ let runBy = null;
2139
+ const runByPath = path.join(sessionPath, 'runBy.json');
2140
+ if (await fs.pathExists(runByPath)) {
2141
+ try { runBy = await fs.readJson(runByPath); } catch {}
2142
+ }
2143
+ sessions.push({
2144
+ id: data.sessionId || entry, status,
2145
+ url: data.url, goal: data.goal,
2146
+ steps: data.steps?.length || 0,
2147
+ issues: data.issues?.length || 0,
2148
+ duration: formatDuration(data.duration || 0),
2149
+ durationMs: data.duration || 0,
2150
+ when: relativeTime(stat.mtime),
2151
+ mtime: stat.mtime.toISOString(),
2152
+ model: data.model, provider: data.provider,
2153
+ runBy,
2154
+ });
2155
+ } catch {}
2156
+ }
2145
2157
  }
2146
2158
  }
2147
2159
  sessions.sort((a, b) => new Date(b.mtime) - new Date(a.mtime));
2148
2160
  return sessions;
2149
2161
  }
2150
2162
  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; }
2163
+ // Check both reportsDir/id and reportsDir/sessions/id
2164
+ const locations = [path.join(reportsDir, id), path.join(reportsDir, 'sessions', id)];
2165
+ for (const sessionPath of locations) {
2166
+ const jsonPath = path.join(sessionPath, 'report.json');
2167
+ if (!await fs.pathExists(jsonPath)) continue;
2168
+ try {
2169
+ const data = await fs.readJson(jsonPath);
2170
+ const runByPath = path.join(sessionPath, 'runBy.json');
2171
+ if (await fs.pathExists(runByPath)) {
2172
+ try { data.runBy = await fs.readJson(runByPath); } catch {}
2173
+ }
2174
+ return data;
2175
+ } catch {}
2176
+ }
2177
+ return null;
2162
2178
  }
2163
2179
  function computeStats(sessions) {
2164
2180
  const total = sessions.length;
@@ -2173,7 +2189,7 @@ function computeStats(sessions) {
2173
2189
  return { total, passed, failed, stuck, passRate, totalIssues, avgDuration: formatDuration(avgMs), avgDurationMs: avgMs, thisWeek };
2174
2190
  }
2175
2191
  async function getConfig() {
2176
- const envPath = path.resolve(process.cwd(), '.skopix.env');
2192
+ const envPath = path.join(os.homedir(), '.skopix.env');
2177
2193
  if (!await fs.pathExists(envPath)) return [];
2178
2194
  const content = await fs.readFile(envPath, 'utf-8');
2179
2195
  const config = [];
@@ -3388,6 +3404,7 @@ async function deleteAllSuiteRuns(suiteRunsDir) {
3388
3404
  async function closeRemoteIssue(issue) {
3389
3405
  const axios = (await import('axios')).default;
3390
3406
  const dotenv = (await import('dotenv')).default;
3407
+ dotenv.config({ path: path.join(os.homedir(), '.skopix.env') });
3391
3408
  dotenv.config({ path: path.resolve(process.cwd(), '.skopix.env') });
3392
3409
  dotenv.config();
3393
3410
 
@@ -3470,6 +3487,7 @@ async function resolveUserSecretsEnv(userId, teamMode) {
3470
3487
  async function syncIssuesStatus() {
3471
3488
  const axios = (await import('axios')).default;
3472
3489
  const dotenv = (await import('dotenv')).default;
3490
+ dotenv.config({ path: path.join(os.homedir(), '.skopix.env') });
3473
3491
  dotenv.config({ path: path.resolve(process.cwd(), '.skopix.env') });
3474
3492
  dotenv.config();
3475
3493
 
package/cli/index.js CHANGED
@@ -32,7 +32,7 @@ program
32
32
  .requiredOption('-u, --url <url>', 'Target URL to test')
33
33
  .requiredOption('-g, --goal <goal>', 'Testing goal (e.g. "complete the checkout flow")')
34
34
  .option('-c, --credentials <file>', 'Path to credentials YAML file')
35
- .option('-o, --output <dir>', 'Output directory for reports', './skopix-reports')
35
+ .option('-o, --output <dir>', 'Output directory for reports')
36
36
  .option('-m, --max-steps <number>', 'Maximum steps the agent will take', '20')
37
37
  .option('--headless', 'Run browser in headless mode', false)
38
38
  .option('--no-video', 'Disable video recording')
@@ -53,7 +53,7 @@ program
53
53
  program
54
54
  .command('report')
55
55
  .description('Open the latest report in your browser')
56
- .option('-d, --dir <dir>', 'Reports directory', './skopix-reports')
56
+ .option('-d, --dir <dir>', 'Reports directory')
57
57
  .action(reportCommand);
58
58
 
59
59
  program
@@ -68,7 +68,7 @@ program
68
68
  .command('dashboard')
69
69
  .description('Launch the web dashboard')
70
70
  .option('-p, --port <port>', 'Port to run the server on', '9000')
71
- .option('-d, --dir <dir>', 'Reports directory', './skopix-reports')
71
+ .option('-d, --dir <dir>', 'Reports directory')
72
72
  .option('-h, --host <host>', 'Host to bind to (default 127.0.0.1; use 0.0.0.0 for team mode)')
73
73
  .option('--team', 'Enable multi-user team mode (requires SQLite)')
74
74
  .option('--no-open', 'Do not auto-open the browser')
package/core/llm.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import dotenv from 'dotenv';
2
2
  import path from 'path';
3
+ import os from 'os';
3
4
  import axios from 'axios';
4
5
 
5
- const envPath = path.resolve(process.cwd(), '.skopix.env');
6
- dotenv.config({ path: envPath });
6
+ // Always load from home directory
7
+ dotenv.config({ path: path.join(os.homedir(), '.skopix.env') });
7
8
  dotenv.config();
8
9
 
9
10
  export class LLMRouter {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skopix",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
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": {