@ytspar/sweetlink 1.26.2 → 1.26.3-canary.66f78a4

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 (51) hide show
  1. package/README.md +21 -1
  2. package/dist/browser/SweetlinkBridge.d.ts +0 -1
  3. package/dist/browser/SweetlinkBridge.d.ts.map +1 -1
  4. package/dist/browser/SweetlinkBridge.js +33 -29
  5. package/dist/browser/SweetlinkBridge.js.map +1 -1
  6. package/dist/browser/commands/screenshot.d.ts.map +1 -1
  7. package/dist/browser/commands/screenshot.js +3 -0
  8. package/dist/browser/commands/screenshot.js.map +1 -1
  9. package/dist/cli/clickCode.d.ts +35 -0
  10. package/dist/cli/clickCode.d.ts.map +1 -0
  11. package/dist/cli/clickCode.js +68 -0
  12. package/dist/cli/clickCode.js.map +1 -0
  13. package/dist/cli/sweetlink.js +168 -57
  14. package/dist/cli/sweetlink.js.map +1 -1
  15. package/dist/daemon/browser.d.ts +7 -1
  16. package/dist/daemon/browser.d.ts.map +1 -1
  17. package/dist/daemon/browser.js +11 -7
  18. package/dist/daemon/browser.js.map +1 -1
  19. package/dist/daemon/index.js +21 -6
  20. package/dist/daemon/index.js.map +1 -1
  21. package/dist/daemon/server.d.ts +2 -0
  22. package/dist/daemon/server.d.ts.map +1 -1
  23. package/dist/daemon/server.js +26 -5
  24. package/dist/daemon/server.js.map +1 -1
  25. package/dist/daemon/types.d.ts +4 -0
  26. package/dist/daemon/types.d.ts.map +1 -1
  27. package/dist/daemon/types.js.map +1 -1
  28. package/dist/next.d.ts +8 -0
  29. package/dist/next.d.ts.map +1 -1
  30. package/dist/next.js +12 -2
  31. package/dist/next.js.map +1 -1
  32. package/dist/server/discovery.d.ts +52 -0
  33. package/dist/server/discovery.d.ts.map +1 -0
  34. package/dist/server/discovery.js +102 -0
  35. package/dist/server/discovery.js.map +1 -0
  36. package/dist/server/index.d.ts +22 -0
  37. package/dist/server/index.d.ts.map +1 -1
  38. package/dist/server/index.js +145 -31
  39. package/dist/server/index.js.map +1 -1
  40. package/dist/types.d.ts +66 -5
  41. package/dist/types.d.ts.map +1 -1
  42. package/dist/types.js +110 -9
  43. package/dist/types.js.map +1 -1
  44. package/dist/urlUtils.d.ts +35 -0
  45. package/dist/urlUtils.d.ts.map +1 -1
  46. package/dist/urlUtils.js +77 -0
  47. package/dist/urlUtils.js.map +1 -1
  48. package/dist/vite.d.ts.map +1 -1
  49. package/dist/vite.js +5 -4
  50. package/dist/vite.js.map +1 -1
  51. package/package.json +1 -1
@@ -16,8 +16,10 @@ import { extractPort } from '../daemon/stateFile.js';
16
16
  import { ensureDir } from '../daemon/utils.js';
17
17
  import { screenshotViaPlaywright } from '../playwright.js';
18
18
  import { getCardHeaderPreset, getNavigationPreset, measureViaPlaywright } from '../ruler.js';
19
- import { DEFAULT_WS_PORT, MAX_PORT_RETRIES, WS_PORT_OFFSET } from '../types.js';
20
- import { SCREENSHOT_DIR } from '../urlUtils.js';
19
+ import { findServerInfoFile } from '../server/discovery.js';
20
+ import { DEFAULT_WS_PORT, MAX_PORT_RETRIES, parseLocalDevelopmentUrl, parsePortNumber, resolveSweetlinkWsPortForAppPort, } from '../types.js';
21
+ import { SCREENSHOT_DIR, selectClientForTargetUrl, urlsEquivalent } from '../urlUtils.js';
22
+ import { generateClickCode } from './clickCode.js';
21
23
  import { emitJson, printOutputSchema } from './outputSchemas.js';
