skopix 2.0.2 → 2.0.4

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.
@@ -218,6 +218,8 @@ export async function agentCommand(options) {
218
218
 
219
219
  if (env) Object.assign(process.env, env);
220
220
 
221
+ const startedAt = Date.now();
222
+
221
223
  try {
222
224
  const { chromium } = await import('playwright');
223
225
  const sessionDir = path.join(os.homedir(), '.skopix', runId);
@@ -271,24 +273,23 @@ export async function agentCommand(options) {
271
273
  await fs.writeJson(path.join(sessionDir, 'report.json'), {
272
274
  sessionId: runId, goalAchieved: passed, url: test.url || '',
273
275
  goal: test.name + ' (recorded replay)', steps: allSteps.slice(0, stepNum),
274
- duration: 0, type: 'replay', provider: 'replay',
276
+ duration: Date.now() - startedAt, type: 'replay', provider: 'replay',
275
277
  }, { spaces: 2 }).catch(() => {});
276
278
 
277
- // Write report.html — two-column layout: video on left, step list on right
279
+ // Write report.html — two-column layout with inline screenshots
278
280
  try {
279
- const duration = '0.0';
281
+ const duration = ((Date.now() - startedAt) / 1000).toFixed(1);
280
282
  const stepRows = allSteps.slice(0, stepNum).map((s, i) => {
281
283
  const desc = (s.description || (s.action + ' ' + (s.stableSelector || s.selector || ''))).replace(/</g, '&lt;').replace(/>/g, '&gt;');
282
284
  const screenshotFile = 'step-' + String(i + 1).padStart(3, '0') + '.png';
283
285
  const isAssert = s.action === 'assert';
284
286
  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>';
287
+ const actionLabel = isAssert ? ('✓ ' + (s.assertType || 'assert')) : s.action.toUpperCase();
288
+ const actionClass = isAssert ? 'assert-action' : (isFailed ? 'failed-action' : '');
289
+ return '<div class="step ' + (isFailed ? 'failed-step' : '') + '" onclick="showStep(' + i + ')"><div class="step-header"><div class="step-num">' + String(i + 1).padStart(2, '0') + '</div><div class="step-body"><div class="step-action ' + actionClass + '">' + actionLabel + '</div><div class="step-desc">' + desc + '</div></div></div><img class="step-shot" src="' + screenshotFile + '" loading="lazy" onerror="this.style.display=\'none\'"></div>';
289
290
  }).join('\n');
290
291
 
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
+ 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;position:sticky;top:0}.right{padding:0;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}#media video,#media img{width:100%;border-radius:8px;background:#000}.step{padding:16px 24px;border-bottom:1px solid rgba(255,255,255,0.06);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-header{display:flex;gap:14px;margin-bottom:12px}.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}.failed-action{color:#ef4444}.step-desc{font-size:13px}.failed-step .step-action{color:#ef4444}.step-shot{width:100%;border-radius:6px;border:1px solid rgba(255,255,255,0.08);max-height:300px;object-fit:contain;background:#000}</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
 
293
294
  await fs.writeFile(path.join(sessionDir, 'report.html'), html);
294
295
  } catch (e) { /* ignore report html errors */ }
@@ -382,7 +383,15 @@ export async function agentCommand(options) {
382
383
  } else if (step.action === 'assert') {
383
384
  const assertSel = sanitiseSelector(step.stableSelector || step.selector);
384
385
  switch (step.assertType) {
385
- case 'visible': await page.locator(assertSel).first().waitFor({ state: 'visible', timeout: 10000 }); break;
386
+ case 'visible': {
387
+ try {
388
+ await page.locator(assertSel).first().waitFor({ state: 'visible', timeout: 10000 });
389
+ } catch {
390
+ // Fall back to checking element exists in DOM (some elements are technically hidden but visually present via opacity/transform)
391
+ await page.locator(assertSel).first().waitFor({ state: 'attached', timeout: 5000 });
392
+ }
393
+ break;
394
+ }
386
395
  case 'text_contains': { const txt = await page.locator(assertSel).first().textContent({ timeout: 10000 }); if (!txt || !txt.includes(step.value || '')) throw new Error('Expected to contain "' + step.value + '"'); break; }
387
396
  case 'text_equals': { const txt = await page.locator(assertSel).first().textContent({ timeout: 10000 }); if ((txt || '').trim() !== (step.value || '').trim()) throw new Error('Expected "' + step.value + '"'); break; }
388
397
  case 'url_contains': if (!page.url().includes(step.value || '')) throw new Error('URL does not contain "' + step.value + '"'); break;
@@ -2117,14 +2117,13 @@ function testIdFromName(name) {
2117
2117
 
2118
2118
  // ─── SESSIONS ─────────────────────────────────────────────────────────────────
2119
2119
  async function listSessions(reportsDir) {
2120
- if (!await fs.pathExists(reportsDir)) { process.stderr.write('[sessions] reportsDir not found: ' + reportsDir + '\n'); return []; }
2120
+ if (!await fs.pathExists(reportsDir)) return [];
2121
2121
  const sessions = [];
2122
2122
  // Check both reportsDir directly and reportsDir/sessions subfolder
2123
2123
  const dirsToCheck = [reportsDir, path.join(reportsDir, 'sessions')];
2124
2124
  for (const dir of dirsToCheck) {
2125
2125
  if (!await fs.pathExists(dir)) continue;
2126
2126
  const entries = await fs.readdir(dir);
2127
- process.stderr.write('[sessions] scanning ' + dir + ' entries: ' + entries.join(',') + '\n');
2128
2127
  for (const entry of entries) {
2129
2128
  if (entry.startsWith('.')) continue;
2130
2129
  const sessionPath = path.join(dir, entry);
@@ -2899,7 +2898,11 @@ function startReplay(test, setupTest, activeRuns, reportsDir, currentUser, env)
2899
2898
  const assertSel = sanitiseSelector(step.stableSelector || step.selector);
2900
2899
  switch (step.assertType) {
2901
2900
  case 'visible':
2902
- await page.locator(assertSel).first().waitFor({ state: 'visible', timeout: 10000 });
2901
+ try {
2902
+ await page.locator(assertSel).first().waitFor({ state: 'visible', timeout: 10000 });
2903
+ } catch {
2904
+ await page.locator(assertSel).first().waitFor({ state: 'attached', timeout: 5000 });
2905
+ }
2903
2906
  broadcast({ type: 'stdout', text: ' \u2713 Visible: ' + assertSel });
2904
2907
  break;
2905
2908
  case 'text_contains': {
package/core/llm.js CHANGED
@@ -581,7 +581,7 @@ export async function processRecording({ steps, testName, url, provider, apiKey,
581
581
  + ' - For scroll actions: if selector is window use await page.evaluate(() => window.scrollTo(x, y)), otherwise use document.querySelector(sel).scrollTo(x, y)\n'
582
582
  + ' - Add await page.waitForLoadState("networkidle") after navigation actions\n'
583
583
  + ' - For assert steps: generate correct Playwright expect() calls:\n'
584
- + ' visible -> await expect(page.locator(selector)).toBeVisible();\n'
584
+ + ' visible -> await expect(page.locator(selector)).toBeAttached(); // use toBeAttached not toBeVisible — elements may be in DOM but hidden via CSS\n'
585
585
  + ' text_contains -> await expect(page.locator(selector)).toContainText("value");\n'
586
586
  + ' text_equals -> await expect(page.locator(selector)).toHaveText("value");\n'
587
587
  + ' url_contains -> await expect(page).toHaveURL(/value/);\n'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skopix",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
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": {