@ytspar/sweetlink 1.19.0 → 1.21.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.
Files changed (94) hide show
  1. package/README.md +40 -0
  2. package/dist/cli/outputSchemas.d.ts +57 -1
  3. package/dist/cli/outputSchemas.d.ts.map +1 -1
  4. package/dist/cli/outputSchemas.js +36 -1
  5. package/dist/cli/outputSchemas.js.map +1 -1
  6. package/dist/cli/sweetlink.js +400 -48
  7. package/dist/cli/sweetlink.js.map +1 -1
  8. package/dist/daemon/browser.d.ts.map +1 -1
  9. package/dist/daemon/browser.js.map +1 -1
  10. package/dist/daemon/client.d.ts +7 -0
  11. package/dist/daemon/client.d.ts.map +1 -1
  12. package/dist/daemon/client.js +16 -2
  13. package/dist/daemon/client.js.map +1 -1
  14. package/dist/daemon/demo.d.ts.map +1 -1
  15. package/dist/daemon/demo.js +6 -2
  16. package/dist/daemon/demo.js.map +1 -1
  17. package/dist/daemon/diff.d.ts.map +1 -1
  18. package/dist/daemon/diff.js +5 -3
  19. package/dist/daemon/diff.js.map +1 -1
  20. package/dist/daemon/evidence.d.ts.map +1 -1
  21. package/dist/daemon/evidence.js +6 -6
  22. package/dist/daemon/evidence.js.map +1 -1
  23. package/dist/daemon/index.js +1 -1
  24. package/dist/daemon/index.js.map +1 -1
  25. package/dist/daemon/listeners.d.ts.map +1 -1
  26. package/dist/daemon/listeners.js +8 -6
  27. package/dist/daemon/listeners.js.map +1 -1
  28. package/dist/daemon/recording.d.ts +5 -0
  29. package/dist/daemon/recording.d.ts.map +1 -1
  30. package/dist/daemon/recording.js +38 -15
  31. package/dist/daemon/recording.js.map +1 -1
  32. package/dist/daemon/refs.d.ts.map +1 -1
  33. package/dist/daemon/refs.js +1 -1
  34. package/dist/daemon/refs.js.map +1 -1
  35. package/dist/daemon/ringBuffer.d.ts +8 -0
  36. package/dist/daemon/ringBuffer.d.ts.map +1 -1
  37. package/dist/daemon/ringBuffer.js +17 -0
  38. package/dist/daemon/ringBuffer.js.map +1 -1
  39. package/dist/daemon/server.d.ts.map +1 -1
  40. package/dist/daemon/server.js +490 -86
  41. package/dist/daemon/server.js.map +1 -1
  42. package/dist/daemon/stateFile.js +2 -2
  43. package/dist/daemon/stateFile.js.map +1 -1
  44. package/dist/daemon/summary.d.ts +1 -1
  45. package/dist/daemon/summary.d.ts.map +1 -1
  46. package/dist/daemon/summary.js +2 -2
  47. package/dist/daemon/summary.js.map +1 -1
  48. package/dist/daemon/types.d.ts +1 -1
  49. package/dist/daemon/types.d.ts.map +1 -1
  50. package/dist/daemon/types.js.map +1 -1
  51. package/dist/daemon/viewer.d.ts +1 -1
  52. package/dist/daemon/viewer.d.ts.map +1 -1
  53. package/dist/daemon/viewer.js +21 -13
  54. package/dist/daemon/viewer.js.map +1 -1
  55. package/dist/daemon/visualDiff.js +1 -1
  56. package/dist/daemon/visualDiff.js.map +1 -1
  57. package/dist/next.js +3 -3
  58. package/dist/next.js.map +1 -1
  59. package/dist/ruler.js +3 -1
  60. package/dist/ruler.js.map +1 -1
  61. package/dist/runs.d.ts +34 -0
  62. package/dist/runs.d.ts.map +1 -0
  63. package/dist/runs.js +61 -0
  64. package/dist/runs.js.map +1 -0
  65. package/dist/server/index.d.ts.map +1 -1
  66. package/dist/server/index.js +20 -10
  67. package/dist/server/index.js.map +1 -1
  68. package/dist/simulator/android.d.ts +13 -0
  69. package/dist/simulator/android.d.ts.map +1 -1
  70. package/dist/simulator/android.js +75 -5
  71. package/dist/simulator/android.js.map +1 -1
  72. package/dist/simulator/androidTaps.d.ts +99 -0
  73. package/dist/simulator/androidTaps.d.ts.map +1 -0
  74. package/dist/simulator/androidTaps.js +162 -0
  75. package/dist/simulator/androidTaps.js.map +1 -0
  76. package/dist/simulator/ios.d.ts.map +1 -1
  77. package/dist/simulator/ios.js +13 -5
  78. package/dist/simulator/ios.js.map +1 -1
  79. package/dist/simulator/overlay.d.ts +41 -0
  80. package/dist/simulator/overlay.d.ts.map +1 -0
  81. package/dist/simulator/overlay.js +78 -0
  82. package/dist/simulator/overlay.js.map +1 -0
  83. package/dist/term/ansi.d.ts.map +1 -1
  84. package/dist/term/ansi.js +49 -14
  85. package/dist/term/ansi.js.map +1 -1
  86. package/dist/term/player.d.ts.map +1 -1
  87. package/dist/term/player.js +5 -5
  88. package/dist/term/player.js.map +1 -1
  89. package/dist/term/recorder.js +1 -1
  90. package/dist/term/recorder.js.map +1 -1
  91. package/dist/vite.d.ts.map +1 -1
  92. package/dist/vite.js +8 -4
  93. package/dist/vite.js.map +1 -1
  94. package/package.json +1 -1