22
24
  const COMMON_APP_PORTS = [3000, 3001, 4000, 5173, 5174, 8000, 8080];
23
25
  /**
@@ -209,7 +211,7 @@ function reportScreenshotSuccess(outputPath, width, height, method, selector) {
209
211
  }
210
212
  }
211
213
  let resolvedWsUrl = null;
212
- const DEFAULT_WS_URL = process.env.SWEETLINK_WS_URL || 'ws://localhost:9223';
214
+ const DEFAULT_WS_URL = 'ws://localhost:9223';
213
215
  const DEFAULT_TIMEOUT = 30000; // 30 seconds
214
216
  const SERVER_READY_TIMEOUT = 30000; // 30 seconds to wait for server
215
217
  const SERVER_POLL_INTERVAL = 500; // Poll every 500ms
@@ -234,6 +236,7 @@ async function probeServerIdentity(port) {
234
236
  return {
235
237
  port,
236
238
  appPort: data.appPort ?? null,
239
+ pid: typeof data.pid === 'number' ? data.pid : null,
237
240
  gitBranch: data.gitBranch ?? null,
238
241
  appName: data.appName ?? null,
239
242
  };
@@ -252,9 +255,9 @@ async function discoverServer(target) {
252
255
  for (let port = SCAN_PORT_START; port <= SCAN_PORT_END; port++) {
253
256
  probes.push(probeServerIdentity(port));
254
257
  }
255
- // Also probe common offset ports (appPort + 6223)
258
+ // Also probe common offset ports (appPort + 6223, skipping unsafe ports)
256
259
  for (const appPort of COMMON_APP_PORTS) {
257
- const wsPort = appPort + WS_PORT_OFFSET;
260
+ const wsPort = resolveSweetlinkWsPortForAppPort(appPort);
258
261
  if (wsPort < SCAN_PORT_START || wsPort > SCAN_PORT_END) {
259
262
  probes.push(probeServerIdentity(wsPort));
260
263
  }
@@ -280,12 +283,58 @@ async function discoverServer(target) {
280
283
  throw new Error(`No server matching "${target}" found.\nAvailable servers:\n${available}`);
281
284
  }
282
285
  /**
283
- * Resolve the WebSocket URL to use. If --app was provided, scan for
284
- * a matching server. Otherwise use the default/env URL.
286
+ * Resolve this project's server from `.sweetlink/server.json` (written by
287
+ * the server at startup; walked up from cwd). The file can be stale after a
288
+ * crash, so it is only trusted when the info endpoint on that port answers
289
+ * as a sweetlink server whose pid/appPort agree with the file.
290
+ */
291
+ async function resolveWsUrlFromServerInfoFile() {
292
+ const found = findServerInfoFile(process.cwd());
293
+ if (!found)
294
+ return null;
295
+ const { info } = found;
296
+ // With an explicit --url port that disagrees with the file's app port,
297
+ // the user means a different server — fall through to port math.
298
+ const urlArg = getArg('--url');
299
+ const urlPort = urlArg ? parsePortNumber(parseLocalDevelopmentUrl(urlArg)?.port) : null;
300
+ if (urlPort && info.appPort !== null && info.appPort !== urlPort)
301
+ return null;
302
+ const identity = await probeServerIdentity(info.wsPort);
303
+ if (!identity)
304
+ return null; // stale — nothing listening there anymore
305
+ if (identity.pid !== null && identity.pid !== info.pid)
306
+ return null; // port reused
307
+ if (info.appPort !== null && identity.appPort !== null && identity.appPort !== info.appPort) {
308
+ return null; // port reused by another project's server
309
+ }
310
+ console.log(`[Sweetlink] Using server from ${path.relative(process.cwd(), found.filePath) || found.filePath} (ws://localhost:${info.wsPort})`);
311
+ return `ws://localhost:${info.wsPort}`;
312
+ }
313
+ /**
314
+ * Resolve the WebSocket URL to use, in order:
315
+ * 1. SWEETLINK_WS_URL env (explicit override)
316
+ * 2. A live `.sweetlink/server.json` written by this project's server
317
+ * 3. Port math from an explicit --url port (app port + offset)
318
+ * 4. The default ws://localhost:9223
285
319
  */
286
320
  async function getWsUrl() {
287
321
  if (resolvedWsUrl)
288
322
  return resolvedWsUrl;
323
+ if (process.env.SWEETLINK_WS_URL) {
324
+ resolvedWsUrl = process.env.SWEETLINK_WS_URL;
325
+ return resolvedWsUrl;
326
+ }
327
+ const discovered = await resolveWsUrlFromServerInfoFile();
328
+ if (discovered) {
329
+ resolvedWsUrl = discovered;
330
+ return resolvedWsUrl;
331
+ }
332
+ const urlArg = getArg('--url');
333
+ const urlPort = urlArg ? parsePortNumber(parseLocalDevelopmentUrl(urlArg)?.port) : null;
334
+ if (urlPort) {
335
+ resolvedWsUrl = `ws://localhost:${resolveSweetlinkWsPortForAppPort(urlPort)}`;
336
+ return resolvedWsUrl;
337
+ }
289
338
  resolvedWsUrl = DEFAULT_WS_URL;
290
339
  return resolvedWsUrl;
291
340
  }
@@ -347,8 +396,67 @@ async function waitForServer(url, timeout = SERVER_READY_TIMEOUT) {
347
396
  }
348
397
  throw new Error(`Server not ready after ${timeout}ms: ${lastError?.message || 'Connection refused'}`);
349
398
  }