@@ -6,17 +6,17 @@
6
6
  * Manages idle timer for auto-shutdown.
7
7
  */
8
8
  import { createServer } from 'http';
9
- import { closeBrowser, getBrowserInstance, getPage, initBrowser, takeResponsiveScreenshots, takeScreenshot } from './browser.js';
10
- import { annotateScreenshot, diffSnapshots } from './diff.js';
9
+ import { closeBrowser, getBrowserInstance, getPage, initBrowser, takeResponsiveScreenshots, takeScreenshot, } from './browser.js';
11
10
  import { takeDeviceScreenshots } from './devices.js';
12
- import { consoleBuffer, dialogBuffer, formatConsoleEntries, formatNetworkEntries, getErrorCount, getWarningCount, networkBuffer, } from './listeners.js';
13
- import { getRecordingPage, getRecordingStatus, isRecording, logAction, pauseRecording, resumeRecording, startRecording, stopRecording } from './recording.js';
11
+ import { annotateScreenshot, diffSnapshots } from './diff.js';
14
12
  import { detectServerErrors } from './errorPatterns.js';
13
+ import { consoleBuffer, dialogBuffer, formatConsoleEntries, formatNetworkEntries, getErrorCount, getWarningCount, networkBuffer, } from './listeners.js';
14
+ import { getRecordingEventCursors, getRecordingPage, getRecordingStatus, isRecording, logAction, pauseRecording, resumeRecording, startRecording, stopRecording, } from './recording.js';
15
+ import { buildRefMap, checkRefStale, formatRefMap, getBaseline, getCurrentRefMap, resolveRef, setBaseline, } from './refs.js';
15
16
  import { generateSummary } from './summary.js';
17
+ import { DAEMON_IDLE_TIMEOUT_MS, DEFAULT_RESPONSIVE_VIEWPORTS } from './types.js';
16
18
  import { generateViewer } from './viewer.js';
17
19
  import { visualDiff } from './visualDiff.js';
18
- import { buildRefMap, checkRefStale, formatRefMap, getBaseline, getCurrentRefMap, resolveRef, setBaseline, } from './refs.js';
19
- import { DAEMON_IDLE_TIMEOUT_MS, DEFAULT_RESPONSIVE_VIEWPORTS } from './types.js';
20
20
  // ============================================================================
21
21
  // State
22
22
  // ============================================================================
@@ -197,7 +197,10 @@ async function captureFailure(page, reason) {
197
197
  const dir = '.sweetlink/failures';
198
198
  await fsp.mkdir(dir, { recursive: true });
199
199
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
200
- const slug = reason.replace(/[^a-z0-9]/gi, '-').slice(0, 40).toLowerCase();
200
+ const slug = reason
201
+ .replace(/[^a-z0-9]/gi, '-')
202
+ .slice(0, 40)
203
+ .toLowerCase();
201
204
  const filePath = `${dir}/${stamp}-${slug}.png`;
202
205
  const buf = await page.screenshot({ fullPage: false });
203
206
  await fsp.writeFile(filePath, buf);
@@ -207,6 +210,314 @@ async function captureFailure(page, reason) {
207
210
  return undefined;
208
211
  }
209
212
  }
213
+ function failureData(page, details, failureScreenshot) {
214
+ return {
215
+ currentUrl: page.url(),
216
+ ...details,
217
+ ...(failureScreenshot ? { failureScreenshot } : {}),
218
+ };
219
+ }
220
+ function slugifyArtifact(value) {
221
+ return (value
222
+ .toLowerCase()
223
+ .replace(/[^a-z0-9]+/g, '-')
224
+ .replace(/^-|-$/g, '')
225
+ .slice(0, 72) || 'inspect');
226
+ }
227
+ function normalizeActionTranscript(value) {
228
+ if (!Array.isArray(value))
229
+ return [];
230
+ return value.flatMap((entry) => {
231
+ if (!entry || typeof entry !== 'object')
232
+ return [];
233
+ const record = entry;
234
+ const action = typeof record.action === 'string' ? record.action : undefined;
235
+ if (!action)
236
+ return [];
237
+ return [
238
+ {
239
+ action,
240
+ target: typeof record.target === 'string' ? record.target : undefined,
241
+ result: typeof record.result === 'string' ? record.result : undefined,
242
+ },
243
+ ];
244
+ });
245
+ }
246
+ async function getPageInfo(page) {
247
+ return page.evaluate(() => {
248
+ const nav = performance.getEntriesByType('navigation')[0];
249
+ const fcp = performance.getEntriesByName('first-contentful-paint')[0];
250
+ return {
251
+ url: location.href,
252
+ title: document.title,
253
+ viewport: {
254
+ width: window.innerWidth,
255
+ height: window.innerHeight,
256
+ deviceScaleFactor: window.devicePixelRatio,
257
+ },
258
+ vitals: {
259
+ fcp: fcp ? Math.round(fcp.startTime) : null,
260
+ pageSize: nav ? nav.transferSize || nav.encodedBodySize || null : null,
261
+ },
262
+ };
263
+ });
264
+ }
265
+ async function getAxeSource() {
266
+ try {
267
+ const axeModule = await import('axe-core');
268
+ const candidate = axeModule;
269
+ return candidate.source ?? candidate.default?.source ?? null;
270
+ }
271
+ catch {
272
+ return null;
273
+ }
274
+ }
275
+ async function runInspectA11y(page) {
276
+ const source = await getAxeSource();
277
+ if (!source) {
278
+ return {
279
+ ok: false,
280
+ error: 'axe-core is not available in this environment',
281
+ };
282
+ }
283
+ try {
284
+ await page.addScriptTag({ content: source });
285
+ return await page.evaluate(async () => {
286
+ const axe = window.axe;
287
+ if (!axe)
288
+ throw new Error('axe-core did not initialize on the page');
289
+ const compactIssue = (issue) => ({
290
+ id: issue.id,
291
+ impact: issue.impact ?? 'unknown',
292
+ help: issue.help ?? '',
293
+ description: issue.description ?? '',
294
+ helpUrl: issue.helpUrl ?? '',
295
+ nodes: (issue.nodes ?? []).slice(0, 3).map((node) => ({
296
+ target: (node.target ?? []).join(' '),
297
+ html: (node.html ?? '').slice(0, 180),
298
+ summary: node.failureSummary ??
299
+ node.any?.[0]?.message ??
300
+ node.all?.[0]?.message ??
301
+ node.none?.[0]?.message ??
302
+ '',
303
+ })),
304
+ });
305
+ const result = await axe.run(document, {
306
+ runOnly: {
307
+ type: 'tag',
308
+ values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'],
309
+ },
310
+ rules: {
311
+ 'color-contrast': { enabled: true },
312
+ },
313
+ });
314
+ const byImpact = {};
315
+ for (const violation of result.violations) {
316
+ const impact = violation.impact ?? 'unknown';
317
+ byImpact[impact] = (byImpact[impact] ?? 0) + 1;
318
+ }
319
+ return {
320
+ ok: true,
321
+ summary: {
322
+ violations: result.violations.length,
323
+ incomplete: result.incomplete.length,
324
+ passes: result.passes.length,
325
+ byImpact,
326
+ },
327
+ violations: result.violations.map(compactIssue),
328
+ incomplete: result.incomplete.map(compactIssue),
329
+ };
330
+ });
331
+ }
332
+ catch (error) {
333
+ return {
334
+ ok: false,
335
+ error: error instanceof Error ? error.message : String(error),
336
+ };
337
+ }
338
+ }
339
+ function inspectNextActions(counts, artifacts) {
340
+ const actions = [
341
+ `Open ${artifacts.summaryMarkdown} and ${artifacts.screenshotPng} before making visual claims.`,
342
+ ];
343
+ if (counts.consoleErrors > 0) {
344
+ actions.push('Investigate console errors before changing UI behavior.');
345
+ }
346
+ else if (counts.consoleWarnings > 0) {
347
+ actions.push('Review console warnings for stale props, hydration, or deprecated API signals.');
348
+ }
349
+ if (counts.networkFailures > 0) {
350
+ actions.push('Inspect failed network entries before assuming the UI state is a component bug.');
351
+ }
352
+ if ((counts.a11yViolations ?? 0) > 0 || (counts.a11yIncomplete ?? 0) > 0) {
353
+ actions.push(`Use ${artifacts.a11yJson ?? 'the a11y artifact'} to prioritize accessibility fixes.`);
354
+ }
355
+ if (counts.refs > 0) {
356
+ actions.push('Use @e refs for click/fill/press actions, then rerun inspect after DOM changes.');
357
+ }
358
+ else {
359
+ actions.push('No interactive refs were found; inspect the DOM/outline before attempting actions.');
360
+ }
361
+ return actions;
362
+ }
363
+ function renderInspectSummary(data) {
364
+ const transcript = data.actionTranscript.length > 0
365
+ ? data.actionTranscript
366
+ .map((entry, index) => {
367
+ const target = entry.target ? ` target=${entry.target}` : '';
368
+ const result = entry.result ? ` result=${entry.result}` : '';
369
+ return `${index + 1}. ${entry.action}${target}${result}`;
370
+ })
371
+ .join('\n')
372
+ : '(none supplied)';
373
+ const refs = data.refs.length > 0
374
+ ? data.refs.map((ref) => `- ${ref.ref} [${ref.role}] "${ref.name}"`).join('\n')
375
+ : '(no interactive refs)';
376
+ const a11ySummary = data.a11y
377
+ ? JSON.stringify(data.a11y.summary ?? { ok: data.a11y.ok, error: data.a11y.error }, null, 2)
378
+ : '(skipped)';
379
+ return [
380
+ '# Sweetlink Inspect',
381
+ '',
382
+ `- URL: ${data.url}`,
383
+ `- Title: ${data.title || '(untitled)'}`,
384
+ `- Generated: ${data.generatedAt}`,
385
+ `- Viewport: ${data.viewport.width}x${data.viewport.height} @${data.viewport.deviceScaleFactor}x`,
386
+ `- FCP: ${data.vitals.fcp ?? 'n/a'}ms`,
387
+ `- Page size: ${data.vitals.pageSize ?? 'n/a'} bytes`,
388
+ '',
389
+ '## Expected Outcome',
390
+ '',
391
+ data.expectedOutcome ?? '(not supplied)',
392
+ '',
393
+ '## Action Transcript',
394
+ '',
395
+ transcript,
396
+ '',
397
+ '## Counts',
398
+ '',
399
+ `- Interactive refs: ${data.counts.refs}`,
400
+ `- Console: ${data.counts.consoleEntries} entries, ${data.counts.consoleErrors} errors, ${data.counts.consoleWarnings} warnings`,
401
+ `- Network: ${data.counts.networkEntries} entries, ${data.counts.networkFailures} failures`,
402
+ `- Accessibility: ${data.counts.a11yViolations ?? 'n/a'} violations, ${data.counts.a11yIncomplete ?? 'n/a'} incomplete`,
403
+ '',
404
+ '## Next Actions',
405
+ '',
406
+ data.nextActions.map((action) => `- ${action}`).join('\n'),
407
+ '',
408
+ '## Artifacts',
409
+ '',
410
+ ...Object.entries(data.artifacts).map(([key, value]) => `- ${key}: ${value ?? '(not generated)'}`),
411
+ '',
412
+ '## Interactive Refs',
413
+ '',
414
+ refs,
415
+ '',
416
+ '## Console',
417
+ '',
418
+ data.consoleText,
419
+ '',
420
+ '## Network',
421
+ '',
422
+ data.networkText,
423
+ '',
424
+ '## Accessibility',
425
+ '',
426
+ a11ySummary,
427
+ '',
428
+ ].join('\n');
429
+ }
430
+ async function handleInspect(params, url) {
431
+ await initBrowser(url);
432
+ const page = getRecordingPage() ?? getPage();
433
+ const pageInfo = await getPageInfo(page);
434
+ const generatedAt = new Date().toISOString();
435
+ const label = typeof params.label === 'string' ? params.label : 'inspect';
436
+ const stamp = generatedAt.replace(/[:.]/g, '-');
437
+ const { promises: fsp } = await import('fs');
438
+ const path = await import('path');
439
+ const dir = path.resolve(`.sweetlink/inspect/${stamp}-${slugifyArtifact(label)}`);
440
+ await fsp.mkdir(dir, { recursive: true });
441
+ const lastRaw = typeof params.last === 'number' ? params.last : Number(params.last);
442
+ const last = Number.isFinite(lastRaw) && lastRaw > 0 ? Math.min(Math.floor(lastRaw), 500) : 50;
443
+ const includeA11y = params.includeA11y !== false;
444
+ const expectedOutcome = typeof params.expectedOutcome === 'string' ? params.expectedOutcome : undefined;
445
+ const actionTranscript = normalizeActionTranscript(params.actionTranscript);
446
+ const refMap = await buildRefMap(page, { interactive: true });
447
+ const snapshotText = formatRefMap(refMap);
448
+ const screenshotBuffer = await page.screenshot({ fullPage: true });
449
+ const consoleEntries = consoleBuffer.toArray().slice(-last);
450
+ const networkEntries = networkBuffer.toArray().slice(-last);
451
+ const consoleText = formatConsoleEntries(consoleEntries);
452
+ const networkText = formatNetworkEntries(networkEntries);
453
+ const a11y = includeA11y ? await runInspectA11y(page) : undefined;
454
+ const artifacts = {
455
+ dir,
456
+ summaryMarkdown: path.join(dir, 'SUMMARY.md'),
457
+ contextJson: path.join(dir, 'context.json'),
458
+ screenshotPng: path.join(dir, 'screenshot.png'),
459
+ snapshotMarkdown: path.join(dir, 'snapshot.md'),
460
+ consoleText: path.join(dir, 'console.txt'),
461
+ networkText: path.join(dir, 'network.txt'),
462
+ a11yJson: includeA11y ? path.join(dir, 'a11y.json') : undefined,
463
+ };
464
+ const a11ySummary = a11y?.summary;
465
+ const counts = {
466
+ refs: refMap.entries.length,
467
+ consoleEntries: consoleEntries.length,
468
+ consoleErrors: consoleEntries.filter((entry) => entry.level === 'error').length,
469
+ consoleWarnings: consoleEntries.filter((entry) => entry.level === 'warning').length,
470
+ networkEntries: networkEntries.length,
471
+ networkFailures: networkEntries.filter((entry) => entry.status >= 400 || entry.status === 0)
472
+ .length,
473
+ a11yViolations: a11ySummary?.violations,
474
+ a11yIncomplete: a11ySummary?.incomplete,
475
+ };
476
+ const nextActions = inspectNextActions(counts, artifacts);
477
+ const context = {
478
+ url: pageInfo.url,
479
+ title: pageInfo.title,
480
+ generatedAt,
481
+ viewport: pageInfo.viewport,
482
+ vitals: pageInfo.vitals,
483
+ artifacts,
484
+ counts,
485
+ refs: refMap.entries,
486
+ console: { entries: consoleEntries, formatted: consoleText },
487
+ network: { entries: networkEntries, formatted: networkText },
488
+ a11y,
489
+ expectedOutcome,
490
+ actionTranscript,
491
+ failureArtifacts: [],
492
+ nextActions,
493
+ };
494
+ await fsp.writeFile(artifacts.screenshotPng, screenshotBuffer);
495
+ await fsp.writeFile(artifacts.snapshotMarkdown, snapshotText, 'utf-8');
496
+ await fsp.writeFile(artifacts.consoleText, consoleText, 'utf-8');
497
+ await fsp.writeFile(artifacts.networkText, networkText, 'utf-8');
498
+ if (artifacts.a11yJson) {
499
+ await fsp.writeFile(artifacts.a11yJson, JSON.stringify(a11y ?? null, null, 2), 'utf-8');
500
+ }
501
+ const summary = renderInspectSummary({
502
+ url: context.url,
503
+ title: context.title,
504
+ generatedAt: context.generatedAt,
505
+ viewport: context.viewport,
506
+ vitals: context.vitals,
507
+ artifacts: context.artifacts,
508
+ counts: context.counts,
509
+ refs: context.refs,
510
+ consoleText,
511
+ networkText,
512
+ a11y,
513
+ expectedOutcome,
514
+ actionTranscript,
515
+ nextActions,
516
+ });
517
+ await fsp.writeFile(artifacts.summaryMarkdown, summary, 'utf-8');
518
+ await fsp.writeFile(artifacts.contextJson, JSON.stringify(context, null, 2), 'utf-8');
519
+ return { ok: true, data: context };
520
+ }
210
521
  async function handleClickRef(params, url) {
211
522
  await initBrowser(url);
212
523
  // Use recording page if recording, otherwise main page
@@ -221,7 +532,12 @@ async function handleClickRef(params, url) {
221
532
  return {
222
533
  ok: false,
223
534
  error: `Ref ${ref} is stale — element no longer exists. Run \`snapshot\` to get fresh refs.`,
224
- data: failureScreenshot ? { failureScreenshot } : undefined,
535
+ data: failureData(page, {
536
+ action: 'click-ref',
537
+ ref,
538
+ staleRef: true,
539
+ remediation: 'Run `sweetlink snapshot -i` or `sweetlink inspect` to refresh refs before retrying.',
540
+ }, failureScreenshot),
225
541
  };
226
542
  }
227
543
  const locator = resolveRef(page, ref);
@@ -233,12 +549,31 @@ async function handleClickRef(params, url) {
233
549
  return {
234
550
  ok: false,
235
551
  error: `Ref ${ref} is disabled — cannot click.`,
236
- data: failureScreenshot ? { failureScreenshot } : undefined,
552
+ data: failureData(page, {
553
+ action: 'click-ref',
554
+ ref,
555
+ disabled: true,
556
+ remediation: 'Wait for the control to become enabled or choose a different interactive ref.',
557
+ }, failureScreenshot),
237
558
  };
238
559
  }
239
560
  const box = await locator.boundingBox();
240
561
  const t0 = Date.now();
241
- await locator.click();
562
+ try {
563
+ await locator.click();
564
+ }
565
+ catch (error) {
566
+ const failureScreenshot = await captureFailure(page, `click-failed-${ref}`);
567
+ return {
568
+ ok: false,
569
+ error: `Click failed for ${ref}: ${error instanceof Error ? error.message : String(error)}`,
570
+ data: failureData(page, {
571
+ action: 'click-ref',
572
+ ref,
573
+ remediation: 'Inspect the failure screenshot and rerun `sweetlink snapshot -i` if the layout changed.',
574
+ }, failureScreenshot),
575
+ };
576
+ }
242
577
  const durationMs = Date.now() - t0;
243
578
  // Log action if recording
244
579
  if (isRecording()) {
@@ -258,9 +593,16 @@ async function handleFillRef(params, url) {
258
593
  return { ok: false, error: 'Missing value parameter' };
259
594
  const stale = await checkRefStale(page, ref);
260
595
  if (stale) {
596
+ const failureScreenshot = await captureFailure(page, `stale-fill-ref-${ref}`);
261
597
  return {
262
598
  ok: false,
263
599
  error: `Ref ${ref} is stale — element no longer exists. Run \`snapshot\` to get fresh refs.`,
600
+ data: failureData(page, {
601
+ action: 'fill-ref',
602
+ ref,
603
+ staleRef: true,
604
+ remediation: 'Run `sweetlink snapshot -i` or `sweetlink inspect` to refresh refs before retrying.',
605
+ }, failureScreenshot),
264
606
  };
265
607
  }
266
608
  const locator = resolveRef(page, ref);
@@ -272,12 +614,31 @@ async function handleFillRef(params, url) {
272
614
  return {
273
615
  ok: false,
274
616
  error: `Ref ${ref} is not editable (use click-ref/press-key for non-text inputs).`,
275
- data: failureScreenshot ? { failureScreenshot } : undefined,
617
+ data: failureData(page, {
618
+ action: 'fill-ref',
619
+ ref,
620
+ nonEditable: true,
621
+ remediation: 'Use `fill-ref` only on editable inputs, textareas, or contenteditable fields.',
622
+ }, failureScreenshot),
276
623
  };
277
624
  }
278
625
  const box = await locator.boundingBox();
279
626
  const t0 = Date.now();
280
- await locator.fill(value);
627
+ try {
628
+ await locator.fill(value);
629
+ }
630
+ catch (error) {
631
+ const failureScreenshot = await captureFailure(page, `fill-failed-${ref}`);
632
+ return {
633
+ ok: false,
634
+ error: `Fill failed for ${ref}: ${error instanceof Error ? error.message : String(error)}`,
635
+ data: failureData(page, {
636
+ action: 'fill-ref',
637
+ ref,
638
+ remediation: 'Inspect the failure screenshot and rerun `sweetlink snapshot -i` if the target changed.',
639
+ }, failureScreenshot),
640
+ };
641
+ }
281
642
  const durationMs = Date.now() - t0;
282
643
  if (isRecording()) {
283
644
  await logAction('fill', [ref, value], page, box ?? undefined, durationMs);
@@ -316,7 +677,14 @@ async function handleClickCss(params, url) {
316
677
  return {
317
678
  ok: false,
318
679
  error: `No element found matching: ${selector ?? text} (${found} matches)`,
319
- data: failureScreenshot ? { failureScreenshot } : undefined,
680
+ data: failureData(page, {
681
+ action: 'click-css',
682
+ selector,
683
+ text,
684
+ index,
685
+ matchCount: found,
686
+ remediation: 'Update the selector/text or run `sweetlink inspect` to verify the current DOM.',
687
+ }, failureScreenshot),
320
688
  };
321
689
  }
322
690
  const box = await target.boundingBox();
@@ -334,11 +702,13 @@ async function handleClickCss(params, url) {
334
702
  return { ours: true };
335
703
  if (top === intended || intended.contains(top))
336
704
  return { ours: true };
337
- const desc = top.tagName.toLowerCase() +
338
- (top.id ? '#' + top.id : '') +
339
- (top.className && typeof top.className === 'string'
340
- ? '.' + top.className.split(/\s+/).filter(Boolean).slice(0, 2).join('.')
341
- : '');
705
+ const desc = [
706
+ top.tagName.toLowerCase(),
707
+ top.id ? `#${top.id}` : '',
708
+ top.className && typeof top.className === 'string'
709
+ ? `.${top.className.split(/\s+/).filter(Boolean).slice(0, 2).join('.')}`
710
+ : '',
711
+ ].join('');
342
712
  return { ours: false, coveredBy: desc };
343
713
  }, { cx, cy, sel: selector ?? null });
344
714
  if (!info.ours) {
@@ -346,12 +716,35 @@ async function handleClickCss(params, url) {
346
716
  return {
347
717
  ok: false,
348
718
  error: `Click target ${selector ?? text} is covered by <${info.coveredBy}>. Dismiss the overlay first.`,
349
- data: failureScreenshot ? { failureScreenshot } : undefined,
719
+ data: failureData(page, {
720
+ action: 'click-css',
721
+ selector,
722
+ text,
723
+ index,
724
+ coveredBy: info.coveredBy,
725
+ remediation: 'Scroll, close overlays, or click a visible target before retrying.',
726
+ }, failureScreenshot),
350
727
  };
351
728
  }
352
729
  }
353
730
  const t0 = Date.now();
354
- await target.click();
731
+ try {
732
+ await target.click();
733
+ }
734
+ catch (error) {
735
+ const failureScreenshot = await captureFailure(page, `click-css-failed-${selector ?? text}`);
736
+ return {
737
+ ok: false,
738
+ error: `Click failed for ${selector ?? text}: ${error instanceof Error ? error.message : String(error)}`,
739
+ data: failureData(page, {
740
+ action: 'click-css',
741
+ selector,
742
+ text,
743
+ index,
744
+ remediation: 'Inspect the failure screenshot and rerun `sweetlink inspect` if the layout changed.',
745
+ }, failureScreenshot),
746
+ };
747
+ }
355
748
  const durationMs = Date.now() - t0;
356
749
  const tag = await target.evaluate((el) => el.tagName.toLowerCase()).catch(() => 'unknown');
357
750
  const found = await locator.count();
@@ -499,11 +892,15 @@ async function handleRecordStart(params, url) {
499
892
  const storageState = params.storageState || undefined;
500
893
  const trace = params.trace || undefined;
501
894
  const result = await startRecording(browser, url, '.sweetlink', {
502
- viewport, label, storageState, trace,
895
+ viewport,
896
+ label,
897
+ storageState,
898
+ trace,
503
899
  });
504
900
  return { ok: true, data: { sessionId: result.sessionId, label, trace: !!trace } };
505
901
  }
506
902
  async function handleRecordStop() {
903
+ const eventCursors = getRecordingEventCursors();
507
904
  const manifest = await stopRecording();
508
905
  if (!manifest) {
509
906
  return { ok: false, error: 'No recording in progress' };
@@ -513,8 +910,12 @@ async function handleRecordStop() {
513
910
  let viewerPath;
514
911
  let summaryPath;
515
912
  try {
516
- const consoleLogs = consoleBuffer.toArray();
517
- const networkLogs = networkBuffer.toArray();
913
+ const consoleLogs = eventCursors
914
+ ? consoleBuffer.since(eventCursors.consoleStartCursor)
915
+ : consoleBuffer.toArray();
916
+ const networkLogs = eventCursors
917
+ ? networkBuffer.since(eventCursors.networkStartCursor)
918
+ : networkBuffer.toArray();
518
919
  viewerPath = await generateViewer(manifest, {
519
920
  sessionDir,
520
921
  consoleEntries: consoleLogs,
@@ -523,16 +924,17 @@ async function handleRecordStop() {
523
924
  // Generate SUMMARY.md
524
925
  const { promises: fsp } = await import('fs');
525
926
  // Detect server errors from console log messages
526
- const consoleText = consoleLogs.map(e => e.message).join('\n');
927
+ const consoleText = consoleLogs.map((e) => e.message).join('\n');
527
928
  const serverErrors = detectServerErrors(consoleText);
528
929
  if (serverErrors.length > 0) {
529
930
  manifest.errors.server = serverErrors.length;
530
931
  }
932
+ await fsp.writeFile(`${sessionDir}/sweetlink-session.json`, JSON.stringify(manifest, null, 2), 'utf-8');
531
933
  const summaryMd = generateSummary({
532
934
  manifest,
533
935
  consoleEntries: consoleLogs,
534
936
  networkEntries: networkLogs,
535
- serverErrors: serverErrors.map(e => ({
937
+ serverErrors: serverErrors.map((e) => ({
536
938
  source: 'server',
537
939
  message: e.line,
538
940
  timestamp: Date.now(),
@@ -549,7 +951,9 @@ async function handleRecordStop() {
549
951
  console.error('[Daemon] Report generation error:', e);
550
952
  }
551
953
  // Include a browser-accessible URL for the viewer
552
- const viewerUrl = manifest.sessionId && daemonPort ? `http://127.0.0.1:${daemonPort}/viewer/${manifest.sessionId}` : undefined;
954
+ const viewerUrl = manifest.sessionId && daemonPort
955
+ ? `http://127.0.0.1:${daemonPort}/viewer/${manifest.sessionId}`
956
+ : undefined;
553
957
  return { ok: true, data: { manifest, viewerPath, viewerUrl, summaryPath } };
554
958
  }
555
959
  async function handleRecordStatus() {
@@ -588,8 +992,14 @@ async function handleSessionsList() {
588
992
  try {
589
993
  const raw = await fsp.readFile(manifestPath, 'utf-8');
590
994
  const m = JSON.parse(raw);
591
- const hasVideo = await fsp.access(path.join(dir, e.name, 'session.webm')).then(() => true).catch(() => false);
592
- const hasViewer = await fsp.access(path.join(dir, e.name, 'viewer.html')).then(() => true).catch(() => false);
995
+ const hasVideo = await fsp
996
+ .access(path.join(dir, e.name, 'session.webm'))
997
+ .then(() => true)
998
+ .catch(() => false);
999
+ const hasViewer = await fsp
1000
+ .access(path.join(dir, e.name, 'viewer.html'))
1001
+ .then(() => true)
1002
+ .catch(() => false);
593
1003
  sessions.push({
594
1004
  sessionId: m.sessionId,
595
1005
  label: m.label,
@@ -611,16 +1021,20 @@ async function handleSessionsList() {
611
1021
  sessions.sort((a, b) => (b.startedAt ?? '').localeCompare(a.startedAt ?? ''));
612
1022
  // Also write an index.html that links to every viewer for quick browsing.
613
1023
  try {
614
- const items = sessions.map((s) => {
1024
+ const items = sessions
1025
+ .map((s) => {
615
1026
  const viewerLink = s.hasViewer ? `${s.sessionId}/viewer.html` : '#';
616
1027
  const errorsTotal = s.errors ? s.errors.console + s.errors.network + s.errors.server : 0;
617
1028
  const errBadge = errorsTotal > 0
618
1029
  ? `<span style="color:#c00;font-weight:600">${errorsTotal} err</span>`
619
1030
  : '<span style="color:#0a0">clean</span>';
620
- const labelHtml = s.label ? `<span style="color:#06c">${escape(s.label)}</span> · ` : '';
1031
+ const labelHtml = s.label
1032
+ ? `<span style="color:#06c">${escapeHtml(s.label)}</span> · `
1033
+ : '';
621
1034
  const dur = s.duration ? `${s.duration.toFixed(1)}s` : '—';
622
- return `<li><a href="${viewerLink}">${labelHtml}<code>${escape(s.sessionId)}</code></a> · ${escape(s.url ?? '')} · ${dur} · ${s.actionCount} actions · ${errBadge}</li>`;
623
- }).join('\n');
1035
+ return `<li><a href="${viewerLink}">${labelHtml}<code>${escapeHtml(s.sessionId)}</code></a> · ${escapeHtml(s.url ?? '')} · ${dur} · ${s.actionCount} actions · ${errBadge}</li>`;
1036
+ })
1037
+ .join('\n');
624
1038
  const indexHtml = `<!DOCTYPE html>
625
1039
  <html><head><title>Sweetlink Sessions</title>
626
1040
  <style>
@@ -635,15 +1049,21 @@ a{text-decoration:none;color:#222}
635
1049
  </body></html>`;
636
1050
  await fsp.writeFile(path.join(dir, 'index.html'), indexHtml);
637
1051
  }
638
- catch { /* index is best-effort */ }
1052
+ catch {
1053
+ /* index is best-effort */
1054
+ }
639
1055
  return { ok: true, data: { sessions, indexPath: path.join(dir, 'index.html') } };
640
1056
  }
641
1057
  catch (err) {
642
1058
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
643
1059
  }
644
1060
  }
645
- function escape(s) {
646
- return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1061
+ function escapeHtml(s) {
1062
+ return String(s)
1063
+ .replace(/&/g, '&amp;')
1064
+ .replace(/</g, '&lt;')
1065
+ .replace(/>/g, '&gt;')
1066
+ .replace(/"/g, '&quot;');
647
1067
  }
648
1068
  async function handleGenerateViewer(params) {
649
1069
  const sessionDir = params.sessionDir;
@@ -663,7 +1083,10 @@ async function handleGenerateViewer(params) {
663
1083
  return { ok: true, data: { viewerPath } };
664
1084
  }
665
1085
  catch (error) {
666
- return { ok: false, error: `Failed to generate viewer: ${error instanceof Error ? error.message : error}` };
1086
+ return {
1087
+ ok: false,
1088
+ error: `Failed to generate viewer: ${error instanceof Error ? error.message : error}`,
1089
+ };
667
1090
  }
668
1091
  }
669
1092
  async function handleVisualDiff(params) {
@@ -692,55 +1115,36 @@ async function handleVisualDiff(params) {
692
1115
  // ============================================================================
693
1116
  // Request Handling
694
1117
  // ============================================================================
1118
+ const DAEMON_HANDLERS = {
1119
+ ping: () => handlePing(),
1120
+ shutdown: () => handleShutdown(),
1121
+ screenshot: (params, url) => handleScreenshot(params, url),
1122
+ 'screenshot-responsive': (params, url) => handleResponsiveScreenshot(params, url),
1123
+ snapshot: (params, url) => handleSnapshot(params, url),
1124
+ inspect: (params, url) => handleInspect(params, url),
1125
+ 'click-ref': (params, url) => handleClickRef(params, url),
1126
+ 'click-css': (params, url) => handleClickCss(params, url),
1127
+ 'fill-ref': (params, url) => handleFillRef(params, url),
1128
+ 'hover-ref': (params, url) => handleHoverRef(params, url),
1129
+ 'press-key': (params, url) => handlePressKey(params, url),
1130
+ 'console-read': (params, url) => handleConsoleRead(params, url),
1131
+ 'network-read': (params, url) => handleNetworkRead(params, url),
1132
+ 'dialog-read': (_params, url) => handleDialogRead(url),
1133
+ 'screenshot-devices': (params, url) => handleScreenshotDevices(params, url),
1134
+ 'visual-diff': (params) => handleVisualDiff(params),
1135
+ 'record-start': (params, url) => handleRecordStart(params, url),
1136
+ 'record-pause': () => handleRecordPause(),
1137
+ 'record-resume': () => handleRecordResume(),
1138
+ 'sessions-list': () => handleSessionsList(),
1139
+ 'record-stop': () => handleRecordStop(),
1140
+ 'record-status': () => handleRecordStatus(),
1141
+ 'generate-viewer': (params) => handleGenerateViewer(params),
1142
+ };
695
1143
  async function handleRequest(action, params, url) {
696
- switch (action) {
697
- case 'ping':
698
- return handlePing();
699
- case 'shutdown':
700
- return handleShutdown();
701
- case 'screenshot':
702
- return handleScreenshot(params, url);
703
- case 'screenshot-responsive':
704
- return handleResponsiveScreenshot(params, url);
705
- case 'snapshot':
706
- return handleSnapshot(params, url);
707
- case 'click-ref':
708
- return handleClickRef(params, url);
709
- case 'click-css':
710
- return handleClickCss(params, url);
711
- case 'fill-ref':
712
- return handleFillRef(params, url);
713
- case 'hover-ref':
714
- return handleHoverRef(params, url);
715
- case 'press-key':
716
- return handlePressKey(params, url);
717
- case 'console-read':
718
- return handleConsoleRead(params, url);
719
- case 'network-read':
720
- return handleNetworkRead(params, url);
721
- case 'dialog-read':
722
- return handleDialogRead(url);
723
- case 'screenshot-devices':
724
- return handleScreenshotDevices(params, url);
725
- case 'visual-diff':
726
- return handleVisualDiff(params);
727
- case 'record-start':
728
- return handleRecordStart(params, url);
729
- case 'record-pause':
730
- return handleRecordPause();
731
- case 'record-resume':
732
- return handleRecordResume();
733
- case 'sessions-list':
734
- return handleSessionsList();
735
- case 'record-stop':
736
- return handleRecordStop();
737
- case 'record-status':
738
- return handleRecordStatus();
739
- case 'generate-viewer':
740
- return handleGenerateViewer(params);
741
- default:
742
- return { ok: false, error: `Unknown action: ${action}` };
743
- }
1144
+ const handler = DAEMON_HANDLERS[action];
1145
+ if (!handler)
1146
+ return { ok: false, error: `Unknown action: ${action}` };
1147
+ return handler(params, url);
744
1148
  }
745
1149
  function readBody(req) {
746
1150
  return new Promise((resolve, reject) => {
@@ -807,8 +1211,8 @@ export function startServer(options) {
807
1211
  const { promises: fsp } = await import('fs');
808
1212
  const entries = await fsp.readdir('.sweetlink', { withFileTypes: true });
809
1213
  const sessions = entries
810
- .filter(e => e.isDirectory() && e.name.startsWith('session-'))
811
- .map(e => e.name)
1214
+ .filter((e) => e.isDirectory() && e.name.startsWith('session-'))
1215
+ .map((e) => e.name)
812
1216
  .sort()
813
1217
  .reverse();
814
1218
  res.writeHead(200, { 'Content-Type': 'application/json' });