399
+ /**
400
+ * One-shot preflight for --url commands: verify a browser client that could
401
+ * plausibly be on the target page is attached to the server before
402
+ * dispatching, and surface an actionable error (listing the connected
403
+ * clients' locations) when every client is on a different page/origin.
404
+ * Old servers without per-client info skip the check silently.
405
+ */
406
+ let targetClientPreflightDone = false;
407
+ async function preflightTargetClients(targetUrl) {
408
+ if (targetClientPreflightDone)
409
+ return;
410
+ targetClientPreflightDone = true;
411
+ let failure = null;
412
+ try {
413
+ const wsUrl = await getWsUrl();
414
+ const httpUrl = wsUrl.replace(/^ws:\/\//, 'http://').replace(/^wss:\/\//, 'https://');
415
+ const controller = new AbortController();
416
+ const timeoutId = setTimeout(() => controller.abort(), 2000);
417
+ const response = await fetch(httpUrl, { signal: controller.signal });
418
+ clearTimeout(timeoutId);
419
+ if (!response.ok)
420
+ return;
421
+ const data = (await response.json());
422
+ if (!Array.isArray(data?.clients))
423
+ return; // old server — no visibility
424
+ const browserClients = data.clients.filter((c) => c?.type === 'browser');
425
+ if (browserClients.length === 0)
426
+ return; // "No browser client" paths handle this
427
+ const { match } = selectClientForTargetUrl(browserClients, targetUrl);
428
+ if (match === 'none') {
429
+ const locations = browserClients
430
+ .map((c) => c.url ?? c.origin ?? 'unknown location')
431
+ .join(', ');
432
+ failure =
433
+ `No connected browser client matches ${targetUrl}. ` +
434
+ `Connected client(s): ${locations}. ` +
435
+ 'Open the page in a browser (or fix --url) and retry.';
436
+ }
437
+ else if (match === 'unknown-location') {
438
+ console.warn(`[Sweetlink] ⚠ Connected browser client has not reported its location ` +
439
+ `(older devbar build?) — cannot verify it is on ${targetUrl}`);
440
+ }
441
+ }
442
+ catch {
443
+ // Info endpoint unreachable or unparseable — fall through to normal
444
+ // command dispatch, which has its own failure handling.
445
+ return;
446
+ }
447
+ if (failure) {
448
+ console.error(`[Sweetlink] ${failure}`);
449
+ throw new Error(failure);
450
+ }
451
+ }
350
452
  async function sendCommand(command, timeoutMs = DEFAULT_TIMEOUT) {
351
453
  const wsUrl = await getWsUrl();
454
+ // Target the client at --url so a server with several attached pages
455
+ // (multi-project setups) never dispatches this command to a foreign page.
456
+ const targetUrl = getArg('--url');
457
+ if (targetUrl)
458
+ await preflightTargetClients(targetUrl);
459
+ const payload = targetUrl ? { ...command, targetUrl } : command;
352
460
  return new Promise((resolve, reject) => {
353
461
  const ws = new WebSocket(wsUrl);
354
462
  const timer = setTimeout(() => {
@@ -356,7 +464,7 @@ async function sendCommand(command, timeoutMs = DEFAULT_TIMEOUT) {
356
464
  reject(new Error('Command timeout - is the dev server running?'));
357
465
  }, timeoutMs);
358
466
  ws.on('open', () => {
359
- ws.send(JSON.stringify(command));
467
+ ws.send(JSON.stringify(payload));
360
468
  });
361
469
  ws.on('message', (data) => {
362
470
  clearTimeout(timer);
@@ -371,13 +479,6 @@ async function sendCommand(command, timeoutMs = DEFAULT_TIMEOUT) {
371
479
  });
372
480
  });
373
481
  }
374
- /**
375
- * Compare two URLs ignoring trailing slashes.
376
- * Exported-style name for testability (re-implemented in tests).
377
- */
378
- function urlsMatch(a, b) {
379
- return a.replace(/\/+$/, '') === b.replace(/\/+$/, '');
380
- }
381
482
  const NAVIGATE_POLL_INTERVAL = 500; // ms between reconnection polls
382
483
  const NAVIGATE_DEFAULT_TIMEOUT = 10000; // 10s default
383
484
  /**
@@ -394,7 +495,7 @@ async function navigateBrowser(url, timeout = NAVIGATE_DEFAULT_TIMEOUT) {
394
495
  const response = await sendCommand({ type: 'exec-js', code: 'window.location.href' }, 3000);
395
496
  if (response.success && response.data != null) {
396
497
  const currentUrl = String(response.data.result ?? response.data);
397
- if (urlsMatch(currentUrl, url)) {
498
+ if (urlsEquivalent(currentUrl, url)) {
398
499
  console.log(`[Sweetlink] Browser already on ${url}`);
399
500
  return true;
400
501
  }
@@ -423,7 +524,7 @@ async function navigateBrowser(url, timeout = NAVIGATE_DEFAULT_TIMEOUT) {
423
524
  const response = await sendCommand({ type: 'exec-js', code: 'window.location.href' }, 3000);
424
525
  if (response.success && response.data != null) {
425
526
  const currentUrl = String(response.data.result ?? response.data);
426
- if (urlsMatch(currentUrl, url)) {
527
+ if (urlsEquivalent(currentUrl, url)) {
427
528
  // Give devbar time to fully initialize after page load
428
529
  await new Promise((resolve) => setTimeout(resolve, 1000));
429
530
  console.log(`[Sweetlink] Browser reconnected on ${url}`);
@@ -523,6 +624,9 @@ async function screenshot(options) {
523
624
  const resp = await daemonRequest(daemonState, 'screenshot-responsive', {
524
625
  fullPage: options.fullPage,
525
626
  hideDevbar: options.hideDevbar,
627
+ // Explicit --url only: the daemon re-navigates when the live page
628
+ // differs (SPA routing may have moved it since the last command).
629
+ url: options.url,
526
630
  });
527
631
  const data = resp.data;
528
632
  const outputDir = options.output
@@ -554,6 +658,9 @@ async function screenshot(options) {
554
658
  padding: options.padding,
555
659
  theme: options.theme,
556
660
  hideDevbar: options.hideDevbar,
661
+ // Explicit --url only: the daemon re-navigates when the live page
662
+ // differs (SPA routing may have moved it since the last command).
663
+ url: options.url,
557
664
  });
558
665
  const data = resp.data;
559
666
  const outputPath = options.output || getDefaultScreenshotPath();
@@ -668,10 +775,25 @@ async function screenshot(options) {
668
775
  console.error('[Sweetlink] Screenshot failed:', response.error);
669
776
  process.exit(1);
670
777
  }
778
+ const data = response.data;
779
+ // Tier-1 safety: the browser client reports the URL it actually
780
+ // captured. If --url was requested and the connected client is on a
781
+ // different page (e.g. a zombie headless page left behind by a stale
782
+ // daemon), never save the wrong capture silently — escalate to
783
+ // Playwright, which navigates for real.
784
+ const capturedUrl = typeof data.url === 'string' ? data.url : null;
785
+ if (options.url && capturedUrl && !urlsEquivalent(capturedUrl, options.url)) {
786
+ console.warn(`[Sweetlink] ⚠ Connected browser is on ${capturedUrl}, not ${options.url} — discarding WebSocket capture`);
787
+ if (options.forceWS) {
788
+ console.error('[Sweetlink] Refusing to save a capture of the wrong page (--force-ws prevents Playwright fallback)');
789
+ process.exit(1);
790
+ }
791
+ console.log('[Sweetlink] Escalating to Playwright for the requested URL');
792
+ return await takePlaywrightScreenshot(playwrightOpts, 'Playwright (URL mismatch fallback)');
793
+ }
671
794
  // Save screenshot
672
795
  const outputPath = options.output || getDefaultScreenshotPath();
673
796
  ensureDir(outputPath);
674
- const data = response.data;
675
797
  const base64Data = data.screenshot.replace(/^data:image\/png;base64,/, '');
676
798
  fs.writeFileSync(outputPath, Buffer.from(base64Data, 'base64'));
677
799
  reportScreenshotSuccess(outputPath, data.width, data.height, 'WebSocket (html2canvas)', data.selector);
@@ -1017,39 +1139,6 @@ async function execJS(options) {
1017
1139
  process.exit(1);
1018
1140
  }
1019
1141
  }
1020
- /**
1021
- * Generate JavaScript code that finds elements and clicks the one at the given index.
1022
- * Parameterized by the element-finding strategy: text content search or CSS selector.
1023
- */
1024
- function generateClickCode(strategy, index) {
1025
- // The element-finding expression differs, but the bounds-check + click + return is shared
1026
- const escapedSelector = JSON.stringify(strategy.selector);
1027
- let findExpression;
1028
- let notFoundMsg;
1029
- if (strategy.type === 'text') {
1030
- const escapedText = JSON.stringify(strategy.text);
1031
- findExpression = `Array.from(document.querySelectorAll(${escapedSelector})).filter(el => el.textContent?.includes(${escapedText}))`;
1032
- notFoundMsg = `"No element found with text: " + ${escapedText}`;
1033
- }
1034
- else {
1035
- findExpression = `Array.from(document.querySelectorAll(${escapedSelector}))`;
1036
- notFoundMsg = `"No element found matching: " + ${escapedSelector}`;
1037
- }
1038
- return `
1039
- (() => {
1040
- const elements = ${findExpression};
1041
- if (elements.length === 0) {
1042
- return { success: false, error: ${notFoundMsg} };
1043
- }
1044
- const target = elements[${index}];
1045
- if (!target) {
1046
- return { success: false, error: "Index ${index} out of bounds, found " + elements.length + " elements" };
1047
- }
1048
- target.click();
1049
- return { success: true, clicked: target.tagName + (target.className ? "." + target.className.split(" ")[0] : ""), found: elements.length };
1050
- })()
1051
- `;
1052
- }
1053
1142
  async function click(options) {
1054
1143
  const { selector, text, index = 0 } = options;
1055
1144
  if (!selector && !text) {
@@ -1059,9 +1148,14 @@ async function click(options) {
1059
1148
  let clickCode;
1060
1149
  let description;
1061
1150
  if (text) {
1062
- const baseSelector = selector || '*';
1063
1151
  description = selector ? `"${text}" within ${selector}` : `"${text}"`;
1064
- clickCode = generateClickCode({ type: 'text', text, selector: baseSelector }, index);
1152
+ // Explicit selector: the user chose the candidate scoping. Bare --text:
1153
+ // leaf-most + clickable-preferred matching (see clickCode.ts) so the
1154
+ // click lands on the button around the text, not on <html> (whose
1155
+ // textContent also matches).
1156
+ clickCode = selector
1157
+ ? generateClickCode({ type: 'text', text, selector }, index)
1158
+ : generateClickCode({ type: 'smart-text', text }, index);
1065
1159
  }
1066
1160
  else {
1067
1161
  description = `${selector}${index > 0 ? ` [${index}]` : ''}`;
@@ -1288,7 +1382,7 @@ function getPortsToScan() {
1288
1382
  }
1289
1383
  // Common app ports + offset (e.g., 3000 -> 9223, 5173 -> 11396)
1290
1384
  for (const appPort of COMMON_APP_PORTS) {
1291
- const wsPort = appPort + WS_PORT_OFFSET;
1385
+ const wsPort = resolveSweetlinkWsPortForAppPort(appPort);
1292
1386
  for (let i = 0; i <= MAX_PORT_RETRIES; i++) {
1293
1387
  ports.add(wsPort + i);
1294
1388
  }
@@ -2383,6 +2477,17 @@ function getArg(flag) {
2383
2477
  function hasFlag(flag) {
2384
2478
  return args.includes(flag);
2385
2479
  }
2480
+ /**
2481
+ * Positional argument at `index`, or undefined when that token is a flag.
2482
+ * Flags must never be consumed as positionals: `click --text "Save list"`
2483
+ * used to take the literal `--text` as the positional selector, producing
2484
+ * querySelectorAll("--text") and "No element found" for every documented
2485
+ * `click --text ...` invocation.
2486
+ */
2487
+ function getPositionalArg(index) {
2488
+ const value = args[index];
2489
+ return value !== undefined && !value.startsWith('--') ? value : undefined;
2490
+ }
2386
2491
  // Per-command --help: `pnpm sweetlink screenshot --help`
2387
2492
  // Past the early-exit at the top of dispatch, commandType is non-null.
2388
2493
  if (hasFlag('--help') || hasFlag('-h')) {
@@ -2572,6 +2677,9 @@ async function handleInspectCmd() {
2572
2677
  expectedOutcome: getArg('--expected'),
2573
2678
  actionTranscript,
2574
2679
  includeA11y: !hasFlag('--no-a11y'),
2680
+ // Explicit --url only: the daemon re-navigates when the live page
2681
+ // differs (SPA routing may have moved it since the last command).
2682
+ url: getArg('--url'),
2575
2683
  });
2576
2684
  const data = resp.data;
2577
2685
  const output = getArg('--output');
@@ -2636,7 +2744,7 @@ async function handleExecCmd() {
2636
2744
  });
2637
2745
  }
2638
2746
  async function handleClickCmd() {
2639
- const clickTarget = getArg('--selector') ?? args[1];
2747
+ const clickTarget = getArg('--selector') ?? getPositionalArg(1);
2640
2748
  const clickText = getArg('--text');
2641
2749
  const clickIndex = getArg('--index') ? parseInt(getArg('--index'), 10) : 0;
2642
2750
  const projRoot = findProjectRoot();
@@ -3007,8 +3115,8 @@ async function handleDaemonCmd() {
3007
3115
  return status;
3008
3116
  }
3009
3117
  async function handleFillCmd() {
3010
- const fillTarget = getArg('--selector') ?? args[1];
3011
- const fillValue = getArg('--value') ?? args[2];
3118
+ const fillTarget = getArg('--selector') ?? getPositionalArg(1);
3119
+ const fillValue = getArg('--value') ?? getPositionalArg(2);
3012
3120
  if (!fillTarget) {
3013
3121
  console.error('[Sweetlink] Error: fill requires a target (@ref or --selector)');
3014
3122
  process.exit(1);
@@ -3039,6 +3147,9 @@ async function handleSnapshotCmd() {
3039
3147
  interactive,
3040
3148
  diff: doDiff,
3041
3149
  annotate: doAnnotate,
3150
+ // Explicit --url only: the daemon re-navigates when the live page
3151
+ // differs (SPA routing may have moved it since the last command).
3152
+ url: getArg('--url'),
3042
3153
  });
3043
3154
  const data = resp.data;
3044
3155
  if (doDiff && data.diff) {